feat: embed nsites/napplets as in-process tabs; pin them to the bottom bar

Completes the favorites system: a favorited nsite/napplet can now be
pinned to the bottom bar and render as an embedded, swap-in-place tab —
no longer only a full-screen activity launch.

New sandbox surface (:napplet, keyless), mirroring the browser embed:
- NappletHostService hosts the verified-blob WebView (same content server,
  shell bridge, and single launch-token broker path as NappletHostActivity)
  and ships it as a SandboxedUiAdapter surface, so applet JS still runs only
  in the keyless process — never where the keys live.
- NappletHostUiAdapter / NappletEmbedContract are the SurfaceControlViewHost
  adapter and the Messenger contract; the create-session bundle reuses
  NappletHostContract's EXTRA_* keys, so the embedded and full-screen host
  paths launch from identical, main-process-minted parameters.

Main process:
- NappletLauncher.buildLaunchParams extracts the verified param/token minting
  so both the activity intent and the embedded session share it.
- EmbeddedNappletController binds the service and attaches the surface
  (mirror of EmbeddedBrowserController).
- FavoriteNappletScreen draws the TRUSTED CHROME (sandbox shield, app name,
  "what it can access") in the main process around the surface — the sandbox
  must never draw chrome the user is meant to trust — plus a pop-out to the
  full-screen host. Capability consent still flows through the existing
  main-process broker + consent activity, unchanged and host-agnostic.

Security parity with the full-screen host:
- The applet's JS + timers are paused while the app is backgrounded
  (lifecycle ON_STOP/ON_START → MSG_PAUSE/MSG_RESUME), so an "allow always"
  napplet can't act on the user's behalf when they aren't looking.
- Granted sensitive ops (publish/upload/pay) surface a notice toast.

Both favorite kinds are now pinnable; the bottom bar routes WebUrl →
FavoriteWebApp and NostrApp → FavoriteNostrApp, each embedding in place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
This commit is contained in:
Claude
2026-06-23 14:59:44 +00:00
parent 0606566238
commit e82f1057c8
13 changed files with 1109 additions and 32 deletions
+8
View File
@@ -444,6 +444,14 @@
android:process=":napplet"
android:exported="false" />
<!-- Embedded nsite/napplet provider: hosts the verified-blob WebView in the isolated, keyless
process and ships its surface to the main app, so a favorited nsite/napplet can render as
an in-app tab instead of taking over the screen. Same trust model as NappletHostActivity. -->
<service
android:name="com.vitorpamplona.amethyst.napplethost.NappletHostService"
android:process=":napplet"
android:exported="false" />
</application>
@@ -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.
@@ -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<PathTag>,
servers: List<String>,
authorPubKey: HexKey,
identifier: String,
aggregateHash: HexKey?,
title: String,
requires: List<String>,
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)
}
}
}
@@ -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<Route.Browser> { BrowserScreen(accountViewModel, nav) }
composableFromEnd<Route.FavoriteApps> { FavoriteAppsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.FavoriteWebApp> { FavoriteWebAppScreen(it.url, accountViewModel, nav) }
composableFromEndArgs<Route.FavoriteNostrApp> { FavoriteNappletScreen(it.coordinate, accountViewModel, nav) }
composableFromEnd<Route.NappletPermissions> { NappletPermissionsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.SoftwareAppDetail> { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEnd<Route.Calendars> { CalendarsScreen(accountViewModel, nav) }
@@ -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<NavBarItem>,
favoriteTabs: List<FavoriteApp.WebUrl>,
favoriteTabs: List<FavoriteApp>,
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) },
)
@@ -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(
@@ -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) }
}
}
@@ -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) },
)
@@ -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<String>,
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
}
+9
View File
@@ -680,6 +680,15 @@
<string name="favorite_app_open_window">Open in its own window</string>
<string name="favorite_app_pin">Pin to bottom bar</string>
<string name="favorite_app_unpin">Unpin from bottom bar</string>
<string name="favorite_app_access_title">What this app can access</string>
<string name="favorite_app_access_static">Runs sandboxed with no special access to your account.</string>
<string name="favorite_app_access_show">What it can access</string>
<string name="favorite_app_network_tor">Loads over Tor.</string>
<string name="favorite_app_network_open">Loads over the open web.</string>
<string name="favorite_app_unavailable">This app isn\'t loaded yet. Open it from its card, or try again in a moment.</string>
<string name="favorite_notice_published">Published to relays</string>
<string name="favorite_notice_uploaded">Uploaded a file</string>
<string name="favorite_notice_paid">Made a payment</string>
<string name="favorite_app_still_loading">That app isn\'t loaded yet. Try again in a moment.</string>
<string name="favorite_app_recent">Recent</string>
<string name="napplet_permissions">nApplet permissions</string>
@@ -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"
}
@@ -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<PathTag>()
private val servers = mutableListOf<String>()
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<String> = 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<Message>()
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"
}
}
@@ -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<String> = emptySet()
override fun notifyResized(
width: Int,
height: Int,
) {
webView.layoutParams = ViewGroup.LayoutParams(width, height)
webView.requestLayout()
}
override fun notifyZOrderChanged(isZOrderOnTop: Boolean) {}
override fun notifyConfigurationChanged(configuration: Configuration) {}
override fun notifyUiChanged(uiContainerInfo: Bundle) {}
override fun close() {
service.onSessionClosed()
}
}