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