diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts
index c8c3a72587..fab52d3ec9 100644
--- a/amethyst/build.gradle.kts
+++ b/amethyst/build.gradle.kts
@@ -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)
diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml
index a450afeba5..5d9b201257 100644
--- a/amethyst/src/main/AndroidManifest.xml
+++ b/amethyst/src/main/AndroidManifest.xml
@@ -425,6 +425,13 @@
android:name=".napplet.NappletBrokerService"
android:exported="false" />
+
+
+
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt
index 54c6be7520..e686513606 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt
@@ -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:`. It is never treated as a real pubkey.
+ */
+ private const val BROWSER_IDENTITY_AUTHOR = "browser"
+ }
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt
index c8b88598a5..c586739085 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt
@@ -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,
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
index 73588ba054..350bb316bc 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
@@ -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 { SoftwareAppsScreen(accountViewModel, nav) }
composableFromEnd { NappletsScreen(accountViewModel, nav) }
composableFromEnd { NsitesScreen(accountViewModel, nav) }
+ composableFromEnd { BrowserScreen(accountViewModel, nav) }
composableFromEnd { NappletPermissionsScreen(accountViewModel, nav) }
composableFromEndArgs { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEnd { CalendarsScreen(accountViewModel, nav) }
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt
index 9acfaac10b..cbb1d9ecd8 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt
@@ -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 =
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.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,
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt
index a929a2ef22..db30e1be22 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt
@@ -91,6 +91,8 @@ sealed class Route {
@Serializable object Nsites : Route()
+ @Serializable object Browser : Route()
+
@Serializable object NappletPermissions : Route()
@Serializable data class SoftwareAppDetail(
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt
index 233ab9bd53..8320b2390c 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt
@@ -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)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt
new file mode 100644
index 0000000000..75c9ab84bb
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt
@@ -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))
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedBrowserController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedBrowserController.kt
new file mode 100644
index 0000000000..f00ec8a8bd
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedBrowserController.kt
@@ -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) }
+ }
+}
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index b7b2e687cf..7d10fb4076 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -666,6 +666,12 @@
nApplets
nSites
No nSites found yet.
+ Browser
+ Search or enter address
+ Reload
+ Loading over Tor. Tap to use the open web.
+ Loading over the open web. Tap to use Tor.
+ The in-app browser needs Android 11 or newer.
nApplet permissions
Manage permissions
No nApplet permissions yet
diff --git a/commons/src/commonMain/composeResources/files/napplet/shim.js b/commons/src/commonMain/composeResources/files/napplet/shim.js
index 323b6e024e..7b6cb8cd94 100644
--- a/commons/src/commonMain/composeResources/files/napplet/shim.js
+++ b/commons/src/commonMain/composeResources/files/napplet/shim.js
@@ -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()
+ 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()
+ private val pendingByOrigin = mutableMapOf>()
+ private val mintInFlight = mutableSetOf()
+
+ 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"
+ }
+}
diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserUiAdapter.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserUiAdapter.kt
new file mode 100644
index 0000000000..1d4f5ff98a
--- /dev/null
+++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserUiAdapter.kt
@@ -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 = 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()
+ }
+}
diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt
index 81800e9e8e..7014cdb2bf 100644
--- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt
+++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt
@@ -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()
+ private val browserPendingByOrigin = mutableMapOf>()
+ private val browserMintInFlight = mutableSetOf()
+
+ // 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
diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostContract.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostContract.kt
index c34eaeffcd..8f9ddbed61 100644
--- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostContract.kt
+++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostContract.kt
@@ -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 `` declaration.
diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt
index acf1e115bb..bd2e8eb615 100644
--- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt
+++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt
@@ -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"
diff --git a/nappletHost/src/main/res/values/strings.xml b/nappletHost/src/main/res/values/strings.xml
index fb51769ca8..a3efe84601 100644
--- a/nappletHost/src/main/res/values/strings.xml
+++ b/nappletHost/src/main/res/values/strings.xml
@@ -24,6 +24,10 @@
Use open web
Use Tor
+
+ Search or enter address
+ Reload page
+
Couldn\'t load “%1$s”
The publisher\'s servers may be offline, or you\'re not connected. You can try again.