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:
Claude
2026-06-23 03:39:06 +00:00
parent 068e12b5f6
commit d63bf87d2a
21 changed files with 1382 additions and 7 deletions
+5
View File
@@ -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)
+7
View File
@@ -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) }
@@ -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(
@@ -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)
@@ -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))
}
}
}
@@ -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) }
}
}
+6
View File
@@ -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); }
+5
View File
@@ -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" }
+5
View File
@@ -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)
}
@@ -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"
}
@@ -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"
}
}
@@ -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()
}
}
@@ -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
@@ -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>