diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml
index 74a9682265..5932e41837 100644
--- a/amethyst/src/main/AndroidManifest.xml
+++ b/amethyst/src/main/AndroidManifest.xml
@@ -444,6 +444,14 @@
android:process=":napplet"
android:exported="false" />
+
+
+
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt
index 1e51a73477..af3f4732c3 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.favorites
import android.app.Activity
import android.content.Context
+import android.os.Bundle
import android.util.Log
import android.widget.Toast
import com.vitorpamplona.amethyst.R
@@ -112,6 +113,70 @@ object FavoriteAppLauncher {
}
}
+ /**
+ * Builds the main-process-minted launch parameters for embedding the nsite/napplet at [coordinate]
+ * as an in-app tab (see `NappletHostService`). Returns null when the event isn't resolvable in
+ * [LocalCache] yet — the caller shows a loading state. nsite vs napplet (and website mode) is decided
+ * here from the live event, exactly as in [launchNostrApp].
+ */
+ fun embedParams(
+ context: Context,
+ coordinate: String,
+ ): Bundle? {
+ val event = LocalCache.getAddressableNoteIfExists(coordinate)?.event
+ return when (event) {
+ is RootNappletEvent ->
+ NappletLauncher.buildLaunchParams(
+ context,
+ event.paths(),
+ event.servers(),
+ event.pubKey,
+ "",
+ event.declaredAggregateHash() ?: event.computeAggregateHash(),
+ event.title() ?: "Napplet",
+ event.requires(),
+ false,
+ )
+ is NamedNappletEvent ->
+ NappletLauncher.buildLaunchParams(
+ context,
+ event.paths(),
+ event.servers(),
+ event.pubKey,
+ event.identifier(),
+ event.declaredAggregateHash() ?: event.computeAggregateHash(),
+ event.title() ?: event.identifier(),
+ event.requires(),
+ false,
+ )
+ is RootSiteEvent ->
+ NappletLauncher.buildLaunchParams(
+ context,
+ event.paths(),
+ event.servers(),
+ event.pubKey,
+ "",
+ null,
+ event.title() ?: "nsite",
+ emptyList(),
+ true,
+ )
+ is NamedSiteEvent ->
+ NappletLauncher.buildLaunchParams(
+ context,
+ event.paths(),
+ event.servers(),
+ event.pubKey,
+ event.identifier(),
+ null,
+ event.title() ?: event.identifier(),
+ emptyList(),
+ true,
+ )
+ else -> null
+ }
+ }
+
/**
* The addressable coordinate `kind:pubkey:dtag` used to key an nsite/napplet favorite. Stored
* instead of the content hash so the favorite survives routine code/manifest updates.
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..5d19b53899 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.napplet
import android.content.Context
import android.content.Intent
+import android.os.Bundle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
@@ -76,6 +77,33 @@ object NappletLauncher {
// (empty) manifest `requires`. Napplets pass false and keep their declared-only, locked sandbox.
websiteMode: Boolean = false,
) {
+ val params = buildLaunchParams(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, websiteMode)
+ val intent =
+ Intent(context, NappletHostActivity::class.java).apply {
+ putExtras(params)
+ if (context !is android.app.Activity) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+ }
+
+ /**
+ * Builds the verified launch parameters — minting the launch token, augmenting the Blossom server
+ * set, resolving the per-site Tor choice and capability labels — as a [Bundle] keyed by the
+ * [NappletHostContract] EXTRA_* names. Used both for the activity intent (above) and for the
+ * embedded [com.vitorpamplona.amethyst.napplethost.NappletHostService] session (carried over
+ * Messenger), so the two host paths launch from identical, main-process-minted parameters.
+ */
+ fun buildLaunchParams(
+ context: Context,
+ paths: List,
+ servers: List,
+ authorPubKey: HexKey,
+ identifier: String,
+ aggregateHash: HexKey?,
+ title: String,
+ requires: List,
+ websiteMode: Boolean,
+ ): Bundle {
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
// Augment the manifest's servers with the author's published Blossom list (kind:10063), if
@@ -106,23 +134,20 @@ object NappletLauncher {
// Resolve capability labels here (the app has the resources) so the sandbox module needs none.
val capLabels = declared.map { context.getString(it.labelRes()) }
- val intent =
- Intent(context, NappletHostActivity::class.java).apply {
- putExtra(NappletHostContract.EXTRA_PATHS, ArrayList(paths.map { it.path }))
- putExtra(NappletHostContract.EXTRA_HASHES, ArrayList(paths.map { it.hash }))
- putExtra(NappletHostContract.EXTRA_SERVERS, ArrayList(allServers))
- putExtra(NappletHostContract.EXTRA_AUTHOR, authorPubKey)
- putExtra(NappletHostContract.EXTRA_IDENTIFIER, identifier)
- putExtra(NappletHostContract.EXTRA_AGGREGATE_HASH, aggregateHash)
- putExtra(NappletHostContract.EXTRA_TITLE, title)
- putExtra(NappletHostContract.EXTRA_REQUIRES, ArrayList(requires))
- putExtra(NappletHostContract.EXTRA_CAP_LABELS, ArrayList(capLabels))
- putExtra(NappletHostContract.EXTRA_LAUNCH_TOKEN, launchToken)
- putExtra(NappletHostContract.EXTRA_PROXY_PORT, proxyPort)
- putExtra(NappletHostContract.EXTRA_WEBSITE_MODE, websiteMode)
- putExtra(NappletHostContract.EXTRA_USE_TOR, useTor)
- if (context !is android.app.Activity) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- }
- context.startActivity(intent)
+ return Bundle().apply {
+ putStringArrayList(NappletHostContract.EXTRA_PATHS, ArrayList(paths.map { it.path }))
+ putStringArrayList(NappletHostContract.EXTRA_HASHES, ArrayList(paths.map { it.hash }))
+ putStringArrayList(NappletHostContract.EXTRA_SERVERS, ArrayList(allServers))
+ putString(NappletHostContract.EXTRA_AUTHOR, authorPubKey)
+ putString(NappletHostContract.EXTRA_IDENTIFIER, identifier)
+ putString(NappletHostContract.EXTRA_AGGREGATE_HASH, aggregateHash)
+ putString(NappletHostContract.EXTRA_TITLE, title)
+ putStringArrayList(NappletHostContract.EXTRA_REQUIRES, ArrayList(requires))
+ putStringArrayList(NappletHostContract.EXTRA_CAP_LABELS, ArrayList(capLabels))
+ putString(NappletHostContract.EXTRA_LAUNCH_TOKEN, launchToken)
+ putInt(NappletHostContract.EXTRA_PROXY_PORT, proxyPort)
+ putBoolean(NappletHostContract.EXTRA_WEBSITE_MODE, websiteMode)
+ putBoolean(NappletHostContract.EXTRA_USE_TOR, useTor)
+ }
}
}
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 e20c2976b0..461a8911e6 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
@@ -119,6 +119,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list.metadata.Em
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.membershipManagement.EmojiPackSelectionScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.membershipManagement.MyEmojiListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.FavoriteAppsScreen
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.FavoriteNappletScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.FollowPackFeedScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.list.FollowPacksScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen
@@ -299,6 +300,7 @@ fun BuildNavigation(
composableFromEnd { BrowserScreen(accountViewModel, nav) }
composableFromEnd { FavoriteAppsScreen(accountViewModel, nav) }
composableFromEndArgs { FavoriteWebAppScreen(it.url, accountViewModel, nav) }
+ composableFromEndArgs { FavoriteNappletScreen(it.coordinate, 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/AppBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt
index e8f1822c89..331456dadf 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt
@@ -46,6 +46,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
+import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -89,13 +90,14 @@ fun AppBottomBar(
return
}
- // User-pinned favorite web apps appear as extra tabs after the built-in items. Only WebUrl
- // favorites are pinnable (they embed in-process), so a pinned tab always swaps in place.
+ // User-pinned favorite apps appear as extra tabs after the built-in items. Both kinds embed
+ // in-process (WebUrl → browser surface, NostrApp → napplet surface), so a pinned tab always
+ // swaps in place rather than launching an activity from the bottom row.
val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
val pinnedIds by FavoriteAppsRegistry.pinnedIds.collectAsStateWithLifecycle()
val pinnedFavorites =
remember(favorites, pinnedIds) {
- pinnedIds.mapNotNull { id -> favorites.firstOrNull { it.id == id } as? FavoriteApp.WebUrl }
+ pinnedIds.mapNotNull { id -> favorites.firstOrNull { it.id == id } }
}
val isKeyboardState by keyboardAsState()
@@ -107,7 +109,7 @@ fun AppBottomBar(
@Composable
private fun RenderBottomMenu(
items: List,
- favoriteTabs: List,
+ favoriteTabs: List,
selectedRoute: Route?,
accountViewModel: AccountViewModel,
nav: (Route) -> Unit,
@@ -136,8 +138,13 @@ private fun RenderBottomMenu(
HasNewItemsIcon(destination == selectedRoute, def, destination, accountViewModel, nav)
}
favoriteTabs.forEach { fav ->
- val destination = Route.FavoriteWebApp(fav.url)
- FavoriteNavItem(destination == selectedRoute, fav, destination, nav)
+ val destination =
+ when (fav) {
+ is FavoriteApp.WebUrl -> Route.FavoriteWebApp(fav.url)
+ is FavoriteApp.NostrApp -> Route.FavoriteNostrApp(fav.coordinate)
+ }
+ val icon = if (fav is FavoriteApp.NostrApp) MaterialSymbols.Apps else MaterialSymbols.Public
+ FavoriteNavItem(destination == selectedRoute, fav.label, icon, destination, nav)
}
}
}
@@ -146,7 +153,8 @@ private fun RenderBottomMenu(
@Composable
private fun RowScope.FavoriteNavItem(
selected: Boolean,
- fav: FavoriteApp.WebUrl,
+ label: String,
+ icon: MaterialSymbol,
destination: Route,
nav: (Route) -> Unit,
) {
@@ -155,14 +163,14 @@ private fun RowScope.FavoriteNavItem(
icon = {
Box(Size27Modifier, contentAlignment = Alignment.Center) {
Icon(
- symbol = MaterialSymbols.Public,
- contentDescription = fav.label,
+ symbol = icon,
+ contentDescription = label,
modifier = Size25Modifier,
tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65,
)
}
},
- label = { Text(fav.label, maxLines = 1, overflow = TextOverflow.Ellipsis) },
+ label = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis) },
selected = selected,
onClick = { nav(destination) },
)
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 891bd2d45b..20a69e1bb9 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
@@ -99,6 +99,10 @@ sealed class Route {
val url: String,
) : Route()
+ @Serializable data class FavoriteNostrApp(
+ val coordinate: String,
+ ) : Route()
+
@Serializable object NappletPermissions : Route()
@Serializable data class SoftwareAppDetail(
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNappletController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNappletController.kt
new file mode 100644
index 0000000000..5459f40ee6
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNappletController.kt
@@ -0,0 +1,146 @@
+/*
+ * 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.favorites
+
+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.NappletEmbedContract
+
+/**
+ * Client-side handle to an embedded nsite/napplet. Binds [NappletHostService][com.vitorpamplona.amethyst.napplethost.NappletHostService]
+ * (in the keyless `:napplet` process), hands its `SandboxedUiAdapter` to a [SandboxedSdkView] so the
+ * verified-blob WebView renders inside the main activity, and relays back/reload while receiving
+ * navigation state + "allow always" notices that the trusted main-process chrome reflects.
+ *
+ * [params] is the bundle minted in the main process by
+ * [NappletLauncher.buildLaunchParams][com.vitorpamplona.amethyst.napplet.NappletLauncher.buildLaunchParams]
+ * — the verified manifest, identity, and launch token. The mirror of `EmbeddedBrowserController`.
+ */
+@RequiresApi(Build.VERSION_CODES.R)
+class EmbeddedNappletController(
+ private val appContext: Context,
+ private val params: Bundle,
+) {
+ 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
+
+ /** (canGoBack) — drives the in-tab back gesture. */
+ var onStateChanged: ((Boolean) -> Unit)? = null
+
+ /** A granted "allow always" sensitive op just ran (one of NappletEmbedContract.NOTICE_*). */
+ var onNotice: ((String) -> 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() {
+ val intent = Intent().setClassName(appContext, NappletEmbedContract.SERVICE_CLASS)
+ bound = appContext.bindService(intent, connection, Context.BIND_AUTO_CREATE)
+ }
+
+ fun unbind() {
+ if (bound) {
+ runCatching { appContext.unbindService(connection) }
+ bound = false
+ }
+ }
+
+ fun attachView(view: SandboxedSdkView) {
+ sandboxedSdkView = view
+ pendingAdapter?.let {
+ view.setAdapter(it)
+ pendingAdapter = null
+ }
+ }
+
+ private fun sendCreateSession() {
+ val msg =
+ Message.obtain(null, NappletEmbedContract.MSG_CREATE_SESSION).apply {
+ replyTo = incoming
+ data = Bundle(params)
+ }
+ runCatching { serviceMessenger?.send(msg) }
+ }
+
+ private fun onServiceMessage(msg: Message): Boolean {
+ when (msg.what) {
+ NappletEmbedContract.MSG_SESSION_READY -> {
+ val coreLibInfo = msg.data?.getBundle(NappletEmbedContract.KEY_CORE_LIB_INFO) ?: return true
+ val adapter = SandboxedUiAdapterFactory.createFromCoreLibInfo(coreLibInfo)
+ val view = sandboxedSdkView
+ if (view != null) view.setAdapter(adapter) else pendingAdapter = adapter
+ }
+ NappletEmbedContract.MSG_STATE -> {
+ val canGoBack = msg.data?.getBoolean(NappletEmbedContract.KEY_CAN_GO_BACK, false) ?: false
+ onStateChanged?.invoke(canGoBack)
+ }
+ NappletEmbedContract.MSG_NOTICE -> {
+ val notice = msg.data?.getString(NappletEmbedContract.KEY_NOTICE) ?: return true
+ onNotice?.invoke(notice)
+ }
+ else -> return false
+ }
+ return true
+ }
+
+ fun back() = send(NappletEmbedContract.MSG_BACK)
+
+ fun reload() = send(NappletEmbedContract.MSG_RELOAD)
+
+ /** Pause/resume the applet's JS when the tab leaves/returns to the foreground (background gating). */
+ fun pause() = send(NappletEmbedContract.MSG_PAUSE)
+
+ fun resume() = send(NappletEmbedContract.MSG_RESUME)
+
+ private fun send(what: Int) {
+ val msg = Message.obtain(null, what)
+ runCatching { serviceMessenger?.send(msg) }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt
index 8e79a090be..02bb497f1f 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt
@@ -134,7 +134,7 @@ fun FavoriteAppsGrid(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(12.dp),
) {
- // Only WebUrl favorites can be pinned as bottom-bar tabs today (they embed in-process).
+ // Any favorite can be pinned as a bottom-bar tab — both kinds embed in-process.
val pinnedIds by FavoriteAppsRegistry.pinnedIds.collectAsStateWithLifecycle()
LazyVerticalGrid(
@@ -145,11 +145,10 @@ fun FavoriteAppsGrid(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(apps, key = { it.id }) { app ->
- val pinnable = app is FavoriteApp.WebUrl
FavoriteAppCell(
app = app,
- isPinned = pinnable && pinnedIds.contains(app.id),
- onTogglePin = if (pinnable) ({ FavoriteAppsRegistry.setPinned(app.id, !FavoriteAppsRegistry.isPinned(app.id)) }) else null,
+ isPinned = pinnedIds.contains(app.id),
+ onTogglePin = { FavoriteAppsRegistry.setPinned(app.id, !FavoriteAppsRegistry.isPinned(app.id)) },
onOpen = { onOpen(app) },
onRemove = { onRemove(app) },
)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteNappletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteNappletScreen.kt
new file mode 100644
index 0000000000..396e8dc45e
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteNappletScreen.kt
@@ -0,0 +1,262 @@
+/*
+ * 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.favorites
+
+import android.os.Build
+import android.widget.Toast
+import androidx.activity.compose.BackHandler
+import androidx.annotation.RequiresApi
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.SideEffect
+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.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.privacysandbox.ui.client.view.SandboxedSdkView
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
+import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
+import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
+import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
+import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract
+import com.vitorpamplona.amethyst.napplethost.NappletHostContract
+import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
+import com.vitorpamplona.amethyst.ui.navigation.navs.INav
+import com.vitorpamplona.amethyst.ui.navigation.routes.Route
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+
+/**
+ * A favorited nsite/napplet rendered as an **in-app tab**: the verified-blob sandbox surface (hosted in
+ * the keyless `:napplet` process by `NappletHostService`) fills the screen while the app's bottom bar
+ * stays, so switching to/from it is an ordinary tab swap. The **trusted chrome** — the sandbox shield,
+ * the app name, and the "what it can access" sheet — is drawn here in the main process, around the
+ * surface, exactly because the sandbox must never draw chrome the user is meant to trust. The pop-out
+ * hands the app to the full-screen `NappletHostActivity`.
+ *
+ * Requires API 30+ for the cross-process surface; the favorite is only reachable above that.
+ */
+@Composable
+fun FavoriteNappletScreen(
+ coordinate: String,
+ accountViewModel: AccountViewModel,
+ nav: INav,
+) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ EmbeddedNappletTab(coordinate, accountViewModel, nav)
+ } else {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text(
+ stringResource(R.string.browser_unsupported),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
+
+@RequiresApi(Build.VERSION_CODES.R)
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun EmbeddedNappletTab(
+ coordinate: String,
+ accountViewModel: AccountViewModel,
+ nav: INav,
+) {
+ val context = LocalContext.current
+
+ // Mint the verified launch params once (a fresh token per resolve); null until the event loads.
+ val params = remember(coordinate) { FavoriteAppLauncher.embedParams(context, coordinate) }
+ if (params == null) {
+ UnavailableTab(coordinate, accountViewModel, nav)
+ return
+ }
+
+ val title = params.getString(NappletHostContract.EXTRA_TITLE).orEmpty()
+ val capLabels = params.getStringArrayList(NappletHostContract.EXTRA_CAP_LABELS).orEmpty()
+ val websiteMode = params.getBoolean(NappletHostContract.EXTRA_WEBSITE_MODE, false)
+ val useTor = params.getBoolean(NappletHostContract.EXTRA_USE_TOR, true)
+
+ var canGoBack by remember { mutableStateOf(false) }
+ var showAccess by remember { mutableStateOf(false) }
+
+ val controller = remember(coordinate) { EmbeddedNappletController(context.applicationContext, params) }
+
+ // Keep callbacks fresh without re-binding.
+ SideEffect {
+ controller.onStateChanged = { canGoBack = it }
+ controller.onNotice = { notice ->
+ noticeResId(notice)?.let { Toast.makeText(context, it, Toast.LENGTH_SHORT).show() }
+ }
+ }
+
+ DisposableEffect(coordinate) {
+ controller.bind()
+ onDispose { controller.unbind() }
+ }
+
+ // Pause the applet's JS while the app is backgrounded, so an "allow always" napplet can't act on
+ // the user's behalf when they aren't looking — parity with NappletHostActivity's onPause gating.
+ val lifecycleOwner = LocalLifecycleOwner.current
+ DisposableEffect(lifecycleOwner, controller) {
+ val observer =
+ LifecycleEventObserver { _, event ->
+ when (event) {
+ Lifecycle.Event.ON_STOP -> controller.pause()
+ Lifecycle.Event.ON_START -> controller.resume()
+ else -> Unit
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+ }
+
+ BackHandler(enabled = canGoBack) { controller.back() }
+
+ if (showAccess) {
+ AccessDialog(title, capLabels, websiteMode, useTor) { showAccess = false }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ navigationIcon = {
+ // The sandbox shield is part of the trusted chrome; tap it (or the title) to see access.
+ IconButton(onClick = { showAccess = true }) {
+ Icon(MaterialSymbols.Security, contentDescription = stringResource(R.string.favorite_app_access_show))
+ }
+ },
+ title = {
+ Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ },
+ actions = {
+ IconButton(onClick = { controller.reload() }) {
+ Icon(MaterialSymbols.Refresh, contentDescription = stringResource(R.string.browser_reload))
+ }
+ IconButton(onClick = {
+ FavoriteAppLauncher.launch(context, FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis()))
+ }) {
+ Icon(MaterialSymbols.AutoMirrored.OpenInNew, contentDescription = stringResource(R.string.favorite_app_open_window))
+ }
+ },
+ )
+ },
+ bottomBar = {
+ AppBottomBar(Route.FavoriteNostrApp(coordinate), nav, accountViewModel) { route -> nav.navBottomBar(route) }
+ },
+ ) { padding ->
+ AndroidView(
+ factory = { ctx -> SandboxedSdkView(ctx).also { controller.attachView(it) } },
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(padding),
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun UnavailableTab(
+ coordinate: String,
+ accountViewModel: AccountViewModel,
+ nav: INav,
+) {
+ Scaffold(
+ topBar = { TopAppBar(title = { Text(stringResource(R.string.favorite_apps)) }) },
+ bottomBar = {
+ AppBottomBar(Route.FavoriteNostrApp(coordinate), nav, accountViewModel) { route -> nav.navBottomBar(route) }
+ },
+ ) { padding ->
+ Box(
+ Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(32.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ stringResource(R.string.favorite_app_unavailable),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+}
+
+@Composable
+private fun AccessDialog(
+ title: String,
+ capLabels: List,
+ websiteMode: Boolean,
+ useTor: Boolean,
+ onDismiss: () -> Unit,
+) {
+ val capsBody =
+ if (capLabels.isEmpty()) {
+ stringResource(R.string.favorite_app_access_static)
+ } else {
+ capLabels.joinToString("\n") { "• $it" }
+ }
+ val networkBody =
+ if (websiteMode) {
+ "\n\n" + stringResource(if (useTor) R.string.favorite_app_network_tor else R.string.favorite_app_network_open)
+ } else {
+ ""
+ }
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(if (title.isBlank()) stringResource(R.string.favorite_app_access_title) else title) },
+ text = { Text(capsBody + networkBody) },
+ confirmButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.ok)) }
+ },
+ )
+}
+
+private fun noticeResId(notice: String): Int? =
+ when (notice) {
+ NappletEmbedContract.NOTICE_PUBLISHED -> R.string.favorite_notice_published
+ NappletEmbedContract.NOTICE_UPLOADED -> R.string.favorite_notice_uploaded
+ NappletEmbedContract.NOTICE_PAID -> R.string.favorite_notice_paid
+ else -> null
+ }
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index 4d0b79f755..5e44e5ada9 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -680,6 +680,15 @@
Open in its own window
Pin to bottom bar
Unpin from bottom bar
+ What this app can access
+ Runs sandboxed with no special access to your account.
+ What it can access
+ Loads over Tor.
+ Loads over the open web.
+ This app isn\'t loaded yet. Open it from its card, or try again in a moment.
+ Published to relays
+ Uploaded a file
+ Made a payment
That app isn\'t loaded yet. Try again in a moment.
Recent
nApplet permissions
diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt
new file mode 100644
index 0000000000..7f20d606cf
--- /dev/null
+++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt
@@ -0,0 +1,78 @@
+/*
+ * 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 [NappletHostService] (provider, in the keyless
+ * `:napplet` process) for an **embedded** nsite/napplet — the in-app-tab counterpart of the full-screen
+ * [NappletHostActivity]. The provider hosts the verified-blob WebView and ships its rendered surface
+ * back through `androidx.privacysandbox.ui` (SurfaceControlViewHost), so applet JS never runs in the
+ * key-holding main process. The session config (verified manifest paths/hashes/servers, identity,
+ * launch token, …) is carried in the [MSG_CREATE_SESSION] bundle using the same keys as
+ * [NappletHostContract] (the activity's intent extras), so the two host paths stay in lockstep.
+ *
+ * The trusted chrome (shield, title, "what it can access") is drawn by the main process around the
+ * embedded surface — the sandbox can't draw chrome the user should trust.
+ */
+object NappletEmbedContract {
+ /** FQN of the provider service, bound by name so the client needs no compile-time reference. */
+ const val SERVICE_CLASS = "com.vitorpamplona.amethyst.napplethost.NappletHostService"
+
+ /** Client → provider: create the session. Bundle uses [NappletHostContract] EXTRA_* keys. */
+ const val MSG_CREATE_SESSION = 1
+
+ /** Client → provider: go back in the applet's page history. */
+ const val MSG_BACK = 2
+
+ /** Client → provider: reload. */
+ const val MSG_RELOAD = 3
+
+ /**
+ * Client → provider: pause the applet's JS + timers (the tab left the foreground). Mirrors
+ * [NappletHostActivity]'s onPause gating, so a backgrounded napplet — even one with an "allow
+ * always" capability — can't act on the user's behalf while they aren't looking at it.
+ */
+ const val MSG_PAUSE = 4
+
+ /** Client → provider: resume the applet's JS + timers (the tab returned to the foreground). */
+ const val MSG_RESUME = 5
+
+ /** Provider → client: the session's [KEY_CORE_LIB_INFO] (the SandboxedUiAdapter handle). */
+ const val MSG_SESSION_READY = 10
+
+ /** Provider → client: navigation state changed; carries [KEY_CAN_GO_BACK]. */
+ const val MSG_STATE = 11
+
+ /**
+ * Provider → client: a sensitive "allow always" capability just acted on the user's behalf
+ * (carries [KEY_NOTICE] — one of [NOTICE_PUBLISHED] / [NOTICE_UPLOADED] / [NOTICE_PAID]). The main
+ * process surfaces it so a granted relay-publish / upload / payment can never run fully silently.
+ */
+ const val MSG_NOTICE = 12
+
+ const val KEY_CORE_LIB_INFO = "coreLibInfo"
+ const val KEY_CAN_GO_BACK = "canGoBack"
+ const val KEY_NOTICE = "notice"
+
+ const val NOTICE_PUBLISHED = "published"
+ const val NOTICE_UPLOADED = "uploaded"
+ const val NOTICE_PAID = "paid"
+}
diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt
new file mode 100644
index 0000000000..deaf0d770d
--- /dev/null
+++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt
@@ -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.view.View
+import android.webkit.WebResourceRequest
+import android.webkit.WebResourceResponse
+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.ProxyConfig
+import androidx.webkit.ProxyController
+import androidx.webkit.WebMessageCompat
+import androidx.webkit.WebViewCompat
+import androidx.webkit.WebViewFeature
+import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
+import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
+import com.vitorpamplona.amethyst.commons.napplet.resolveRequiredCapabilities
+import com.vitorpamplona.quartz.nip01Core.core.toHexKey
+import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
+import com.vitorpamplona.quartz.utils.sha256.sha256
+import org.json.JSONObject
+import java.util.concurrent.Executor
+
+/**
+ * Provider for an **embedded** nsite/napplet tab — the in-app-tab counterpart of [NappletHostActivity].
+ * Runs in the keyless `:napplet` process: it serves the trusted shell + the manifest's already-verified
+ * blobs (via [NappletContentServer]), relays the applet's `window.napplet.*` calls to the main-process
+ * broker (with this launch's single token), and exposes the WebView to the main app as a
+ * `SandboxedUiAdapter` so only pixels + input cross the process boundary — never the keys, which live
+ * only in the main process where every brokered op is still consent-gated.
+ *
+ * Shares [NappletHostActivity]'s trust model and resource edge; differs only in being a windowless
+ * Service whose surface the main app embeds (vs. an Activity that owns the screen). The trusted chrome
+ * is drawn by the main process around the surface. Requires API 30+ (SurfaceControlViewHost).
+ */
+@RequiresApi(Build.VERSION_CODES.R)
+class NappletHostService : Service() {
+ private val incoming = Messenger(Handler(Looper.getMainLooper(), ::onClientMessage))
+ private var clientMessenger: Messenger? = null
+
+ private val paths = mutableListOf()
+ private val servers = mutableListOf()
+ private var author = ""
+ private var identifier = ""
+ private var launchToken = ""
+ private var websiteMode = false
+ private var useTor = true
+ private var proxyPort = -1
+ private var declaredDomains: List = emptyList()
+
+ private lateinit var contentServer: NappletContentServer
+
+ private var webView: WebView? = null
+
+ // ---- broker bridge (identical trust model to NappletHostActivity: one launch token) ----
+ private var brokerMessenger: Messenger? = null
+ private val replyMessenger = Messenger(Handler(Looper.getMainLooper(), ::onBrokerReply))
+ private val pendingBrokerRequests = mutableListOf()
+ private var bridgeReplyProxy: JavaScriptReplyProxy? = null
+ private var fireSeq = 0
+
+ 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) {
+ NappletEmbedContract.MSG_CREATE_SESSION -> {
+ if (!readParams(msg)) return true
+ clientMessenger = msg.replyTo
+ bindService(Intent().setClassName(this, NappletHostContract.BROKER_SERVICE_CLASS), brokerConnection, BIND_AUTO_CREATE)
+ replyWithAdapter()
+ }
+ NappletEmbedContract.MSG_BACK -> webView?.let { if (it.canGoBack()) it.goBack() }
+ NappletEmbedContract.MSG_RELOAD -> webView?.reload()
+ NappletEmbedContract.MSG_PAUSE ->
+ webView?.let {
+ it.onPause()
+ it.pauseTimers()
+ }
+ NappletEmbedContract.MSG_RESUME ->
+ webView?.let {
+ it.onResume()
+ it.resumeTimers()
+ }
+ else -> return false
+ }
+ return true
+ }
+
+ private fun readParams(msg: Message): Boolean {
+ val data = msg.data ?: return false
+ val pathList = data.getStringArrayList(NappletHostContract.EXTRA_PATHS) ?: return false
+ val hashList = data.getStringArrayList(NappletHostContract.EXTRA_HASHES) ?: return false
+ if (pathList.size != hashList.size || pathList.isEmpty()) return false
+ for (i in pathList.indices) paths.add(PathTag(pathList[i], hashList[i]))
+ servers.addAll(data.getStringArrayList(NappletHostContract.EXTRA_SERVERS) ?: emptyList())
+ author = data.getString(NappletHostContract.EXTRA_AUTHOR).orEmpty()
+ identifier = data.getString(NappletHostContract.EXTRA_IDENTIFIER).orEmpty()
+ websiteMode = data.getBoolean(NappletHostContract.EXTRA_WEBSITE_MODE, false)
+ useTor = data.getBoolean(NappletHostContract.EXTRA_USE_TOR, true)
+ proxyPort = data.getInt(NappletHostContract.EXTRA_PROXY_PORT, -1)
+ launchToken = data.getString(NappletHostContract.EXTRA_LAUNCH_TOKEN).orEmpty()
+
+ val requires = data.getStringArrayList(NappletHostContract.EXTRA_REQUIRES) ?: emptyList()
+ declaredDomains = (listOf("shell") + resolveRequiredCapabilities(requires).capabilities.map { it.name.lowercase() }).distinct()
+
+ return author.isNotEmpty() && launchToken.isNotEmpty()
+ }
+
+ /** Builds the SandboxedUiAdapter and ships its cross-process handle (coreLibInfo) to the client. */
+ private fun replyWithAdapter() {
+ val adapter = NappletHostUiAdapter(this)
+ val coreLibInfo = adapter.toCoreLibInfo(this)
+ val reply =
+ Message.obtain(null, NappletEmbedContract.MSG_SESSION_READY).apply {
+ data = Bundle().apply { putBundle(NappletEmbedContract.KEY_CORE_LIB_INFO, coreLibInfo) }
+ }
+ runCatching { clientMessenger?.send(reply) }
+ }
+
+ /**
+ * Builds the session WebView (called by the adapter on the main thread when the client attaches the
+ * surface). Mirrors [NappletHostActivity]: serves the shell + verified blobs through the content
+ * server, installs the origin-restricted shell bridge, loads the trusted shell URL.
+ */
+ fun createHostWebView(context: Context): WebView {
+ val shellHtml = readContractAsset(NappletWebContract.SHELL_HTML_PATH)
+ val shim = readContractAsset(NappletWebContract.SHIM_JS_PATH).decodeToString()
+ val appOrigin = NappletWebContract.appOrigin(deriveAppId(author, identifier))
+ val effectiveProxy = if (useTor) proxyPort else -1
+ contentServer = NappletContentServer(paths, servers, effectiveProxy, cacheDir, shellHtml, shim, appOrigin, websiteMode)
+
+ val wv = WebView(context)
+ hardenWebView(wv)
+ if (websiteMode) applyWebViewProxy(effectiveProxy)
+ WebViewCompat.addWebMessageListener(wv, NappletWebContract.BRIDGE_NAME, setOf(NappletWebContract.ORIGIN), ::onShellMessage)
+ webView = wv
+ wv.loadUrl(NappletWebContract.SHELL_URL)
+ return wv
+ }
+
+ fun onSessionClosed() {
+ webView?.destroy()
+ webView = null
+ }
+
+ @Suppress("SetJavaScriptEnabled")
+ private fun hardenWebView(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
+ cacheMode = WebSettings.LOAD_NO_CACHE
+ mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
+ if (WebViewFeature.isFeatureSupported(WebViewFeature.SAFE_BROWSING_ENABLE)) {
+ safeBrowsingEnabled = true
+ }
+ }
+ wv.overScrollMode = View.OVER_SCROLL_NEVER
+ WebView.setWebContentsDebuggingEnabled(false)
+ wv.webViewClient = HostClient()
+ }
+
+ private fun applyWebViewProxy(port: Int) {
+ if (!WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) return
+ val executor = Executor { it.run() }
+ runCatching {
+ if (port > 0) {
+ val config = ProxyConfig.Builder().addProxyRule("socks5://127.0.0.1:$port").build()
+ ProxyController.getInstance().setProxyOverride(config, executor) {}
+ } else {
+ ProxyController.getInstance().clearProxyOverride(executor) {}
+ }
+ }.onFailure { Log.w(TAG, "Failed to apply WebView proxy override", it) }
+ }
+
+ /** Serves only the trusted shell and the manifest's verified blobs; external links go to the system. */
+ private inner class HostClient : WebViewClient() {
+ override fun shouldInterceptRequest(
+ view: WebView,
+ request: WebResourceRequest,
+ ): WebResourceResponse? = contentServer.serve(request)
+
+ override fun doUpdateVisitedHistory(
+ view: WebView,
+ url: String,
+ isReload: Boolean,
+ ) = pushState(view)
+
+ override fun onPageFinished(
+ view: WebView,
+ url: String,
+ ) = pushState(view)
+
+ override fun shouldOverrideUrlLoading(
+ view: WebView,
+ request: WebResourceRequest,
+ ): Boolean {
+ val uri = request.url
+ if (NappletWebContract.isInternalHost(uri.host)) return false
+ if (request.hasGesture() && (uri.scheme == "https" || uri.scheme == "http")) {
+ runCatching { startActivity(Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }
+ }
+ return true
+ }
+ }
+
+ private fun pushState(view: WebView) {
+ val message =
+ Message.obtain(null, NappletEmbedContract.MSG_STATE).apply {
+ data = Bundle().apply { putBoolean(NappletEmbedContract.KEY_CAN_GO_BACK, view.canGoBack()) }
+ }
+ runCatching { clientMessenger?.send(message) }
+ }
+
+ // ---- bridge: shell <-> native (mirror of NappletHostActivity.onShellMessage) ----
+
+ private fun onShellMessage(
+ 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
+
+ if (envelope.optString("type") == "shell.ready") {
+ runCatching { replyProxy.postMessage(NappletProtocolJson.encodeShellInit(declaredDomains, declaredDomains)) }
+ return
+ }
+
+ 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)
+ putString(NappletIpc.KEY_LAUNCH_TOKEN, launchToken)
+ }
+ }
+ 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)
+ notifyIfSensitive(result)
+ bridgeReplyProxy?.postMessage(result.toString())
+ }
+ NappletIpc.MSG_PUSH -> {
+ val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
+ bridgeReplyProxy?.postMessage(payload)
+ }
+ else -> return false
+ }
+ return true
+ }
+
+ /** Pushes a notice to the main process for a granted "allow always" sensitive op, so it can toast. */
+ private fun notifyIfSensitive(result: JSONObject) {
+ if (!result.optBoolean("ok")) return
+ val notice =
+ when (result.optString("type")) {
+ "relay.publish.result", "relay.publishEncrypted.result" -> NappletEmbedContract.NOTICE_PUBLISHED
+ "upload.upload.result" -> NappletEmbedContract.NOTICE_UPLOADED
+ "value.payInvoice.result" -> NappletEmbedContract.NOTICE_PAID
+ else -> return
+ }
+ val message =
+ Message.obtain(null, NappletEmbedContract.MSG_NOTICE).apply {
+ data = Bundle().apply { putString(NappletEmbedContract.KEY_NOTICE, notice) }
+ }
+ runCatching { clientMessenger?.send(message) }
+ }
+
+ private fun readContractAsset(path: String): ByteArray = assets.open(NappletWebContract.RESOURCE_ASSET_ROOT + path).use { it.readBytes() }
+
+ private fun deriveAppId(
+ author: String,
+ identifier: String,
+ ): String = "n" + sha256("$author:$identifier".encodeToByteArray()).toHexKey().take(31)
+
+ private companion object {
+ private const val TAG = "NappletHostService"
+ }
+}
diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostUiAdapter.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostUiAdapter.kt
new file mode 100644
index 0000000000..2f7f194b60
--- /dev/null
+++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostUiAdapter.kt
@@ -0,0 +1,98 @@
+/*
+ * 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 verified-blob napplet/nsite WebView (built by [NappletHostService]) 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. Mirror of [NappletBrowserUiAdapter] for the embedded-tab host.
+ */
+@RequiresApi(Build.VERSION_CODES.R)
+class NappletHostUiAdapter(
+ private val service: NappletHostService,
+) : 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.createHostWebView(context)
+ webView.layoutParams = ViewGroup.LayoutParams(initialWidth, initialHeight)
+ HostSession(webView, service)
+ }.onSuccess { session -> clientExecutor.execute { client.onSessionOpened(session) } }
+ .onFailure { t -> clientExecutor.execute { client.onSessionError(t) } }
+ }
+ }
+}
+
+/** A single embedded napplet/nsite session: the WebView is the rendered view; close tears it down. */
+@RequiresApi(Build.VERSION_CODES.R)
+private class HostSession(
+ private val webView: WebView,
+ private val service: NappletHostService,
+) : 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()
+ }
+}