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() + } +}