mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-30 19:46:17 +00:00
feat: add in-app browser tab rendered from the keyless napplet sandbox
Adds a "Browser" navigation destination (drawer + pinnable bottom-nav item, API 30+) that opens any URL. The page renders in the sandboxed, keyless `:napplet` process and is streamed into the main activity as a cross-process surface via androidx.privacysandbox.ui (SurfaceControlViewHost) — only pixels and input cross the boundary, never the WebView's JS context or the NIP-07 bridge. The trusted address bar is drawn by the main process around the embedded surface, so the sandbox can never spoof the URL. NIP-07 `window.nostr` is injected the same way nSite website mode does it, but scoped per visited origin: each origin gets its own broker-minted launch token (keyed by the trusted source origin), so a grant to one site never leaks to another. - NappletBrowserService (`:napplet`): hosts the live-URL WebView, exposes it as a SandboxedUiAdapter, and relays the per-origin NIP-07 bridge to the broker. - NappletBrowserUiAdapter: wraps the WebView session for privacysandbox.ui. - NappletBrokerService: mints a per-origin synthetic identity so NIP-07 consent is scoped per host. - EmbeddedBrowserController + BrowserScreen: bind the service, render the SandboxedSdkView, and drive the trusted address bar (navigate/reload/back/Tor). - shim.js: a direct-bridge transport so the injected shim works in a top-level page that has no trusted shell parent. - Browser nav item hidden below API 30 (SurfaceControlViewHost requirement). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
This commit is contained in:
@@ -341,6 +341,11 @@ dependencies {
|
||||
// Hardened WebView host for sandboxed napplet/nsite rendering (origin-restricted message bridge).
|
||||
implementation(libs.androidx.webkit)
|
||||
|
||||
// Client side of the cross-process UI embedding: renders the sandboxed browser surface (hosted in
|
||||
// the keyless `:napplet` process) inside a Compose component in the main app.
|
||||
implementation(libs.androidx.privacysandbox.ui.core)
|
||||
implementation(libs.androidx.privacysandbox.ui.client)
|
||||
|
||||
implementation(libs.androidx.ui)
|
||||
implementation(libs.androidx.ui.graphics)
|
||||
implementation(libs.androidx.ui.tooling.preview)
|
||||
|
||||
@@ -425,6 +425,13 @@
|
||||
android:name=".napplet.NappletBrokerService"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Embedded-browser provider: hosts the in-app browser WebView in the isolated, keyless
|
||||
process and ships its rendered surface to the main app (SurfaceControlViewHost). -->
|
||||
<service
|
||||
android:name="com.vitorpamplona.amethyst.napplethost.NappletBrowserService"
|
||||
android:process=":napplet"
|
||||
android:exported="false" />
|
||||
|
||||
</application>
|
||||
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ import android.os.RemoteException
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletBroker
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter
|
||||
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
|
||||
@@ -115,6 +117,27 @@ class NappletBrokerService : Service() {
|
||||
return true
|
||||
}
|
||||
|
||||
// Browser mode mints a fresh launch token per visited origin, so NIP-07 consent is scoped to the
|
||||
// one site the request came from. The origin is the trusted source origin the WebView reported
|
||||
// (the sandbox can't forge it), and the synthetic identity keys the permission ledger per host.
|
||||
if (msg.what == NappletIpc.MSG_MINT_BROWSER_TOKEN) {
|
||||
val data = msg.data ?: return true
|
||||
val replyTo = msg.replyTo ?: return true
|
||||
val origin = data.getString(NappletIpc.KEY_BROWSER_ORIGIN)?.takeIf { it.isNotBlank() } ?: return true
|
||||
val identity = NappletIdentity(authorPubKey = BROWSER_IDENTITY_AUTHOR, identifier = origin)
|
||||
val token = NappletLaunchRegistry.register(identity, setOf(NappletCapability.IDENTITY, NappletCapability.RELAY))
|
||||
val response =
|
||||
Message.obtain(null, NappletIpc.MSG_BROWSER_TOKEN).apply {
|
||||
this.data =
|
||||
Bundle().apply {
|
||||
putString(NappletIpc.KEY_BROWSER_ORIGIN, origin)
|
||||
putString(NappletIpc.KEY_LAUNCH_TOKEN, token)
|
||||
}
|
||||
}
|
||||
runCatching { replyTo.send(response) }
|
||||
return true
|
||||
}
|
||||
|
||||
if (msg.what != NappletIpc.MSG_REQUEST) return false
|
||||
|
||||
val data = msg.data ?: return true
|
||||
@@ -216,4 +239,14 @@ class NappletBrokerService : Service() {
|
||||
Log.w("NappletBrokerService", "Applet host went away before push could be delivered", e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Sentinel "author" for a browser-mode per-origin identity. The real key is the visited origin,
|
||||
* carried in the identity's identifier (which the consent dialog shows); this constant only fills
|
||||
* the coordinate's author slot so each origin keys the permission ledger separately as
|
||||
* `browser:<origin>`. It is never treated as a real pubkey.
|
||||
*/
|
||||
private const val BROWSER_IDENTITY_AUTHOR = "browser"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,28 @@ import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||
* declared capabilities, and a display title. No account state crosses into the sandbox process.
|
||||
*/
|
||||
object NappletLauncher {
|
||||
/**
|
||||
* Opens the in-app web browser at [url] in the sandboxed [NappletHostActivity] (the keyless
|
||||
* `:napplet` process). Unlike an nSite, it loads an arbitrary **live** URL behind an editable
|
||||
* address bar; it still injects the consent-gated NIP-07 `window.nostr`, scoped per visited origin
|
||||
* (the sandbox mints a per-origin token from the broker). Routes through Tor when Tor is active.
|
||||
*/
|
||||
fun launchBrowser(
|
||||
context: Context,
|
||||
url: String,
|
||||
) {
|
||||
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
|
||||
val intent =
|
||||
Intent(context, NappletHostActivity::class.java).apply {
|
||||
putExtra(NappletHostContract.EXTRA_BROWSER_MODE, true)
|
||||
putExtra(NappletHostContract.EXTRA_BROWSER_URL, url)
|
||||
putExtra(NappletHostContract.EXTRA_PROXY_PORT, proxyPort)
|
||||
putExtra(NappletHostContract.EXTRA_USE_TOR, proxyPort > 0)
|
||||
if (context !is android.app.Activity) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
/** Opens a NIP-5D napplet, forwarding its declared capabilities to the broker. */
|
||||
fun launch(
|
||||
context: Context,
|
||||
|
||||
@@ -76,6 +76,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadat
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.BrowserScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarReminderSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsScreen
|
||||
@@ -293,6 +294,7 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.SoftwareApps> { SoftwareAppsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Napplets> { NappletsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Nsites> { NsitesScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Browser> { BrowserScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.NappletPermissions> { NappletPermissionsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.SoftwareAppDetail> { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
|
||||
composableFromEnd<Route.Calendars> { CalendarsScreen(accountViewModel, nav) }
|
||||
|
||||
+12
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.navigation.bottombars
|
||||
|
||||
import android.os.Build
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
@@ -54,6 +55,7 @@ enum class NavBarItem {
|
||||
SOFTWARE_APPS,
|
||||
NAPPLETS,
|
||||
NSITES,
|
||||
BROWSER,
|
||||
CALENDARS,
|
||||
CALENDAR_COLLECTIONS,
|
||||
SHORTS,
|
||||
@@ -237,6 +239,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
|
||||
icon = MaterialSymbols.Language,
|
||||
resolveRoute = { Route.Nsites },
|
||||
),
|
||||
NavBarItem.BROWSER to
|
||||
NavBarItemDef(
|
||||
id = NavBarItem.BROWSER,
|
||||
labelRes = R.string.browser,
|
||||
icon = MaterialSymbols.Language,
|
||||
resolveRoute = { Route.Browser },
|
||||
),
|
||||
NavBarItem.CALENDARS to
|
||||
NavBarItemDef(
|
||||
id = NavBarItem.CALENDARS,
|
||||
@@ -402,6 +411,9 @@ val DrawerFeedsItems: List<NavBarItem> =
|
||||
NavBarItem.SOFTWARE_APPS,
|
||||
NavBarItem.NAPPLETS,
|
||||
NavBarItem.NSITES,
|
||||
// The embedded browser renders a cross-process surface (SurfaceControlViewHost), which needs
|
||||
// API 30+. Below that the item is hidden so the feature can't be pinned or opened.
|
||||
NavBarItem.BROWSER.takeIf { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R },
|
||||
NavBarItem.CALENDARS,
|
||||
NavBarItem.CALENDAR_COLLECTIONS,
|
||||
NavBarItem.SHORTS,
|
||||
|
||||
@@ -91,6 +91,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object Nsites : Route()
|
||||
|
||||
@Serializable object Browser : Route()
|
||||
|
||||
@Serializable object NappletPermissions : Route()
|
||||
|
||||
@Serializable data class SoftwareAppDetail(
|
||||
|
||||
+3
@@ -101,6 +101,9 @@ private fun PreloadFor(
|
||||
|
||||
NavBarItem.NSITES -> {}
|
||||
|
||||
// The browser is a "new tab" launcher with no feed to preload.
|
||||
NavBarItem.BROWSER -> {}
|
||||
|
||||
NavBarItem.CALENDARS,
|
||||
NavBarItem.CALENDAR_COLLECTIONS,
|
||||
-> CalendarsFilterAssemblerSubscription(accountViewModel)
|
||||
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.browser
|
||||
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
/**
|
||||
* The in-app web browser tab. The page renders in the keyless `:napplet` process and is streamed into
|
||||
* this (key-holding) main process as a surface (see [EmbeddedBrowserController]); the trusted address
|
||||
* bar is drawn here, around the embedded surface, so the sandbox can never spoof the URL. NIP-07
|
||||
* `window.nostr` is injected in the sandbox, consent-gated and scoped per visited origin.
|
||||
*
|
||||
* Requires API 30+ (SurfaceControlViewHost); below that the Browser nav item is hidden, so this screen
|
||||
* is unreachable — the fallback message is just defense in depth.
|
||||
*/
|
||||
@Composable
|
||||
fun BrowserScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
EmbeddedBrowser()
|
||||
} else {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
stringResource(R.string.browser_unsupported),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
@Composable
|
||||
private fun EmbeddedBrowser() {
|
||||
val context = LocalContext.current
|
||||
val proxyPort = remember { Amethyst.instance.torManager.activePortOrNull.value ?: -1 }
|
||||
|
||||
var address by remember { mutableStateOf("") }
|
||||
var canGoBack by remember { mutableStateOf(false) }
|
||||
var torOn by remember { mutableStateOf(proxyPort > 0) }
|
||||
|
||||
val controller =
|
||||
remember {
|
||||
EmbeddedBrowserController(context.applicationContext, proxyPort, proxyPort > 0).apply {
|
||||
onUrlChanged = { url, back ->
|
||||
if (url != "about:blank") address = url
|
||||
canGoBack = back
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
controller.bind("about:blank")
|
||||
onDispose { controller.unbind() }
|
||||
}
|
||||
|
||||
BackHandler(enabled = canGoBack) { controller.back() }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
BrowserAddressBar(
|
||||
address = address,
|
||||
onAddressChange = { address = it },
|
||||
onGo = { controller.navigate(address) },
|
||||
onReload = { controller.reload() },
|
||||
onBack = { controller.back() },
|
||||
canGoBack = canGoBack,
|
||||
showTor = proxyPort > 0,
|
||||
torOn = torOn,
|
||||
onToggleTor = {
|
||||
torOn = !torOn
|
||||
controller.setTor(torOn)
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
AndroidView(
|
||||
factory = { ctx -> SandboxedSdkView(ctx).also { controller.attachView(it) } },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BrowserAddressBar(
|
||||
address: String,
|
||||
onAddressChange: (String) -> Unit,
|
||||
onGo: () -> Unit,
|
||||
onReload: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
canGoBack: Boolean,
|
||||
showTor: Boolean,
|
||||
torOn: Boolean,
|
||||
onToggleTor: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onBack, enabled = canGoBack) {
|
||||
Icon(MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringResource(R.string.back))
|
||||
}
|
||||
TextField(
|
||||
value = address,
|
||||
onValueChange = onAddressChange,
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
placeholder = { Text(stringResource(R.string.browser_address_hint)) },
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Go,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onGo = { onGo() }),
|
||||
colors =
|
||||
TextFieldDefaults.colors(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
if (showTor) {
|
||||
IconButton(onClick = onToggleTor) {
|
||||
Icon(
|
||||
MaterialSymbols.Security,
|
||||
contentDescription = stringResource(if (torOn) R.string.browser_tor_on else R.string.browser_tor_off),
|
||||
tint = if (torOn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onReload) {
|
||||
Icon(MaterialSymbols.Refresh, contentDescription = stringResource(R.string.browser_reload))
|
||||
}
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.browser
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.Message
|
||||
import android.os.Messenger
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory
|
||||
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
|
||||
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletBrowserContract
|
||||
|
||||
/**
|
||||
* Client-side handle to the embedded browser. Binds [NappletBrowserService] (in the keyless `:napplet`
|
||||
* process), hands its `SandboxedUiAdapter` to a [SandboxedSdkView] so the remote WebView renders inside
|
||||
* the main activity, and relays chrome controls (navigate/reload/back/Tor) while receiving URL updates
|
||||
* that drive the trusted, main-process address bar.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
class EmbeddedBrowserController(
|
||||
private val appContext: Context,
|
||||
private val proxyPort: Int,
|
||||
private val initialUseTor: Boolean,
|
||||
) {
|
||||
private val incoming = Messenger(Handler(Looper.getMainLooper(), ::onServiceMessage))
|
||||
private var serviceMessenger: Messenger? = null
|
||||
private var bound = false
|
||||
|
||||
private var sandboxedSdkView: SandboxedSdkView? = null
|
||||
private var pendingAdapter: SandboxedUiAdapter? = null
|
||||
private var startUrl: String = "about:blank"
|
||||
|
||||
/** Invoked on the main thread when the page navigates: (url, canGoBack). */
|
||||
var onUrlChanged: ((String, Boolean) -> Unit)? = null
|
||||
|
||||
private val connection =
|
||||
object : ServiceConnection {
|
||||
override fun onServiceConnected(
|
||||
name: ComponentName?,
|
||||
service: IBinder?,
|
||||
) {
|
||||
serviceMessenger = Messenger(service)
|
||||
sendCreateSession()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
serviceMessenger = null
|
||||
}
|
||||
}
|
||||
|
||||
fun bind(startUrl: String) {
|
||||
this.startUrl = startUrl
|
||||
val intent = Intent().setClassName(appContext, NappletBrowserContract.BROWSER_SERVICE_CLASS)
|
||||
bound = appContext.bindService(intent, connection, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
|
||||
fun unbind() {
|
||||
if (bound) {
|
||||
runCatching { appContext.unbindService(connection) }
|
||||
bound = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Hands the surface view to the controller; applies the adapter if it already arrived. */
|
||||
fun attachView(view: SandboxedSdkView) {
|
||||
sandboxedSdkView = view
|
||||
pendingAdapter?.let {
|
||||
view.setAdapter(it)
|
||||
pendingAdapter = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendCreateSession() {
|
||||
val msg =
|
||||
Message.obtain(null, NappletBrowserContract.MSG_CREATE_SESSION).apply {
|
||||
replyTo = incoming
|
||||
data =
|
||||
Bundle().apply {
|
||||
putString(NappletBrowserContract.KEY_URL, startUrl)
|
||||
putInt(NappletBrowserContract.KEY_PROXY_PORT, proxyPort)
|
||||
putBoolean(NappletBrowserContract.KEY_USE_TOR, initialUseTor)
|
||||
}
|
||||
}
|
||||
runCatching { serviceMessenger?.send(msg) }
|
||||
}
|
||||
|
||||
private fun onServiceMessage(msg: Message): Boolean {
|
||||
when (msg.what) {
|
||||
NappletBrowserContract.MSG_SESSION_READY -> {
|
||||
val coreLibInfo = msg.data?.getBundle(NappletBrowserContract.KEY_CORE_LIB_INFO) ?: return true
|
||||
val adapter = SandboxedUiAdapterFactory.createFromCoreLibInfo(coreLibInfo)
|
||||
val view = sandboxedSdkView
|
||||
if (view != null) view.setAdapter(adapter) else pendingAdapter = adapter
|
||||
}
|
||||
NappletBrowserContract.MSG_URL_CHANGED -> {
|
||||
val url = msg.data?.getString(NappletBrowserContract.KEY_URL).orEmpty()
|
||||
val canGoBack = msg.data?.getBoolean(NappletBrowserContract.KEY_CAN_GO_BACK, false) ?: false
|
||||
onUrlChanged?.invoke(url, canGoBack)
|
||||
}
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun navigate(url: String) = send(NappletBrowserContract.MSG_NAVIGATE) { putString(NappletBrowserContract.KEY_URL, url) }
|
||||
|
||||
fun reload() = send(NappletBrowserContract.MSG_RELOAD) {}
|
||||
|
||||
fun back() = send(NappletBrowserContract.MSG_BACK) {}
|
||||
|
||||
fun setTor(useTor: Boolean) = send(NappletBrowserContract.MSG_SET_TOR) { putBoolean(NappletBrowserContract.KEY_USE_TOR, useTor) }
|
||||
|
||||
private inline fun send(
|
||||
what: Int,
|
||||
crossinline block: Bundle.() -> Unit,
|
||||
) {
|
||||
val msg = Message.obtain(null, what).apply { data = Bundle().apply(block) }
|
||||
runCatching { serviceMessenger?.send(msg) }
|
||||
}
|
||||
}
|
||||
@@ -666,6 +666,12 @@
|
||||
<string name="napplets">nApplets</string>
|
||||
<string name="nsites">nSites</string>
|
||||
<string name="nsite_none_found">No nSites found yet.</string>
|
||||
<string name="browser">Browser</string>
|
||||
<string name="browser_address_hint">Search or enter address</string>
|
||||
<string name="browser_reload">Reload</string>
|
||||
<string name="browser_tor_on">Loading over Tor. Tap to use the open web.</string>
|
||||
<string name="browser_tor_off">Loading over the open web. Tap to use Tor.</string>
|
||||
<string name="browser_unsupported">The in-app browser needs Android 11 or newer.</string>
|
||||
<string name="napplet_permissions">nApplet permissions</string>
|
||||
<string name="napplet_manage_permissions">Manage permissions</string>
|
||||
<string name="napplet_permissions_empty">No nApplet permissions yet</string>
|
||||
|
||||
@@ -47,7 +47,23 @@
|
||||
})();
|
||||
|
||||
var seq = 0, pending = {}, subs = {}, actions = {}, identityHandlers = [];
|
||||
function send(env){ env.id = env.id || ('r' + (seq++)); parent.postMessage(JSON.stringify(env), '*'); return env.id; }
|
||||
// Transport. A napplet/nSite runs inside the trusted shell's iframe and talks to the shell via
|
||||
// postMessage (the shell relays to the native bridge). A top-level page opened in the in-app browser
|
||||
// has no shell parent, so it talks to the origin-scoped native bridge object (__nappletBridge,
|
||||
// injected for the page) directly. __nappletDirectBridge selects that path.
|
||||
var DIRECT = false; try { DIRECT = !!window.__nappletDirectBridge; } catch (_) {}
|
||||
var recvWired = false;
|
||||
function rawSend(s){
|
||||
if (DIRECT) {
|
||||
var b = window.__nappletBridge; if (!b) return;
|
||||
// Wire the reply channel before the first send, so no reply can arrive before we listen.
|
||||
if (!recvWired) { recvWired = true; b.onmessage = function(e){ onIncoming(e.data); }; }
|
||||
b.postMessage(s);
|
||||
} else {
|
||||
parent.postMessage(s, '*');
|
||||
}
|
||||
}
|
||||
function send(env){ env.id = env.id || ('r' + (seq++)); rawSend(JSON.stringify(env)); return env.id; }
|
||||
function call(type, fields){
|
||||
return new Promise(function(resolve, reject){
|
||||
var env = { type: type }; if (fields) for (var k in fields) env[k] = fields[k];
|
||||
@@ -57,9 +73,8 @@
|
||||
}
|
||||
// Fire-and-forget (no .result awaited), used for subscribe/unsubscribe.
|
||||
function post(type, fields){ var env = { type: type }; if (fields) for (var k in fields) env[k] = fields[k]; send(env); }
|
||||
window.addEventListener('message', function(e){
|
||||
if (e.source !== parent) return;
|
||||
var msg; if (typeof e.data === 'string') { try { msg = JSON.parse(e.data); } catch (_) { return; } } else { msg = e.data; }
|
||||
function onIncoming(raw){
|
||||
var msg; if (typeof raw === 'string') { try { msg = JSON.parse(raw); } catch (_) { return; } } else { msg = raw; }
|
||||
if (!msg) return;
|
||||
// Subscription pushes are keyed by subId, not a request id.
|
||||
if (msg.type === 'relay.event' || msg.type === 'relay.eose' || msg.type === 'relay.closed') {
|
||||
@@ -77,7 +92,12 @@
|
||||
var p = pending[msg.id]; if (!p) return; delete pending[msg.id];
|
||||
if (msg.ok) p.resolve(msg);
|
||||
else { var err = new Error(msg.reason || msg.operation || msg.error || 'napplet error'); err.napplet = msg; p.reject(err); }
|
||||
});
|
||||
}
|
||||
// The shell-relayed path listens for window messages from the parent; the direct-bridge path wires
|
||||
// its receive channel in rawSend (above) the first time it posts.
|
||||
if (!DIRECT) {
|
||||
window.addEventListener('message', function(e){ if (e.source !== parent) return; onIncoming(e.data); });
|
||||
}
|
||||
function field(promise, name){ return promise.then(function(m){ return m[name]; }); }
|
||||
function normFilters(filters){ return Array.isArray(filters) ? { filters: filters } : { filter: filters || {} }; }
|
||||
function bytesToB64(bytes){ var u = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); var s=''; for (var i=0;i<u.length;i++) s+=String.fromCharCode(u[i]); return btoa(s); }
|
||||
|
||||
@@ -77,6 +77,8 @@ thumbnailator = "0.4.21"
|
||||
zxing = "3.5.4"
|
||||
zxingAndroidEmbedded = "4.3.0"
|
||||
webkit = "1.12.1"
|
||||
# Cross-process UI embedding (SurfaceControlViewHost wrapper) for the in-app browser surface. Apache-2.0.
|
||||
privacysandboxUi = "1.0.0-alpha10"
|
||||
windowCoreAndroid = "1.5.1"
|
||||
workRuntime = "2.11.2"
|
||||
androidxCamera = "1.6.1"
|
||||
@@ -210,6 +212,9 @@ unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", ve
|
||||
play-services-cast-framework = { group = "com.google.android.gms", name = "play-services-cast-framework", version.ref = "playServicesCast" }
|
||||
vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts-compose" }
|
||||
androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
|
||||
androidx-privacysandbox-ui-core = { group = "androidx.privacysandbox.ui", name = "ui-core", version.ref = "privacysandboxUi" }
|
||||
androidx-privacysandbox-ui-client = { group = "androidx.privacysandbox.ui", name = "ui-client", version.ref = "privacysandboxUi" }
|
||||
androidx-privacysandbox-ui-provider = { group = "androidx.privacysandbox.ui", name = "ui-provider", version.ref = "privacysandboxUi" }
|
||||
vico-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico-charts-compose" }
|
||||
zelory-image-compressor = { group = "id.zelory", name = "compressor", version.ref = "zelory" }
|
||||
zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" }
|
||||
|
||||
@@ -35,4 +35,9 @@ dependencies {
|
||||
implementation(libs.androidx.activity)
|
||||
implementation(libs.androidx.webkit)
|
||||
implementation(libs.okhttp)
|
||||
|
||||
// Provider side of the cross-process UI embedding: hosts the browser WebView in this keyless
|
||||
// `:napplet` process and ships its rendered surface to the main app via SurfaceControlViewHost.
|
||||
implementation(libs.androidx.privacysandbox.ui.core)
|
||||
implementation(libs.androidx.privacysandbox.ui.provider)
|
||||
}
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.napplethost
|
||||
|
||||
/**
|
||||
* Messenger contract between the main app (client) and [NappletBrowserService] (provider, in the
|
||||
* keyless `:napplet` process) for the **embedded** in-app browser. The provider hosts the WebView and
|
||||
* ships its rendered surface back through `androidx.privacysandbox.ui` (SurfaceControlViewHost), so the
|
||||
* page never renders in the key-holding main process. This channel only carries the surface handshake
|
||||
* (the `coreLibInfo` Bundle) plus chrome controls (navigate/reload/back/Tor) and URL updates — the
|
||||
* trusted address bar is drawn by the main process around the embedded surface.
|
||||
*/
|
||||
object NappletBrowserContract {
|
||||
/** FQN of the provider service, bound by name so the client needs no compile-time reference. */
|
||||
const val BROWSER_SERVICE_CLASS = "com.vitorpamplona.amethyst.napplethost.NappletBrowserService"
|
||||
|
||||
/** Client → provider: create a browser session. Carries [KEY_URL], [KEY_PROXY_PORT], [KEY_USE_TOR]. */
|
||||
const val MSG_CREATE_SESSION = 1
|
||||
|
||||
/** Provider → client: the session's [KEY_CORE_LIB_INFO] Bundle (the SandboxedUiAdapter handle). */
|
||||
const val MSG_SESSION_READY = 2
|
||||
|
||||
/** Client → provider: load [KEY_URL]. */
|
||||
const val MSG_NAVIGATE = 3
|
||||
|
||||
/** Client → provider: reload the current page. */
|
||||
const val MSG_RELOAD = 4
|
||||
|
||||
/** Client → provider: go back in the page history. */
|
||||
const val MSG_BACK = 5
|
||||
|
||||
/** Client → provider: route this session over Tor ([KEY_USE_TOR]) or the open web. */
|
||||
const val MSG_SET_TOR = 6
|
||||
|
||||
/** Provider → client: the page navigated; carries [KEY_URL] and [KEY_CAN_GO_BACK]. */
|
||||
const val MSG_URL_CHANGED = 7
|
||||
|
||||
const val KEY_URL = "url"
|
||||
const val KEY_PROXY_PORT = "proxyPort"
|
||||
const val KEY_USE_TOR = "useTor"
|
||||
const val KEY_CORE_LIB_INFO = "coreLibInfo"
|
||||
const val KEY_CAN_GO_BACK = "canGoBack"
|
||||
}
|
||||
+373
@@ -0,0 +1,373 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.napplethost
|
||||
|
||||
import android.app.Service
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.Message
|
||||
import android.os.Messenger
|
||||
import android.util.Log
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.privacysandbox.ui.provider.toCoreLibInfo
|
||||
import androidx.webkit.JavaScriptReplyProxy
|
||||
import androidx.webkit.WebMessageCompat
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Provider for the **embedded** in-app browser. Runs in the keyless `:napplet` process: it hosts the
|
||||
* live-URL WebView and exposes it to the main app as a `SandboxedUiAdapter` (the main app renders it
|
||||
* inside a `SandboxedSdkView`, so only pixels + input cross the process boundary — never the WebView's
|
||||
* JS context, cookies, or the NIP-07 bridge). Keys still live only in the main process; every NIP-07
|
||||
* `window.nostr` call is brokered and consent-gated there, per visited origin.
|
||||
*
|
||||
* Mirrors [NappletHostActivity]'s browser path, but as a windowless Service so the surface can be
|
||||
* embedded in the main activity rather than taking over the screen. Requires API 30+
|
||||
* (SurfaceControlViewHost); the feature is hidden below that.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
class NappletBrowserService : Service() {
|
||||
private val incoming = Messenger(Handler(Looper.getMainLooper(), ::onClientMessage))
|
||||
|
||||
// The main-process client, kept to push URL/state updates that drive its address bar.
|
||||
private var clientMessenger: Messenger? = null
|
||||
|
||||
// Session config captured at create time; consumed when the library opens the session (WebView built).
|
||||
private var pendingUrl: String = "about:blank"
|
||||
private var proxyPort: Int = -1
|
||||
private var useTor: Boolean = false
|
||||
|
||||
private var webView: WebView? = null
|
||||
|
||||
// ---- broker bridge (identical trust model to NappletHostActivity) ----
|
||||
private var brokerMessenger: Messenger? = null
|
||||
private val replyMessenger = Messenger(Handler(Looper.getMainLooper(), ::onBrokerReply))
|
||||
private val pendingBrokerRequests = mutableListOf<Message>()
|
||||
private var bridgeReplyProxy: JavaScriptReplyProxy? = null
|
||||
private var fireSeq = 0
|
||||
|
||||
// Per visited origin: its broker-minted launch token, the requests queued until it arrives, and the
|
||||
// set of origins a mint is already in flight for — so NIP-07 consent is scoped per site.
|
||||
private val originTokens = mutableMapOf<String, String>()
|
||||
private val pendingByOrigin = mutableMapOf<String, MutableList<Message>>()
|
||||
private val mintInFlight = mutableSetOf<String>()
|
||||
|
||||
private val brokerConnection =
|
||||
object : ServiceConnection {
|
||||
override fun onServiceConnected(
|
||||
name: ComponentName?,
|
||||
service: IBinder?,
|
||||
) {
|
||||
brokerMessenger = Messenger(service)
|
||||
pendingBrokerRequests.forEach { sendToBroker(it) }
|
||||
pendingBrokerRequests.clear()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
brokerMessenger = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder = incoming.binder
|
||||
|
||||
override fun onDestroy() {
|
||||
runCatching { unbindService(brokerConnection) }
|
||||
webView?.destroy()
|
||||
webView = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun onClientMessage(msg: Message): Boolean {
|
||||
when (msg.what) {
|
||||
NappletBrowserContract.MSG_CREATE_SESSION -> {
|
||||
val data = msg.data ?: return true
|
||||
clientMessenger = msg.replyTo
|
||||
pendingUrl = data.getString(NappletBrowserContract.KEY_URL)?.ifBlank { "about:blank" } ?: "about:blank"
|
||||
proxyPort = data.getInt(NappletBrowserContract.KEY_PROXY_PORT, -1)
|
||||
useTor = data.getBoolean(NappletBrowserContract.KEY_USE_TOR, false)
|
||||
bindService(Intent().setClassName(this, NappletHostContract.BROKER_SERVICE_CLASS), brokerConnection, BIND_AUTO_CREATE)
|
||||
replyWithAdapter()
|
||||
}
|
||||
NappletBrowserContract.MSG_NAVIGATE -> webView?.loadUrl(normalizeUrl(msg.data?.getString(NappletBrowserContract.KEY_URL).orEmpty()))
|
||||
NappletBrowserContract.MSG_RELOAD -> webView?.reload()
|
||||
NappletBrowserContract.MSG_BACK -> webView?.let { if (it.canGoBack()) it.goBack() }
|
||||
NappletBrowserContract.MSG_SET_TOR -> {
|
||||
useTor = msg.data?.getBoolean(NappletBrowserContract.KEY_USE_TOR, false) ?: false
|
||||
applyWebViewProxy(if (useTor) proxyPort else -1)
|
||||
webView?.reload()
|
||||
}
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Builds the SandboxedUiAdapter and ships its cross-process handle (coreLibInfo) to the client. */
|
||||
private fun replyWithAdapter() {
|
||||
val adapter = NappletBrowserUiAdapter(this)
|
||||
val coreLibInfo = adapter.toCoreLibInfo(this)
|
||||
val reply =
|
||||
Message.obtain(null, NappletBrowserContract.MSG_SESSION_READY).apply {
|
||||
data = Bundle().apply { putBundle(NappletBrowserContract.KEY_CORE_LIB_INFO, coreLibInfo) }
|
||||
}
|
||||
runCatching { clientMessenger?.send(reply) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and configures the session's WebView (called by the adapter on the main thread when the
|
||||
* client attaches the surface). Injects the same NIP-07 shim [NappletHostActivity] uses, at document
|
||||
* start for every origin, reaching the broker directly (no shell). Loads the pending URL.
|
||||
*/
|
||||
fun createBrowserWebView(context: Context): WebView {
|
||||
val wv = WebView(context)
|
||||
configureWebView(wv)
|
||||
applyWebViewProxy(if (useTor) proxyPort else -1)
|
||||
val shim = readContractAsset(NappletWebContract.SHIM_JS_PATH).decodeToString()
|
||||
WebViewCompat.addWebMessageListener(wv, NappletWebContract.BRIDGE_NAME, setOf("*"), ::onBridgeMessage)
|
||||
val startScript = "if (window.top === window) { window.__nappletDirectBridge = true; window.__nappletNip07 = true; }\n$shim"
|
||||
WebViewCompat.addDocumentStartJavaScript(wv, startScript, setOf("*"))
|
||||
webView = wv
|
||||
wv.loadUrl(pendingUrl)
|
||||
return wv
|
||||
}
|
||||
|
||||
fun onSessionClosed() {
|
||||
webView?.destroy()
|
||||
webView = null
|
||||
}
|
||||
|
||||
@Suppress("SetJavaScriptEnabled")
|
||||
private fun configureWebView(wv: WebView) {
|
||||
wv.settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = false
|
||||
allowFileAccess = false
|
||||
allowContentAccess = false
|
||||
@Suppress("DEPRECATION")
|
||||
allowFileAccessFromFileURLs = false
|
||||
@Suppress("DEPRECATION")
|
||||
allowUniversalAccessFromFileURLs = false
|
||||
javaScriptCanOpenWindowsAutomatically = false
|
||||
setSupportMultipleWindows(false)
|
||||
setGeolocationEnabled(false)
|
||||
mediaPlaybackRequiresUserGesture = true
|
||||
builtInZoomControls = true
|
||||
displayZoomControls = false
|
||||
loadWithOverviewMode = true
|
||||
useWideViewPort = true
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
||||
if (WebViewFeature.isFeatureSupported(WebViewFeature.SAFE_BROWSING_ENABLE)) {
|
||||
safeBrowsingEnabled = true
|
||||
}
|
||||
}
|
||||
WebView.setWebContentsDebuggingEnabled(false)
|
||||
wv.webViewClient = BrowserClient()
|
||||
}
|
||||
|
||||
/** Loads live web pages in-WebView (http/https) and hands other schemes to the system on a user tap. */
|
||||
private inner class BrowserClient : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
): Boolean {
|
||||
val uri = request.url
|
||||
val scheme = uri.scheme?.lowercase()
|
||||
if (scheme == "http" || scheme == "https") return false
|
||||
if (request.hasGesture()) {
|
||||
runCatching { startActivity(Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPageStarted(
|
||||
view: WebView,
|
||||
url: String,
|
||||
favicon: android.graphics.Bitmap?,
|
||||
) = pushUrl(view)
|
||||
|
||||
override fun doUpdateVisitedHistory(
|
||||
view: WebView,
|
||||
url: String,
|
||||
isReload: Boolean,
|
||||
) = pushUrl(view)
|
||||
|
||||
override fun onPageFinished(
|
||||
view: WebView,
|
||||
url: String,
|
||||
) = pushUrl(view)
|
||||
}
|
||||
|
||||
private fun pushUrl(view: WebView) {
|
||||
val url = view.url ?: return
|
||||
val message =
|
||||
Message.obtain(null, NappletBrowserContract.MSG_URL_CHANGED).apply {
|
||||
data =
|
||||
Bundle().apply {
|
||||
putString(NappletBrowserContract.KEY_URL, url)
|
||||
putBoolean(NappletBrowserContract.KEY_CAN_GO_BACK, view.canGoBack())
|
||||
}
|
||||
}
|
||||
runCatching { clientMessenger?.send(message) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes WebView traffic through the Tor SOCKS proxy when [port] > 0, else clears the override.
|
||||
* Process-global (this `:napplet` process hosts only sandbox WebViews) and best-effort.
|
||||
*/
|
||||
private fun applyWebViewProxy(port: Int) {
|
||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) return
|
||||
val executor = java.util.concurrent.Executor { it.run() }
|
||||
runCatching {
|
||||
if (port > 0) {
|
||||
val config =
|
||||
androidx.webkit.ProxyConfig
|
||||
.Builder()
|
||||
.addProxyRule("socks5://127.0.0.1:$port")
|
||||
.build()
|
||||
androidx.webkit.ProxyController
|
||||
.getInstance()
|
||||
.setProxyOverride(config, executor) {}
|
||||
} else {
|
||||
androidx.webkit.ProxyController
|
||||
.getInstance()
|
||||
.clearProxyOverride(executor) {}
|
||||
}
|
||||
}.onFailure { Log.w(TAG, "Failed to apply WebView proxy override", it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* A top-frame NIP-07 call from [sourceOrigin]. The origin is the trusted value the WebView reports
|
||||
* (the page can't forge it), so it keys consent; each origin uses its own broker-minted launch token.
|
||||
*/
|
||||
private fun onBridgeMessage(
|
||||
view: WebView,
|
||||
message: WebMessageCompat,
|
||||
sourceOrigin: Uri,
|
||||
isMainFrame: Boolean,
|
||||
replyProxy: JavaScriptReplyProxy,
|
||||
) {
|
||||
if (!isMainFrame) return
|
||||
bridgeReplyProxy = replyProxy
|
||||
val raw = message.data ?: return
|
||||
val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return
|
||||
|
||||
val scheme = sourceOrigin.scheme ?: return
|
||||
val host = sourceOrigin.host ?: return
|
||||
val origin = "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else ""
|
||||
|
||||
val id = envelope.optString("id").ifEmpty { "fire-${fireSeq++}" }
|
||||
val msg =
|
||||
Message.obtain(null, NappletIpc.MSG_REQUEST).apply {
|
||||
replyTo = replyMessenger
|
||||
data =
|
||||
Bundle().apply {
|
||||
putString(NappletIpc.KEY_REQUEST_ID, id)
|
||||
putString(NappletIpc.KEY_PAYLOAD, raw)
|
||||
}
|
||||
}
|
||||
|
||||
val token = originTokens[origin]
|
||||
if (token != null) {
|
||||
msg.data.putString(NappletIpc.KEY_LAUNCH_TOKEN, token)
|
||||
if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg)
|
||||
} else {
|
||||
pendingByOrigin.getOrPut(origin) { mutableListOf() }.add(msg)
|
||||
requestBrowserToken(origin)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestBrowserToken(origin: String) {
|
||||
if (!mintInFlight.add(origin)) return
|
||||
val msg =
|
||||
Message.obtain(null, NappletIpc.MSG_MINT_BROWSER_TOKEN).apply {
|
||||
replyTo = replyMessenger
|
||||
data = Bundle().apply { putString(NappletIpc.KEY_BROWSER_ORIGIN, origin) }
|
||||
}
|
||||
if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg)
|
||||
}
|
||||
|
||||
private fun sendToBroker(msg: Message) {
|
||||
try {
|
||||
brokerMessenger?.send(msg)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to deliver request to broker", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onBrokerReply(msg: Message): Boolean {
|
||||
val data = msg.data ?: return true
|
||||
when (msg.what) {
|
||||
NappletIpc.MSG_RESPONSE -> {
|
||||
val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true
|
||||
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
|
||||
val result = runCatching { JSONObject(payload) }.getOrNull() ?: JSONObject()
|
||||
result.put("id", id)
|
||||
bridgeReplyProxy?.postMessage(result.toString())
|
||||
}
|
||||
NappletIpc.MSG_PUSH -> {
|
||||
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
|
||||
bridgeReplyProxy?.postMessage(payload)
|
||||
}
|
||||
NappletIpc.MSG_BROWSER_TOKEN -> {
|
||||
val origin = data.getString(NappletIpc.KEY_BROWSER_ORIGIN) ?: return true
|
||||
val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) ?: return true
|
||||
originTokens[origin] = token
|
||||
mintInFlight.remove(origin)
|
||||
pendingByOrigin.remove(origin)?.forEach { queued ->
|
||||
queued.data.putString(NappletIpc.KEY_LAUNCH_TOKEN, token)
|
||||
sendToBroker(queued)
|
||||
}
|
||||
}
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun readContractAsset(path: String): ByteArray = assets.open(NappletWebContract.RESOURCE_ASSET_ROOT + path).use { it.readBytes() }
|
||||
|
||||
/** Address-bar text → URL: keep an explicit scheme, prefix a bare domain, else DuckDuckGo search. */
|
||||
private fun normalizeUrl(input: String): String {
|
||||
val text = input.trim()
|
||||
if (text.isEmpty()) return "about:blank"
|
||||
if (text.contains("://")) return text
|
||||
if (!text.contains(' ') && text.contains('.')) return "https://$text"
|
||||
return "https://duckduckgo.com/?q=" + Uri.encode(text)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val TAG = "NappletBrowserService"
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.napplethost
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.WebView
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
|
||||
import androidx.privacysandbox.ui.provider.AbstractSandboxedUiAdapter
|
||||
import java.util.concurrent.Executor
|
||||
|
||||
/**
|
||||
* Exposes the browser WebView (built by [NappletBrowserService]) as a `SandboxedUiAdapter`. The
|
||||
* `androidx.privacysandbox.ui` machinery wraps the returned view in a SurfaceControlViewHost and ships
|
||||
* its surface to the main app's `SandboxedSdkView`; only pixels + input cross the process boundary.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
class NappletBrowserUiAdapter(
|
||||
private val service: NappletBrowserService,
|
||||
) : AbstractSandboxedUiAdapter() {
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
override fun openSession(
|
||||
context: Context,
|
||||
windowInputToken: IBinder,
|
||||
initialWidth: Int,
|
||||
initialHeight: Int,
|
||||
isZOrderOnTop: Boolean,
|
||||
clientExecutor: Executor,
|
||||
client: SandboxedUiAdapter.SessionClient,
|
||||
) {
|
||||
// WebView creation must run on the main thread; openSession is called on a binder thread.
|
||||
mainHandler.post {
|
||||
runCatching {
|
||||
val webView = service.createBrowserWebView(context)
|
||||
webView.layoutParams = ViewGroup.LayoutParams(initialWidth, initialHeight)
|
||||
BrowserSession(webView, service)
|
||||
}.onSuccess { session -> clientExecutor.execute { client.onSessionOpened(session) } }
|
||||
.onFailure { t -> clientExecutor.execute { client.onSessionError(t) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A single embedded browser session: the WebView is the rendered view; close tears it down. */
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
private class BrowserSession(
|
||||
private val webView: WebView,
|
||||
private val service: NappletBrowserService,
|
||||
) : SandboxedUiAdapter.Session {
|
||||
override val view: View get() = webView
|
||||
|
||||
override val signalOptions: Set<String> = emptySet()
|
||||
|
||||
override fun notifyResized(
|
||||
width: Int,
|
||||
height: Int,
|
||||
) {
|
||||
webView.layoutParams = ViewGroup.LayoutParams(width, height)
|
||||
webView.requestLayout()
|
||||
}
|
||||
|
||||
override fun notifyZOrderChanged(isZOrderOnTop: Boolean) {}
|
||||
|
||||
override fun notifyConfigurationChanged(configuration: Configuration) {}
|
||||
|
||||
override fun notifyUiChanged(uiContainerInfo: Bundle) {}
|
||||
|
||||
override fun close() {
|
||||
service.onSessionClosed()
|
||||
}
|
||||
}
|
||||
+352
-2
@@ -24,6 +24,7 @@ import android.app.AlertDialog
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
@@ -31,18 +32,22 @@ import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.Message
|
||||
import android.os.Messenger
|
||||
import android.text.InputType
|
||||
import android.util.Log
|
||||
import android.util.TypedValue
|
||||
import android.view.Gravity
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
@@ -111,6 +116,22 @@ class NappletHostActivity : ComponentActivity() {
|
||||
// nSite "website mode": a normal web app (NIP-07 window.nostr + normal network), vs a locked napplet.
|
||||
private var websiteMode: Boolean = false
|
||||
|
||||
// "Browser mode": render an arbitrary live URL with an editable address bar (see EXTRA_BROWSER_MODE).
|
||||
// No manifest/content server; NIP-07 is injected per visited origin.
|
||||
private var browserMode: Boolean = false
|
||||
|
||||
// The address bar shown in browser mode.
|
||||
private var addressBar: android.widget.EditText? = null
|
||||
|
||||
// Visited origin (e.g. https://example.com) → its broker-minted launch token, plus the requests that
|
||||
// arrived for an origin before its token was minted, and the set of origins a mint is in flight for.
|
||||
private val browserOriginTokens = mutableMapOf<String, String>()
|
||||
private val browserPendingByOrigin = mutableMapOf<String, MutableList<Message>>()
|
||||
private val browserMintInFlight = mutableSetOf<String>()
|
||||
|
||||
// The onion toggle in the browser bar, kept so its tint can follow the live useTor state.
|
||||
private var browserTorIcon: ImageView? = null
|
||||
|
||||
// Per-site network choice: route this site's traffic through Tor (default) or over the open web.
|
||||
// Applied to both the WebView proxy and the blob-fetch client; toggled from the top-bar onion.
|
||||
private var useTor: Boolean = true
|
||||
@@ -183,6 +204,11 @@ class NappletHostActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (intent.getBooleanExtra(NappletHostContract.EXTRA_BROWSER_MODE, false)) {
|
||||
setupBrowser()
|
||||
return
|
||||
}
|
||||
|
||||
if (!readManifestExtras()) {
|
||||
Toast.makeText(this, getString(R.string.napplet_invalid), Toast.LENGTH_SHORT).show()
|
||||
finish()
|
||||
@@ -199,8 +225,7 @@ class NappletHostActivity : ComponentActivity() {
|
||||
// once up front so the WebView worker threads that serve them never block on resource I/O.
|
||||
// This isolated `:napplet` process skips app init (to stay key-free), so the compose-resources
|
||||
// Android context is never set here and `Res.readBytes` would crash — read the contract bytes
|
||||
// straight from the APK assets, where compose-resources packages them.
|
||||
fun readContractAsset(path: String): ByteArray = assets.open(NappletWebContract.RESOURCE_ASSET_ROOT + path).use { it.readBytes() }
|
||||
// straight from the APK assets, where compose-resources packages them (see [readContractAsset]).
|
||||
val shellHtml = readContractAsset(NappletWebContract.SHELL_HTML_PATH)
|
||||
val shim = readContractAsset(NappletWebContract.SHIM_JS_PATH).decodeToString()
|
||||
val appOrigin = NappletWebContract.appOrigin(deriveAppId(author, identifier))
|
||||
@@ -358,6 +383,315 @@ class NappletHostActivity : ComponentActivity() {
|
||||
return author.isNotEmpty() && launchToken.isNotEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a shared napplet web-contract asset (shell HTML / shim JS) straight from the APK assets. This
|
||||
* isolated `:napplet` process skips app init to stay key-free, so the compose-resources Android context
|
||||
* is never set and `Res.readBytes` would crash; the bytes are packaged under
|
||||
* [NappletWebContract.RESOURCE_ASSET_ROOT].
|
||||
*/
|
||||
private fun readContractAsset(path: String): ByteArray = assets.open(NappletWebContract.RESOURCE_ASSET_ROOT + path).use { it.readBytes() }
|
||||
|
||||
// ---- browser mode ----
|
||||
|
||||
/**
|
||||
* Sets up the in-app web browser: a hardened, keyless WebView behind an editable address bar, loading
|
||||
* an arbitrary live URL. The same consent-gated NIP-07 `window.nostr` shim the shell injects is
|
||||
* injected here at document start for every origin, but each visited origin gets its own broker-minted
|
||||
* launch token (see [onBrowserMessage]) so a grant to one site never leaks to another.
|
||||
*/
|
||||
private fun setupBrowser() {
|
||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) ||
|
||||
!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)
|
||||
) {
|
||||
Toast.makeText(this, getString(R.string.napplet_webview_too_old), Toast.LENGTH_LONG).show()
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
browserMode = true
|
||||
proxyPort = intent.getIntExtra(NappletHostContract.EXTRA_PROXY_PORT, -1)
|
||||
useTor = intent.getBooleanExtra(NappletHostContract.EXTRA_USE_TOR, proxyPort > 0)
|
||||
val startUrl = normalizeUrl(intent.getStringExtra(NappletHostContract.EXTRA_BROWSER_URL).orEmpty())
|
||||
|
||||
webView = WebView(this)
|
||||
configureBrowserWebView(webView)
|
||||
applyWebViewProxy(if (useTor) proxyPort else -1)
|
||||
|
||||
// Inject the NIP-07 shim at document start for every origin, and let it reach native directly (no
|
||||
// shell). addWebMessageListener exposes the bridge object the shim posts to.
|
||||
val shim = readContractAsset(NappletWebContract.SHIM_JS_PATH).decodeToString()
|
||||
WebViewCompat.addWebMessageListener(webView, NappletWebContract.BRIDGE_NAME, setOf("*"), ::onShellMessage)
|
||||
// Only the TOP frame becomes an Amethyst-aware Nostr page; sub-frames get neither flag, so they
|
||||
// don't try (and fail) to reach native — their NIP-07 calls would otherwise hang on isMainFrame.
|
||||
val startScript = "if (window.top === window) { window.__nappletDirectBridge = true; window.__nappletNip07 = true; }\n$shim"
|
||||
WebViewCompat.addDocumentStartJavaScript(webView, startScript, setOf("*"))
|
||||
|
||||
bindService(Intent().setClassName(this, NappletHostContract.BROKER_SERVICE_CLASS), brokerConnection, BIND_AUTO_CREATE)
|
||||
onBackPressedDispatcher.addCallback(this, backCallback)
|
||||
|
||||
val root =
|
||||
LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
addView(buildBrowserBar())
|
||||
addView(buildDivider())
|
||||
addView(contentFrame, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f))
|
||||
}
|
||||
setContentView(root)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
|
||||
val applied = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
|
||||
val bars = insets.getInsets(applied)
|
||||
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
|
||||
WindowInsetsCompat.Builder(insets).setInsets(applied, Insets.NONE).build()
|
||||
}
|
||||
|
||||
contentFrame.addView(webView, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
|
||||
started = true
|
||||
webView.loadUrl(startUrl)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
// singleTask: relaunching the browser with a new URL reuses this instance, so load the new URL.
|
||||
if (browserMode && intent.getBooleanExtra(NappletHostContract.EXTRA_BROWSER_MODE, false)) {
|
||||
val url = intent.getStringExtra(NappletHostContract.EXTRA_BROWSER_URL)
|
||||
if (!url.isNullOrBlank() && this::webView.isInitialized) webView.loadUrl(normalizeUrl(url))
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("SetJavaScriptEnabled")
|
||||
private fun configureBrowserWebView(webView: WebView) {
|
||||
webView.settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = false
|
||||
allowFileAccess = false
|
||||
allowContentAccess = false
|
||||
@Suppress("DEPRECATION")
|
||||
allowFileAccessFromFileURLs = false
|
||||
@Suppress("DEPRECATION")
|
||||
allowUniversalAccessFromFileURLs = false
|
||||
javaScriptCanOpenWindowsAutomatically = false
|
||||
setSupportMultipleWindows(false)
|
||||
setGeolocationEnabled(false)
|
||||
mediaPlaybackRequiresUserGesture = true
|
||||
builtInZoomControls = true
|
||||
displayZoomControls = false
|
||||
loadWithOverviewMode = true
|
||||
useWideViewPort = true
|
||||
if (WebViewFeature.isFeatureSupported(WebViewFeature.SAFE_BROWSING_ENABLE)) {
|
||||
safeBrowsingEnabled = true
|
||||
}
|
||||
}
|
||||
WebView.setWebContentsDebuggingEnabled(false)
|
||||
webView.webViewClient = BrowserWebViewClient()
|
||||
}
|
||||
|
||||
/** Loads live web pages in-WebView (http/https) and hands other schemes to the system on a user tap. */
|
||||
private inner class BrowserWebViewClient : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
): Boolean {
|
||||
val uri = request.url
|
||||
val scheme = uri.scheme?.lowercase()
|
||||
if (scheme == "http" || scheme == "https") return false // navigate inside the browser
|
||||
// Non-web schemes (mailto:, tel:, lightning:, bitcoin:, intent:, …) only on a real user tap, so
|
||||
// a hostile page can't silently fire intents.
|
||||
if (request.hasGesture()) {
|
||||
runCatching { startActivity(Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPageStarted(
|
||||
view: WebView,
|
||||
url: String,
|
||||
favicon: Bitmap?,
|
||||
) {
|
||||
updateAddressBar(url)
|
||||
syncBackState()
|
||||
}
|
||||
|
||||
override fun doUpdateVisitedHistory(
|
||||
view: WebView,
|
||||
url: String,
|
||||
isReload: Boolean,
|
||||
) {
|
||||
updateAddressBar(url)
|
||||
syncBackState()
|
||||
}
|
||||
|
||||
override fun onPageFinished(
|
||||
view: WebView,
|
||||
url: String,
|
||||
) {
|
||||
updateAddressBar(url)
|
||||
syncBackState()
|
||||
}
|
||||
}
|
||||
|
||||
/** Reflects the WebView's current URL in the address bar, unless the user is editing it. */
|
||||
private fun updateAddressBar(url: String) {
|
||||
val bar = addressBar ?: return
|
||||
if (bar.hasFocus()) return
|
||||
if (url == "about:blank") return
|
||||
bar.setText(url)
|
||||
}
|
||||
|
||||
/** Editable address bar + reload, plus the Tor onion toggle when Tor is available. */
|
||||
private fun buildBrowserBar(): View {
|
||||
val onSurface = resolveThemeColor(android.R.attr.textColorPrimary)
|
||||
val bar =
|
||||
LinearLayout(this).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground))
|
||||
setPadding(dp(10), dp(8), dp(10), dp(8))
|
||||
}
|
||||
|
||||
val input =
|
||||
EditText(this).apply {
|
||||
setSingleLine()
|
||||
hint = getString(R.string.napplet_browser_address_hint)
|
||||
setHintTextColor(resolveThemeColor(android.R.attr.textColorSecondary))
|
||||
setTextColor(onSurface)
|
||||
textSize = 15f
|
||||
inputType = InputType.TYPE_TEXT_VARIATION_URI
|
||||
imeOptions = EditorInfo.IME_ACTION_GO
|
||||
setSelectAllOnFocus(true)
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||
setOnEditorActionListener { v, actionId, _ ->
|
||||
if (actionId == EditorInfo.IME_ACTION_GO) {
|
||||
navigateTo(v.text.toString())
|
||||
clearFocus()
|
||||
hideKeyboard(v)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
addressBar = input
|
||||
bar.addView(input)
|
||||
|
||||
bar.addView(
|
||||
TextView(this).apply {
|
||||
text = "⟳" // reload
|
||||
setTextColor(onSurface)
|
||||
textSize = 20f
|
||||
setPadding(dp(12), 0, dp(4), 0)
|
||||
isClickable = true
|
||||
contentDescription = getString(R.string.napplet_browser_reload_desc)
|
||||
setOnClickListener { if (this@NappletHostActivity::webView.isInitialized) webView.reload() }
|
||||
},
|
||||
)
|
||||
|
||||
// Tor onion: lit when routing through Tor, dimmed over the open web. Tap flips it for the session.
|
||||
if (proxyPort > 0) {
|
||||
browserTorIcon =
|
||||
ImageView(this).apply {
|
||||
setImageResource(R.drawable.ic_tor)
|
||||
applyTorTint(this)
|
||||
setPadding(dp(8), 0, dp(4), 0)
|
||||
isClickable = true
|
||||
setOnClickListener { toggleBrowserTor() }
|
||||
contentDescription = getString(if (useTor) R.string.napplet_net_tor_desc else R.string.napplet_net_open_desc)
|
||||
layoutParams = LinearLayout.LayoutParams(dp(34), dp(22))
|
||||
}
|
||||
bar.addView(browserTorIcon)
|
||||
}
|
||||
return bar
|
||||
}
|
||||
|
||||
private fun applyTorTint(icon: ImageView) {
|
||||
icon.setColorFilter(if (useTor) resolveThemeColor(android.R.attr.textColorPrimary) else resolveThemeColor(android.R.attr.textColorSecondary))
|
||||
icon.alpha = if (useTor) 1f else 0.4f
|
||||
}
|
||||
|
||||
/** Flips this browser session between Tor and the open web, re-applies the proxy, and reloads. */
|
||||
private fun toggleBrowserTor() {
|
||||
useTor = !useTor
|
||||
applyWebViewProxy(if (useTor) proxyPort else -1)
|
||||
browserTorIcon?.let {
|
||||
applyTorTint(it)
|
||||
it.contentDescription = getString(if (useTor) R.string.napplet_net_tor_desc else R.string.napplet_net_open_desc)
|
||||
}
|
||||
if (this::webView.isInitialized) webView.reload()
|
||||
}
|
||||
|
||||
private fun navigateTo(input: String) {
|
||||
if (this::webView.isInitialized) webView.loadUrl(normalizeUrl(input))
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns address-bar text into a URL: keeps an explicit scheme, prefixes a bare domain with `https://`,
|
||||
* and treats anything else (spaces, or no dot) as a DuckDuckGo search.
|
||||
*/
|
||||
private fun normalizeUrl(input: String): String {
|
||||
val text = input.trim()
|
||||
if (text.isEmpty()) return "about:blank"
|
||||
if (text.contains("://")) return text
|
||||
if (!text.contains(' ') && text.contains('.')) return "https://$text"
|
||||
return "https://duckduckgo.com/?q=" + Uri.encode(text)
|
||||
}
|
||||
|
||||
private fun hideKeyboard(view: View) {
|
||||
val imm = getSystemService(INPUT_METHOD_SERVICE) as? InputMethodManager
|
||||
imm?.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-mode bridge handler: a top-frame NIP-07 call from [sourceOrigin]. The origin is the trusted
|
||||
* value the WebView reports (the page can't forge it), so it keys consent. Each origin uses its own
|
||||
* broker-minted launch token; until that token arrives the request is queued and a mint is requested.
|
||||
*/
|
||||
private fun onBrowserMessage(
|
||||
message: WebMessageCompat,
|
||||
sourceOrigin: Uri,
|
||||
isMainFrame: Boolean,
|
||||
replyProxy: JavaScriptReplyProxy,
|
||||
) {
|
||||
if (!isMainFrame) return
|
||||
bridgeReplyProxy = replyProxy
|
||||
val raw = message.data ?: return
|
||||
val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return
|
||||
|
||||
val scheme = sourceOrigin.scheme ?: return
|
||||
val host = sourceOrigin.host ?: return
|
||||
val origin = "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else ""
|
||||
|
||||
val id = envelope.optString("id").ifEmpty { "fire-${fireSeq++}" }
|
||||
val msg =
|
||||
Message.obtain(null, NappletIpc.MSG_REQUEST).apply {
|
||||
replyTo = replyMessenger
|
||||
data =
|
||||
Bundle().apply {
|
||||
putString(NappletIpc.KEY_REQUEST_ID, id)
|
||||
putString(NappletIpc.KEY_PAYLOAD, raw)
|
||||
}
|
||||
}
|
||||
|
||||
val token = browserOriginTokens[origin]
|
||||
if (token != null) {
|
||||
msg.data.putString(NappletIpc.KEY_LAUNCH_TOKEN, token)
|
||||
if (brokerMessenger == null) pendingRequests.add(msg) else sendToBroker(msg)
|
||||
} else {
|
||||
browserPendingByOrigin.getOrPut(origin) { mutableListOf() }.add(msg)
|
||||
requestBrowserToken(origin)
|
||||
}
|
||||
}
|
||||
|
||||
/** Asks the broker (once per origin) to mint a per-origin launch token. */
|
||||
private fun requestBrowserToken(origin: String) {
|
||||
if (!browserMintInFlight.add(origin)) return
|
||||
val msg =
|
||||
Message.obtain(null, NappletIpc.MSG_MINT_BROWSER_TOKEN).apply {
|
||||
replyTo = replyMessenger
|
||||
data = Bundle().apply { putString(NappletIpc.KEY_BROWSER_ORIGIN, origin) }
|
||||
}
|
||||
if (brokerMessenger == null) pendingRequests.add(msg) else sendToBroker(msg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable, unique, DNS-label-safe id for this applet's sandbox origin: a sha256 of
|
||||
* `author:identifier`, hex, truncated to 31 chars and letter-prefixed (`n`). The leading letter
|
||||
@@ -479,6 +813,10 @@ class NappletHostActivity : ComponentActivity() {
|
||||
isMainFrame: Boolean,
|
||||
replyProxy: JavaScriptReplyProxy,
|
||||
) {
|
||||
if (browserMode) {
|
||||
onBrowserMessage(message, sourceOrigin, isMainFrame, replyProxy)
|
||||
return
|
||||
}
|
||||
if (!isMainFrame) return // only the trusted shell, never a sub-frame
|
||||
bridgeReplyProxy = replyProxy
|
||||
|
||||
@@ -555,6 +893,18 @@ class NappletHostActivity : ComponentActivity() {
|
||||
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
|
||||
bridgeReplyProxy?.postMessage(payload)
|
||||
}
|
||||
// Browser mode: the broker minted the per-origin launch token; cache it and flush the requests
|
||||
// that were waiting on it (each now carries the token, so the broker can resolve their identity).
|
||||
NappletIpc.MSG_BROWSER_TOKEN -> {
|
||||
val origin = data.getString(NappletIpc.KEY_BROWSER_ORIGIN) ?: return true
|
||||
val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) ?: return true
|
||||
browserOriginTokens[origin] = token
|
||||
browserMintInFlight.remove(origin)
|
||||
browserPendingByOrigin.remove(origin)?.forEach { queued ->
|
||||
queued.data.putString(NappletIpc.KEY_LAUNCH_TOKEN, token)
|
||||
sendToBroker(queued)
|
||||
}
|
||||
}
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
|
||||
+12
@@ -66,6 +66,18 @@ object NappletHostContract {
|
||||
*/
|
||||
const val EXTRA_USE_TOR = "napplet_use_tor"
|
||||
|
||||
/**
|
||||
* "Browser mode": the host renders an arbitrary **live** URL ([EXTRA_BROWSER_URL]) with an editable
|
||||
* address bar instead of a verified-blob nSite/napplet. It still runs in the keyless `:napplet`
|
||||
* process and injects the same consent-gated NIP-07 `window.nostr`, but per **visited origin** — the
|
||||
* sandbox mints a separate launch token per origin from the broker, so a grant to one site never
|
||||
* leaks to another. No manifest, content server, or launch token is passed up front.
|
||||
*/
|
||||
const val EXTRA_BROWSER_MODE = "napplet_browser_mode"
|
||||
|
||||
/** The initial URL to load in [EXTRA_BROWSER_MODE]. */
|
||||
const val EXTRA_BROWSER_URL = "napplet_browser_url"
|
||||
|
||||
/**
|
||||
* FQN of the main-process broker service (in `:amethyst`). The sandbox binds it by name so it
|
||||
* needs no compile-time reference to `:amethyst`. Must match the manifest `<service>` declaration.
|
||||
|
||||
@@ -47,9 +47,22 @@ object NappletIpc {
|
||||
*/
|
||||
const val MSG_SET_NETWORK_MODE = 4
|
||||
|
||||
/**
|
||||
* Host → broker (browser mode): mint a per-origin launch token for the visited origin in
|
||||
* [KEY_BROWSER_ORIGIN]. The broker registers a synthetic per-origin identity (so NIP-07 consent is
|
||||
* scoped to that one site) and answers with [MSG_BROWSER_TOKEN].
|
||||
*/
|
||||
const val MSG_MINT_BROWSER_TOKEN = 5
|
||||
|
||||
/** Broker → host: the [KEY_LAUNCH_TOKEN] minted for [KEY_BROWSER_ORIGIN]. */
|
||||
const val MSG_BROWSER_TOKEN = 6
|
||||
|
||||
const val KEY_REQUEST_ID = "requestId"
|
||||
const val KEY_PAYLOAD = "payload"
|
||||
|
||||
/** The visited web origin (e.g. `https://example.com`) a browser-mode request belongs to. */
|
||||
const val KEY_BROWSER_ORIGIN = "browserOrigin"
|
||||
|
||||
/** Boolean: route this site through Tor (true) or over the open web (false). */
|
||||
const val KEY_NETWORK_USE_TOR = "networkUseTor"
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
<string name="napplet_net_switch_open">Use open web</string>
|
||||
<string name="napplet_net_switch_tor">Use Tor</string>
|
||||
|
||||
<!-- In-app browser (arbitrary live URLs) -->
|
||||
<string name="napplet_browser_address_hint">Search or enter address</string>
|
||||
<string name="napplet_browser_reload_desc">Reload page</string>
|
||||
|
||||
<!-- Loading / unavailable screens -->
|
||||
<string name="napplet_unavailable_title">Couldn\'t load “%1$s”</string>
|
||||
<string name="napplet_unavailable_subtitle">The publisher\'s servers may be offline, or you\'re not connected. You can try again.</string>
|
||||
|
||||
Reference in New Issue
Block a user