mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
@@ -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) }
|
||||
|
||||
+18
-10
@@ -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(
|
||||
|
||||
+146
@@ -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) }
|
||||
}
|
||||
}
|
||||
+3
-4
@@ -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) },
|
||||
)
|
||||
|
||||
+262
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+78
@@ -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"
|
||||
}
|
||||
+373
@@ -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"
|
||||
}
|
||||
}
|
||||
+98
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user