Merge pull request #4260 from vitorpamplona/ccr-c9ac5e5e-62o6th

fix(browser): embedded browser/napplet tabs: black screens, NIP-07 routing, Tor proxy, lifecycle
This commit is contained in:
Vitor Pamplona
2026-09-30 20:03:19 -04:00
committed by GitHub
52 changed files with 3703 additions and 550 deletions
@@ -240,9 +240,15 @@ class Amethyst : Application() {
// BACKGROUND means the process is on the system LRU list (real reclaim pressure), while UI_HIDDEN
// fires on every app switch — so evict only at BACKGROUND and above, letting a pinned tab survive
// a plain backgrounding. R+ only.
//
// rebuildAll, not a plain eviction: the tab screen the user left stays composed, holding its
// controller, and it only re-acquires when the rebuild epoch moves. Tearing the sessions down without
// bumping it left that screen with a dead controller and no active tab — blank on return. The epoch
// bump is picked up by the next recomposition, which doesn't run while the app is backgrounded, so the
// memory stays freed until the user comes back.
val pressure = level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND
if (pressure && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
EmbeddedTabHost.evictAll()
EmbeddedTabHost.rebuildAll(keepPages = true)
}
}
}
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.favoriteCoordinateOf
import com.vitorpamplona.amethyst.commons.model.ThemeType
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.tor.TorType
import com.vitorpamplona.amethyst.napplet.NappletLauncher
import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
@@ -87,7 +88,10 @@ object FavoriteAppLauncher {
preferTor: Boolean = false,
) {
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
val useTor = proxyPort > 0 && (preferTor || WebAppNetworkRegistry.useTor(url))
// Whether Tor is ON, not whether its port is known yet: a surface that wants Tor with no port refuses
// to load (fails closed) rather than quietly going out on the open web while Tor is still starting.
val torEnabled = Amethyst.instance.torPrefs.torType.value != TorType.OFF
val useTor = torEnabled && (preferTor || WebAppNetworkRegistry.useTor(url))
val themeType = Amethyst.instance.uiPrefs.value.theme.value
val theme =
when (themeType) {
@@ -37,8 +37,10 @@ import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.napplet.NappletAttendance
import com.vitorpamplona.amethyst.commons.napplet.NappletBroker
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletHeldRequests
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentityWatch
import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter
@@ -93,9 +95,11 @@ class NappletBrokerService : Service() {
private val incoming by lazy { Messenger(Handler(Looper.getMainLooper(), ::handleMessage)) }
// Live relay subscriptions, keyed by the applet's subId. The account comes per-open from the
// Live relay subscriptions, keyed by the requesting surface plus the applet's subId. The account comes per-open from the
// requesting surface's launch token, so a surface's REQs always target the account it acts as.
private val liveSubscriptions = NappletLiveSubscriptions(scope)
// Which surfaces the user is looking at: relay reads are decrypted for a page only while it is.
private val attendance = NappletAttendance<Messenger>()
private val liveSubscriptions = NappletLiveSubscriptions(scope, attendance)
// NAP-RESOURCE cancellation is keyed by the trusted launch token plus the caller's request id.
// Cancelling removes the job before it can emit a late terminal envelope to the sandbox.
@@ -158,7 +162,20 @@ class NappletBrokerService : Service() {
// client's Messenger keeps a binder alive, which pins that surface's whole Activity (and its
// WebView) in the `:napplet` process past onDestroy — reclaimable only by killing the process.
if (msg.what == NappletIpc.MSG_RELEASE_CLIENT) {
msg.replyTo?.let { incBus.removeAll(it) }
msg.replyTo?.let {
incBus.removeAll(it)
liveSubscriptions.closeAllFor(it)
attendance.forget(it)
}
// Tokens the surface will never use again: drop their sessions and whatever runs under them.
// Only the surface that minted a token holds it (tokens are unguessable), so it can only ever
// give back its own.
msg.data?.getStringArray(NappletIpc.KEY_RELEASED_TOKENS)?.forEach { token ->
NappletLaunchRegistry.unregister(token)
identityWatch.stop(token)
val prefix = "$token\u0000"
resourceRequests.keys.filter { it.startsWith(prefix) }.forEach { key -> resourceRequests.remove(key)?.cancel() }
}
// Release its foreground lease too; otherwise a destroyed surface keeps the main process
// pinned resumed until the lease watchdog expires it.
msg.data?.getString(NappletIpc.KEY_LAUNCH_TOKEN)?.let { token ->
@@ -171,6 +188,15 @@ class NappletBrokerService : Service() {
// A sandbox surface (full-screen :napplet host) entered, renewed, or left the foreground. Hold the
// main process resumed while at least one is foreground, so opening it doesn't tear down Tor/relays.
if (msg.what == NappletIpc.MSG_SET_ATTENDED) {
val owner = msg.replyTo ?: return true
val attended = msg.data?.getBoolean(NappletIpc.KEY_ATTENDED, false) ?: false
attendance.set(owner, attended)
// Encrypted events its subscriptions received meanwhile can be decrypted and delivered now.
if (attended) liveSubscriptions.onAttended(owner)
return true
}
if (msg.what == NappletIpc.MSG_SET_FOREGROUND) {
val data = msg.data ?: return true
val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) ?: return true
@@ -336,8 +362,22 @@ class NappletBrokerService : Service() {
val identity = NappletIdentity(authorPubKey = BROWSER_IDENTITY_AUTHOR, identifier = origin)
// Bind to the account active at mint time: a browser token minted for one account must
// never sign as another if the user switches while the page is still open.
val mintAccount = Amethyst.instance.sessionManager.loggedInAccount() ?: return true
val token = NappletLaunchRegistry.register(identity, NappletCapability.WEBSITE_CAPABILITIES, mintAccount.pubKey)
val mintAccount = Amethyst.instance.sessionManager.loggedInAccount()
// The surface names the storage jar it runs in: a page left open across an account switch (its
// cookies, its session, belong to the previous account) must not be re-minted a token that acts
// as the new one — its token is evicted or released, and it asks again from the old jar.
val surfaceProfile = data.getString(NappletIpc.KEY_WEBVIEW_PROFILE)
if (mintAccount == null || surfaceProfile != NappletWebViewProfiles.forPubKey(mintAccount.pubKey)) {
// No one to act as: answer anyway (with no token), so the page's queued calls fail right away
// instead of waiting forever for a token that will never come.
val refusal =
Message.obtain(null, NappletIpc.MSG_BROWSER_TOKEN).apply {
this.data = Bundle().apply { putString(NappletIpc.KEY_BROWSER_ORIGIN, origin) }
}
runCatching { replyTo.send(refusal) }
return true
}
val token = NappletLaunchRegistry.register(identity, NappletCapability.WEBSITE_CAPABILITIES, mintAccount.pubKey, browserOrigin = true)
val response =
Message.obtain(null, NappletIpc.MSG_BROWSER_TOKEN).apply {
this.data =
@@ -366,6 +406,14 @@ class NappletBrokerService : Service() {
val session = NappletLaunchRegistry.resolve(launchToken)
if (session == null) {
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("Unknown napplet session.")))
// Tell the surface its token is gone, so a browser tab re-mints instead of failing every call.
if (launchToken != null) {
val unknown =
Message.obtain(null, NappletIpc.MSG_TOKEN_UNKNOWN).apply {
this.data = Bundle().apply { putString(NappletIpc.KEY_LAUNCH_TOKEN, launchToken) }
}
runCatching { replyTo.send(unknown) }
}
return true
}
val identity = session.identity
@@ -389,6 +437,12 @@ class NappletBrokerService : Service() {
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("That account is no longer signed in.")))
return@launch
}
// A query's results are decrypted with the user's key: while nobody is looking at the page it
// waits (as its sign / decrypt requests do), and gives up like them.
if (requestType == "relay.query" && !attendance.awaitAttended(replyTo, NappletHeldRequests.MAX_AGE_MS)) {
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed(NappletHeldRequests.EXPIRED)))
return@launch
}
when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) {
is NappletRequestRouter.Outcome.Ignore -> {}
is NappletRequestRouter.Outcome.Reply -> {
@@ -401,8 +455,8 @@ class NappletBrokerService : Service() {
}
}
is NappletRequestRouter.Outcome.OpenSubscription ->
liveSubscriptions.open(outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) }
is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(outcome.subId)
liveSubscriptions.open(replyTo, outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) }
is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(replyTo, outcome.subId)
is NappletRequestRouter.Outcome.Push -> outcome.payloads.forEach { push(replyTo, it) }
is NappletRequestRouter.Outcome.SubscribeInc -> incBus.subscribe(replyTo, outcome.topic)
is NappletRequestRouter.Outcome.UnsubscribeInc -> incBus.unsubscribe(replyTo, outcome.topic)
@@ -61,28 +61,37 @@ object NappletLaunchRegistry {
val accountPubKey: HexKey,
)
// Access-ordered + capped so tokens from long-closed napplets can't accumulate without bound. The
// active napplet always re-touches its token, so only stale sessions are ever evicted.
private const val MAX_SESSIONS = 128
// Access-ordered + capped so tokens from long-closed napplets can't accumulate without bound. Closed
// surfaces give their tokens back ([unregister]), so the cap is only a backstop for ones that never
// could (a crashed process); an idle live surface whose token is evicted anyway is told so and re-mints.
private const val MAX_SESSIONS = 512
// LruCache is internally synchronized and access-ordered — the same
// touch-on-resolve + evict-eldest-beyond-cap semantics the old access-ordered
// LinkedHashMap + @Synchronized pair provided, without JVM-only APIs.
private val sessions = LruCache<String, Session>(MAX_SESSIONS)
// Browser tokens live apart: a page mints one per origin it touches, so a site cycling through
// subdomains (a.x.com, b.x.com, …) could otherwise push every open napplet's token out of the cache.
private val browserSessions = LruCache<String, Session>(MAX_SESSIONS)
fun register(
identity: NappletIdentity,
declared: Set<NappletCapability>,
accountPubKey: HexKey,
browserOrigin: Boolean = false,
): String {
val token = RandomInstance.bytes(32).toHexKey()
sessions.put(token, Session(identity.copy(instanceId = token), declared, accountPubKey))
(if (browserOrigin) browserSessions else sessions).put(token, Session(identity.copy(instanceId = token), declared, accountPubKey))
return token
}
fun resolve(token: String?): Session? = token?.let { sessions[it] }
fun resolve(token: String?): Session? = token?.let { sessions[it] ?: browserSessions[it] }
fun unregister(token: String?) {
token?.let { sessions.remove(it) }
token?.let {
sessions.remove(it)
browserSessions.remove(it)
}
}
}
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.ThemeType
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.napplet.NappletArtifactPolicy
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.commons.tor.TorType
import com.vitorpamplona.amethyst.napplethost.HostProfile
import com.vitorpamplona.amethyst.napplethost.NappletHostActivity
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
@@ -174,7 +175,10 @@ object NappletLauncher {
// Resolve the per-site network choice (Tor default; a site can be opted out to the open web).
// Locked napplets always keep Tor for their blob fetches — only nSites expose the toggle.
NappletNetworkRegistry.init(context.applicationContext)
val useTor = if (profile.exposesNetwork) NappletNetworkRegistry.useTor(identity.coordinate) else true
// Whether Tor is ON, not whether its port is known yet: with Tor on and no port the host refuses to
// fetch anything (fails closed) instead of going out directly while Tor is still starting.
val torEnabled = Amethyst.instance.torPrefs.torType.value != TorType.OFF
val useTor = torEnabled && (!profile.exposesNetwork || NappletNetworkRegistry.useTor(identity.coordinate))
// Resolve capability labels here (the app has the resources) so the sandbox module needs none.
val capLabels = declared.map { stringRes(context, it.labelResId()) }
@@ -20,7 +20,9 @@
*/
package com.vitorpamplona.amethyst.napplet
import android.os.Messenger
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.napplet.NappletAttendance
import com.vitorpamplona.amethyst.commons.napplet.NappletRelayCleartext
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -37,7 +39,10 @@ import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
/**
* The registry of live relay subscriptions an applet has open, keyed by its `subId`. Each entry
* The registry of live relay subscriptions applets have open, keyed by the requesting surface's reply
* [Messenger] plus the applet's own `subId`. One broker serves every surface, and each page numbers its
* subs from scratch (`s0`, `s1`, …), so the `subId` alone would let one tab's REQ replace — or its
* `relay.close` kill — another tab's feed. Each entry
* holds the exact [INostrClient] that opened it, so teardown unsubscribes from the right account
* even after an account switch, plus an EOSE latch so a multi-relay subscription emits a single
* `relay.eose`. Encodes the `relay.event`/`relay.eose`/`relay.closed` pushes and hands them to the
@@ -51,8 +56,14 @@ import java.util.concurrent.atomic.AtomicInteger
*/
class NappletLiveSubscriptions(
private val scope: CoroutineScope,
private val attendance: NappletAttendance<Messenger>,
) {
private val liveSubs = ConcurrentHashMap<String, LiveSub>()
private data class Key(
val owner: Messenger,
val subId: String,
)
private val liveSubs = ConcurrentHashMap<Key, LiveSub>()
private val liveSeq = AtomicInteger(0)
private class LiveSub(
@@ -62,6 +73,10 @@ class NappletLiveSubscriptions(
val eoseSent = AtomicBoolean(false)
val deliveries = Channel<Delivery>(Channel.UNLIMITED)
var deliveryJob: Job? = null
// Encrypted events that arrived while nobody was looking at the page, still encrypted: they are
// decrypted and delivered when it is attended again. Touched only by the delivery coroutine.
val heldEncrypted = ArrayDeque<Event>()
}
private sealed interface Delivery {
@@ -71,17 +86,21 @@ class NappletLiveSubscriptions(
data object Eose : Delivery
// The page is being looked at again: deliver what was held.
data object Attended : Delivery
data class Closed(
val reason: String,
) : Delivery
}
/**
* Opens a live relay subscription for [nappletSubId], streaming `relay.event`/`relay.eose`/
* `relay.closed` envelopes to [push] as events arrive. Replaces any existing subscription for
* the same id. With no account/relays/filters it pushes a single empty EOSE to close it.
* Opens [owner]'s live relay subscription [nappletSubId], streaming `relay.event`/`relay.eose`/
* `relay.closed` envelopes to [push] as events arrive. Replaces any existing subscription [owner] has
* under the same id. With no account/relays/filters it pushes a single empty EOSE to close it.
*/
fun open(
owner: Messenger,
nappletSubId: String,
filters: List<Filter>,
account: Account?,
@@ -93,19 +112,34 @@ class NappletLiveSubscriptions(
return
}
close(nappletSubId)
val key = Key(owner, nappletSubId)
close(owner, nappletSubId)
// liveSeq guarantees a unique client subId, so a rapid re-open of the same applet subId
// can't collide with the subscription it's replacing.
val sub = LiveSub("napplet-$nappletSubId-${liveSeq.incrementAndGet()}", account.client)
liveSubs[nappletSubId] = sub
liveSubs[key] = sub
sub.deliveryJob =
scope.launch {
for (delivery in sub.deliveries) {
if (liveSubs[nappletSubId] !== sub) break
if (liveSubs[key] !== sub) break
when (delivery) {
is Delivery.RelayEvent ->
NappletRelayCleartext.forDelivery(delivery.event, account.signer)?.let {
push(NappletProtocolJson.encodeRelayEvent(nappletSubId, it))
is Delivery.RelayEvent -> {
val event = delivery.event
if (NappletRelayCleartext.isEncrypted(event) && !attendance.isAttended(owner)) {
// Don't decrypt for a page nobody is watching: keep it (bounded) for later.
if (sub.heldEncrypted.size >= MAX_HELD_ENCRYPTED) sub.heldEncrypted.removeFirst()
sub.heldEncrypted.addLast(event)
} else {
NappletRelayCleartext.forDelivery(event, account.signer)?.let {
push(NappletProtocolJson.encodeRelayEvent(nappletSubId, it))
}
}
}
Delivery.Attended ->
while (sub.heldEncrypted.isNotEmpty() && attendance.isAttended(owner)) {
NappletRelayCleartext.forDelivery(sub.heldEncrypted.removeFirst(), account.signer)?.let {
push(NappletProtocolJson.encodeRelayEvent(nappletSubId, it))
}
}
Delivery.Eose -> push(NappletProtocolJson.encodeRelayEose(nappletSubId))
is Delivery.Closed -> push(NappletProtocolJson.encodeRelayClosed(nappletSubId, delivery.reason))
@@ -145,21 +179,40 @@ class NappletLiveSubscriptions(
runCatching { sub.client.subscribe(sub.clientSubId, relays.associateWith { filters }, listener) }
}
/** Stops the live subscription for [nappletSubId], unsubscribing from the client that opened it. */
fun close(nappletSubId: String) {
val sub = liveSubs.remove(nappletSubId) ?: return
/** [owner] is being looked at again: its subscriptions deliver the encrypted events they held. */
fun onAttended(owner: Messenger) {
liveSubs.forEach { (key, sub) -> if (key.owner == owner) sub.deliveries.trySend(Delivery.Attended) }
}
/** Stops [owner]'s live subscription [nappletSubId], unsubscribing from the client that opened it. */
fun close(
owner: Messenger,
nappletSubId: String,
) {
liveSubs.remove(Key(owner, nappletSubId))?.let(::stop)
}
/** Stops every subscription [owner] still has open (its surface went away without closing them). */
fun closeAllFor(owner: Messenger) {
liveSubs.keys
.filter { it.owner == owner }
.forEach { key -> liveSubs.remove(key)?.let(::stop) }
}
/** Tears down every open subscription (service teardown). */
fun closeAll() {
liveSubs.values.forEach(::stop)
liveSubs.clear()
}
private fun stop(sub: LiveSub) {
sub.deliveries.close()
sub.deliveryJob?.cancel()
runCatching { sub.client.unsubscribe(sub.clientSubId) }
}
/** Tears down every open subscription (service teardown). */
fun closeAll() {
liveSubs.values.forEach { sub ->
sub.deliveries.close()
sub.deliveryJob?.cancel()
runCatching { sub.client.unsubscribe(sub.clientSubId) }
}
liveSubs.clear()
private companion object {
// Per subscription: past this, the oldest held encrypted event is dropped.
const val MAX_HELD_ENCRYPTED = 500
}
}
@@ -36,11 +36,12 @@ import android.os.Messenger
import android.os.SystemClock
import androidx.annotation.RequiresApi
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
import androidx.privacysandbox.ui.client.view.SandboxedSdkViewEventListener
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
import com.vitorpamplona.amethyst.commons.browser.ui.pill.CertificateInfo
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
@@ -49,6 +50,8 @@ import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles
import com.vitorpamplona.amethyst.napplet.WebFileChooserCoordinator
import com.vitorpamplona.amethyst.napplethost.NappletBrowserContract
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleBridge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleBuffer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedAutoRecovery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedLoadStatus
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedMagnifierProbe
@@ -59,6 +62,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.MagnifierFrame
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.consoleLevelOf
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseImeEvent
import java.util.UUID
import java.util.concurrent.atomic.AtomicLong
/**
@@ -70,7 +74,9 @@ import java.util.concurrent.atomic.AtomicLong
@RequiresApi(Build.VERSION_CODES.R)
class EmbeddedWebAppController(
private val appContext: Context,
private val proxyPort: Int,
// Read on every create and load rather than once: Tor may still be starting when the tab is made, and a
// Tor page loads nothing (fails closed) until its port is known.
private val proxyPort: () -> Int,
private val initialUseTor: Boolean,
private val backgroundColor: Int,
private val themeType: String = "SYSTEM",
@@ -97,6 +103,36 @@ class EmbeddedWebAppController(
private var hasLoadedReal = false
private var blankRecovered = false
// Brings the tab back when its sandbox-side surface dies (see [onSurfaceLost]).
private val recovery = EmbeddedAutoRecovery(SystemClock::elapsedRealtime)
// The remote session behind the current view errored out: only a brand-new session can repaint it.
private var sessionDead = false
// Set after the first connection, so a later onServiceConnected is recognised as `:napplet` coming back.
private var everConnected = false
// Set by [unbind]: nothing that arrives afterwards may act.
private var tornDown = false
// A `:napplet` restart found this tab hidden: its session is re-created when it is next shown.
private var createOnShow = false
// A create is in flight: the view's old session erroring out now is the one being replaced, not news.
private var awaitingReady = false
// The current session's surface has shown in the view at least once (see [retry]).
private var uiDisplayed = false
// What the provider was last told (see [syncPageState]). Remembered so both are replayed right after each
// session is created: a parked tab can be hidden before the service even binds.
private var wantPaused = false
private var wantAttended = false
// The app is on screen / has been in the background long enough to pause even the visible tab.
private var appVisible = true
private var backgroundIdle = false
/** Last known main-frame load state, so the tab layer renders the right overlay immediately. */
override var loadStatus: EmbeddedLoadStatus = EmbeddedLoadStatus()
private set
@@ -105,15 +141,65 @@ class EmbeddedWebAppController(
override var onLoadStatusChanged: ((EmbeddedLoadStatus) -> Unit)? = null
/** JavaScript console output received from the embedded WebView, capped at [MAX_CONSOLE_LOGS] entries. */
override val consoleLogs = mutableStateListOf<ConsoleLine>()
private val console = ConsoleBuffer(MAX_CONSOLE_LOGS)
override val consoleLogs get() = console.lines
override val consoleErrorCount get() = console.errorCount
override fun clearConsoleLogs() = consoleLogs.clear()
override fun clearConsoleLogs() = console.clear()
// The user's per-tab page settings. The provider forgets them whenever the session is re-created (a
// `:napplet` restart, a rearm), so they are re-sent with every create — otherwise a site the user
// switched onto Tor would silently come back over clearnet while the pill still said Tor.
private var useTor = initialUseTor
private var textZoom = BrowserChrome.DEFAULT_TEXT_ZOOM
private var desktopSite = false
// The page on screen, kept here rather than in the tab's screen: the screen leaves composition whenever
// the user switches bottom-bar tabs, and coming back must show where they were (the right address for
// share / favorite / site settings, and a Back that goes back in the page instead of leaving the tab).
var lastUrl: String? = null
private set
var lastTitle: String? = null
private set
var lastCanGoBack = false
private set
var lastCanGoForward = false
private set
/** A tab's page and per-tab settings, carried to the controller that replaces this one on a rebuild. */
class PageSnapshot(
val url: String?,
val useTor: Boolean,
val textZoom: Int,
val desktopSite: Boolean,
)
fun snapshot() = PageSnapshot(lastUrl, useTor, textZoom, desktopSite)
/** Takes over a torn-down predecessor's page and settings; call before [bind], which creates the session. */
fun restore(snapshot: PageSnapshot) {
lastUrl = snapshot.url
useTor = snapshot.useTor
textZoom = snapshot.textZoom
desktopSite = snapshot.desktopSite
}
/** The user's per-tab settings as last set, for a screen coming back to this tab. */
val isTorOn: Boolean get() = useTor
// Whether `:napplet` routes through Tor right now: another surface that needs Tor puts every page on it.
private val routedOverTor = mutableStateOf(false)
/** This page is set to the open web but goes through Tor anyway, because another open page needs Tor. */
val isTorForced: Boolean get() = !useTor && routedOverTor.value
val isDesktopSite: Boolean get() = desktopSite
val currentTextZoom: Int get() = textZoom
// A single NappletBrowserService instance serves every embedded browser tab, so each controller
// stamps its own id on every message; the provider uses it to route controls/updates to this tab.
// Re-minted whenever the remote session is re-created (see [attachView]), so a late close() from the
// previous view can never reap the replacement.
private var sessionId: String = "browser-${SESSION_SEQ.incrementAndGet()}"
private var sessionId: String = newSessionId()
/** Invoked on the main thread when the page navigates or retitles: (url, title or null, canGoBack, canGoForward). */
var onUrlChanged: ((String, String?, Boolean, Boolean) -> Unit)? = null
@@ -153,11 +239,29 @@ class EmbeddedWebAppController(
service: IBinder?,
) {
serviceMessenger = Messenger(service)
if (everConnected) {
// `:napplet` died and was restarted. Re-creating the session IS the recovery (a fresh
// process has no session under any id), so nothing else is left pending; cover the
// surface until the new page paints. Only the visible tab rebuilds now: every warm tab
// reconnects at once, and rebuilding them all right after the OS reclaimed that memory
// would just push it back up. The rest re-create when next shown.
recovery.clearPending()
sessionDead = false
showRecovering()
everConnected = true
if (recovery.isShown) sendCreateSession() else createOnShow = true
return
}
everConnected = true
sendCreateSession()
}
override fun onServiceDisconnected(name: ComponentName?) {
// `:napplet` died (the OS reclaimed it, or it crashed). Its WebViews went with it; the
// system restarts the bound service and [onServiceConnected] re-creates the session.
serviceMessenger = null
resetPageState()
showRecovering()
}
}
@@ -168,12 +272,17 @@ class EmbeddedWebAppController(
}
fun unbind() {
// Tell the provider to drop this tab's session now: one created for a view that was disposed before
// it attached never gets the surface close that would otherwise clean it up.
send(NappletBrowserContract.MSG_CLOSE_SESSION) {}
tornDown = true
if (bound) {
runCatching { appContext.unbindService(connection) }
bound = false
}
// Drop refs so an evicted controller doesn't pin the surface view or the remote messenger.
serviceMessenger = null
sandboxedSdkView?.setEventListener(null)
sandboxedSdkView = null
pendingAdapter = null
adapterDelivered = false
@@ -181,15 +290,73 @@ class EmbeddedWebAppController(
onImeEvent = null
onMagnifierFrame = null
onLoadStatusChanged = null
consoleLogs.clear()
console.clear()
resetPageState()
}
/**
* Drops UI state that belongs to the page on screen: once that page is gone (renderer death, session
* lost, `:napplet` restart) nothing will ever close it. A stale fullscreen flag swallowed every Back
* press, and a stale dialog or permission prompt auto-refused every new one from the rebuilt page.
*/
private fun resetPageState() {
pendingDialog.value = null
pendingPermission.value = null
pendingDownload.value = null
isFullscreen.value = false
_findResult.value = null
}
override fun teardown() = unbind()
override fun onShown() {
val deferredRecovery = recovery.onShown()
syncPageState()
if (createOnShow) {
createOnShow = false
sendCreateSession()
} else if (deferredRecovery) {
recover()
}
}
override fun onHidden() {
recovery.onHidden()
syncPageState()
}
override fun onAppVisibility(visible: Boolean) {
appVisible = visible
syncPageState()
}
override fun onBackgroundIdle(idle: Boolean) {
backgroundIdle = idle
syncPageState()
}
/**
* Tells the provider what the page may do now:
* - paused while parked off-screen, or once the app has sat in the background as long as the relays get
* (EmbeddedTabHost.BACKGROUND_PAUSE_MS) — no animations, media or geolocation keep running;
* - attended only while it's the visible tab AND the app is on screen. The provider holds the page's
* NIP-07 sign / encrypt / decrypt while it isn't, so a parked or backgrounded site can't sign (even with
* "allow always") while nobody is looking. That one applies at once: it's about who is watching, not
* about saving work.
*/
private fun syncPageState() {
val pause = !recovery.isShown || backgroundIdle
if (pause != wantPaused) {
wantPaused = pause
send(if (pause) NappletBrowserContract.MSG_PAUSE else NappletBrowserContract.MSG_RESUME) {}
}
val attended = recovery.isShown && appVisible
if (attended != wantAttended) {
wantAttended = attended
send(NappletBrowserContract.MSG_SET_ATTENDED) { putBoolean(NappletBrowserContract.KEY_ENABLED, attended) }
}
}
/**
* Hands the surface view to the controller; applies the adapter if it already arrived, and re-arms the
* remote session when this controller is being re-used by a *second* view.
@@ -212,6 +379,7 @@ class EmbeddedWebAppController(
// Paint the surface placeholder in the app's theme background so there's no white flash before
// the remote WebView delivers its first frame.
view.setBackgroundColor(backgroundColor)
view.setEventListener(surfaceListener(view))
val adapter = pendingAdapter
when {
adapter != null -> {
@@ -221,30 +389,120 @@ class EmbeddedWebAppController(
}
// No adapter in hand and one was already spent on a previous (now disposed) view: the session
// behind it is gone, so this view would stay blank forever. Re-create it.
adapterDelivered -> {
// Mint a FRESH session id. The disposed view's Session.close() reaches the sandbox
// asynchronously (it posts to the sandbox's main thread) and was measured landing ~1 s
// AFTER this create: reusing the id let that late close reap the session we had just asked
// for — a new WebView was built, destroyed, and the surface stayed black. A new id makes
// the stale close target only the corpse it belongs to.
sessionId = "browser-${SESSION_SEQ.incrementAndGet()}"
adapterDelivered = false
sendCreateSession()
}
adapterDelivered -> rearmSession()
// else: the first session is still in flight; MSG_SESSION_READY will arm this view.
}
}
override fun detachView(view: SandboxedSdkView) {
if (sandboxedSdkView !== view) return
view.setEventListener(null)
sandboxedSdkView = null
}
/**
* Asks the sandbox for a brand-new session; the [NappletBrowserContract.MSG_SESSION_READY] reply arms
* the current view with its adapter.
*
* Mints a FRESH session id. A disposed view's Session.close() reaches the sandbox asynchronously (it
* posts to the sandbox's main thread) and was measured landing ~1 s AFTER the create: reusing the id let
* that late close reap the session we had just asked for — a new WebView was built, destroyed, and the
* surface stayed black. A new id makes the stale close target only the corpse it belongs to.
*/
private fun rearmSession() {
// The session being replaced may never have opened a surface (its view went away first), in which
// case no surface close will ever reach the provider for it.
send(NappletBrowserContract.MSG_CLOSE_SESSION) {}
sessionId = newSessionId()
// This create IS the re-creation a `:napplet` restart deferred to the next show.
createOnShow = false
adapterDelivered = false
sessionDead = false
resetPageState()
sendCreateSession()
}
/**
* Watches [view]'s remote session. A session that errors out (its provider failed, or `:napplet` died)
* leaves the view holding a dead client that never reopens: it paints nothing, forever, until it is
* handed a NEW adapter.
*/
private fun surfaceListener(view: SandboxedSdkView) =
object : SandboxedSdkViewEventListener {
override fun onUiDisplayed() {
// The load state reports when the page itself paints; this only says the surface opened.
if (sandboxedSdkView === view) uiDisplayed = true
}
override fun onUiError(error: Throwable) {
// A view this controller has since moved past (disposed, replaced) is not ours to revive, and an
// error landing while a new session is on its way is the old one dying: that create already
// is the rebuild.
if (sandboxedSdkView === view && !awaitingReady) onSurfaceLost(sessionDead = true)
}
override fun onUiClosed() {
// Nothing to do: closes are ours (a view disposed, or an adapter replaced on purpose).
}
}
/**
* The tab's page is gone: its WebView's renderer died ([sessionDead] false — the session lives on, and a
* MSG_RELOAD rebuilds the WebView inside it), or the whole remote session errored out ([sessionDead]
* true — only a new session can repaint the view). Either way the surface is a black rectangle that would
* stay that way, so rebuild it, as [EmbeddedAutoRecovery] allows.
*/
private fun onSurfaceLost(sessionDead: Boolean) {
if (sessionDead) this.sessionDead = true
hasLoadedReal = false
resetPageState()
// `:napplet` itself is down: its restart re-creates the session (see [onServiceConnected]).
if (serviceMessenger?.binder?.isBinderAlive != true) {
showRecovering()
return
}
when (recovery.onLost()) {
EmbeddedAutoRecovery.Decision.RECOVER_NOW -> recover()
EmbeddedAutoRecovery.Decision.DEFERRED -> showRecovering()
EmbeddedAutoRecovery.Decision.GIVE_UP -> publishLoadStatus(EmbeddedLoadStatus(failed = true))
}
}
private fun recover() {
showRecovering()
if (sessionDead) rearmSession() else reload()
}
/** Covers the surface with the loading spinner until the rebuilt page paints. */
private fun showRecovering() {
hasLoadedReal = false
blankRecovered = false
publishLoadStatus(EmbeddedLoadStatus(isLoading = true))
}
// Set by the user's Retry: the next session starts over at [startUrl]. Every other re-creation (a crashed
// renderer, a `:napplet` restart, a memory-trim rebuild) resumes the page the user was on.
private var restartAtStart = false
/** Where a new session opens: the page on screen before it was lost, else the tab's own [startUrl]. */
private fun sessionUrl(): String {
val resume = lastUrl?.takeUnless { restartAtStart || it.isBlankPage() }
restartAtStart = false
return resume ?: startUrl
}
private fun sendCreateSession() {
awaitingReady = true
uiDisplayed = false
val msg =
Message.obtain(null, NappletBrowserContract.MSG_CREATE_SESSION).apply {
replyTo = incoming
data =
Bundle().apply {
putString(NappletBrowserContract.KEY_SESSION_ID, sessionId)
putString(NappletBrowserContract.KEY_URL, startUrl)
putInt(NappletBrowserContract.KEY_PROXY_PORT, proxyPort)
putBoolean(NappletBrowserContract.KEY_USE_TOR, initialUseTor)
putString(NappletBrowserContract.KEY_URL, sessionUrl())
putInt(NappletBrowserContract.KEY_PROXY_PORT, proxyPort())
putBoolean(NappletBrowserContract.KEY_USE_TOR, useTor)
putInt(NappletBrowserContract.KEY_BG_COLOR, backgroundColor)
putString(NappletBrowserContract.KEY_THEME, themeType)
// Opaque per-account storage partition, so an embedded site can't carry one
@@ -253,12 +511,25 @@ class EmbeddedWebAppController(
}
}
runCatching { serviceMessenger?.send(msg) }
// Messenger keeps order, so these land after the CREATE and are stored on the new tab.
if (textZoom != BrowserChrome.DEFAULT_TEXT_ZOOM) setTextZoom(textZoom)
if (desktopSite) setDesktopSite(true)
if (wantPaused) send(NappletBrowserContract.MSG_PAUSE) {}
// Always: a new session starts unattended, so a tab created in view must say it is being watched.
send(NappletBrowserContract.MSG_SET_ATTENDED) { putBoolean(NappletBrowserContract.KEY_ENABLED, wantAttended) }
}
private fun onServiceMessage(msg: Message): Boolean {
// Nothing may act on a torn-down tab (a late file-chooser request would still open a picker), nor on
// what a session this controller has since replaced still had in flight — a stale SESSION_READY
// would re-arm the view with that dead session's adapter.
if (tornDown) return true
val from = msg.data?.getString(NappletBrowserContract.KEY_SESSION_ID)
if (from != null && from != sessionId) return true
when (msg.what) {
NappletBrowserContract.MSG_SESSION_READY -> {
val coreLibInfo = msg.data?.getBundle(NappletBrowserContract.KEY_CORE_LIB_INFO) ?: return true
awaitingReady = false
val adapter = SandboxedUiAdapterFactory.createFromCoreLibInfo(coreLibInfo)
val view = sandboxedSdkView
if (view != null) {
@@ -273,6 +544,12 @@ class EmbeddedWebAppController(
val canGoBack = msg.data?.getBoolean(NappletBrowserContract.KEY_CAN_GO_BACK, false) ?: false
val canGoForward = msg.data?.getBoolean(NappletBrowserContract.KEY_CAN_GO_FORWARD, false) ?: false
val title = msg.data?.getString(NappletBrowserContract.KEY_TITLE)
if (url != "about:blank") {
lastUrl = url
lastTitle = title
}
lastCanGoBack = canGoBack
lastCanGoForward = canGoForward
onUrlChanged?.invoke(url, title, canGoBack, canGoForward)
}
NappletBrowserContract.MSG_IME_EVENT -> {
@@ -283,15 +560,18 @@ class EmbeddedWebAppController(
val isLoading = msg.data?.getBoolean(NappletBrowserContract.KEY_IS_LOADING, false) ?: false
val failed = msg.data?.getBoolean(NappletBrowserContract.KEY_LOAD_FAILED, false) ?: false
val loadedUrl = msg.data?.getString(NappletBrowserContract.KEY_URL).orEmpty()
onLoadState(isLoading, failed, loadedUrl)
if (msg.data?.getBoolean(NappletBrowserContract.KEY_RENDERER_GONE, false) == true) {
onSurfaceLost(sessionDead = false)
} else {
onLoadState(isLoading, failed, loadedUrl)
}
}
NappletBrowserContract.MSG_CONSOLE_LOG -> {
val level = msg.data?.getString(NappletBrowserContract.KEY_CONSOLE_LEVEL) ?: "LOG"
val message = msg.data?.getString(NappletBrowserContract.KEY_CONSOLE_MESSAGE).orEmpty()
val source = msg.data?.getString(NappletBrowserContract.KEY_CONSOLE_SOURCE).orEmpty()
val line = msg.data?.getInt(NappletBrowserContract.KEY_CONSOLE_LINE, 0) ?: 0
if (consoleLogs.size >= MAX_CONSOLE_LOGS) consoleLogs.removeAt(0)
consoleLogs.add(ConsoleLine(consoleLevelOf(level), message, source, line))
console.add(ConsoleLine(consoleLevelOf(level), message, source, line))
}
NappletBrowserContract.MSG_FILE_CHOOSER_REQUEST -> {
val data = msg.data ?: return true
@@ -371,6 +651,7 @@ class EmbeddedWebAppController(
val id = msg.data?.getLong(NappletBrowserContract.KEY_PERMISSION_ID)
if (pendingPermission.value?.id == id) pendingPermission.value = null
}
NappletBrowserContract.MSG_ROUTE -> routedOverTor.value = msg.data?.getBoolean(NappletBrowserContract.KEY_USE_TOR, false) ?: false
NappletBrowserContract.MSG_DOWNLOAD_CONSENT -> {
val data = msg.data ?: return true
val id = data.getLong(NappletBrowserContract.KEY_DOWNLOAD_ID)
@@ -414,9 +695,13 @@ class EmbeddedWebAppController(
return true
}
fun navigate(url: String) = send(NappletBrowserContract.MSG_NAVIGATE) { putString(NappletBrowserContract.KEY_URL, url) }
fun navigate(url: String) =
send(NappletBrowserContract.MSG_NAVIGATE) {
putString(NappletBrowserContract.KEY_URL, url)
putInt(NappletBrowserContract.KEY_PROXY_PORT, proxyPort())
}
fun reload() = send(NappletBrowserContract.MSG_RELOAD) {}
fun reload() = send(NappletBrowserContract.MSG_RELOAD) { putInt(NappletBrowserContract.KEY_PROXY_PORT, proxyPort()) }
/**
* User-triggered recovery for a stuck, blank, or failed session: reload the canonical [startUrl] from
@@ -424,10 +709,15 @@ class EmbeddedWebAppController(
* session that never got its URL), this re-navigates to the favorite's real URL.
*/
override fun retry() {
blankRecovered = false
hasLoadedReal = false
publishLoadStatus(EmbeddedLoadStatus(isLoading = true))
navigate(startUrl)
recovery.clearPending()
showRecovering()
// A surface that never opened has nothing to navigate: only a new session can paint it.
if (sessionDead || (sandboxedSdkView != null && !uiDisplayed)) {
restartAtStart = true
rearmSession()
} else {
navigate(startUrl)
}
}
private fun onLoadState(
@@ -470,9 +760,15 @@ class EmbeddedWebAppController(
override fun findNext(forward: Boolean) = send(NappletBrowserContract.MSG_FIND_NEXT) { putBoolean(NappletBrowserContract.KEY_FIND_FORWARD, forward) }
fun setDesktopSite(enabled: Boolean) = send(NappletBrowserContract.MSG_SET_DESKTOP) { putBoolean(NappletBrowserContract.KEY_ENABLED, enabled) }
fun setDesktopSite(enabled: Boolean) {
desktopSite = enabled
send(NappletBrowserContract.MSG_SET_DESKTOP) { putBoolean(NappletBrowserContract.KEY_ENABLED, enabled) }
}
fun setTextZoom(percent: Int) = send(NappletBrowserContract.MSG_SET_TEXT_ZOOM) { putInt(NappletBrowserContract.KEY_TEXT_ZOOM, percent) }
fun setTextZoom(percent: Int) {
textZoom = percent
send(NappletBrowserContract.MSG_SET_TEXT_ZOOM) { putInt(NappletBrowserContract.KEY_TEXT_ZOOM, percent) }
}
/** Back to the app's home origin ([homeUrl]), Chrome's out-of-scope ✕. */
fun backToScope(homeUrl: String) = send(NappletBrowserContract.MSG_BACK_TO_SCOPE) { putString(NappletBrowserContract.KEY_URL, homeUrl) }
@@ -526,7 +822,13 @@ class EmbeddedWebAppController(
}
}
fun setTor(useTor: Boolean) = send(NappletBrowserContract.MSG_SET_TOR) { putBoolean(NappletBrowserContract.KEY_USE_TOR, useTor) }
fun setTor(useTor: Boolean) {
this.useTor = useTor
send(NappletBrowserContract.MSG_SET_TOR) {
putBoolean(NappletBrowserContract.KEY_USE_TOR, useTor)
putInt(NappletBrowserContract.KEY_PROXY_PORT, proxyPort())
}
}
override fun sendImeOp(json: String) = send(NappletBrowserContract.MSG_IME_OP) { putString(NappletBrowserContract.KEY_IME_PAYLOAD, json) }
@@ -563,6 +865,12 @@ class EmbeddedWebAppController(
private companion object {
private val SESSION_SEQ = AtomicLong()
// The provider outlives this process's restarts (and this counter with them): without a per-process
// nonce a fresh main process would hand out ids a still-running `:napplet` already holds.
private val PROCESS_NONCE = UUID.randomUUID().toString().take(8)
private const val MAX_CONSOLE_LOGS = 200
private fun newSessionId() = "browser-$PROCESS_NONCE-${SESSION_SEQ.incrementAndGet()}"
}
}
@@ -55,6 +55,7 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
@@ -74,6 +75,7 @@ import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.model.navigation.favoriteIds
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.browser_unsupported
import com.vitorpamplona.amethyst.commons.tor.TorType
import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar
@@ -129,23 +131,10 @@ private fun EmbeddedWebAppTab(
// Matches FavoriteApp.WebApp.id, so warm-keep membership lines up with the bottom-bar favorites.
val id = "url:$url"
var currentUrl by remember { mutableStateOf(url) }
// The page's own <title>; null until the current document reports one (the sheet shows the host).
var pageTitle by remember { mutableStateOf<String?>(null) }
var canGoBack by remember { mutableStateOf(false) }
var canGoForward by remember { mutableStateOf(false) }
var desktopSite by remember { mutableStateOf(false) }
var textZoom by remember { mutableIntStateOf(BrowserChrome.DEFAULT_TEXT_ZOOM) }
var showPageInfo by remember { mutableStateOf(false) }
val proxyAvailable = remember { Amethyst.instance.torManager.activePortOrNull.value != null }
// Start from this site's remembered Tor choice (some sites' servers reject Tor exits, so the user
// can opt one out and it must stick). Only meaningful when Tor is actually available.
var torOn by remember { mutableStateOf(proxyAvailable && WebAppNetworkRegistry.useTor(url)) }
val apps by Amethyst.instance.favoriteApps.favorites
.collectAsStateWithLifecycle()
val isFavorite = remember(apps, currentUrl) { apps.any { it is FavoriteApp.WebApp && it.url == currentUrl } }
// Tor is ON (its port may still be coming up: a Tor page then waits, it never falls back to the open web).
val proxyAvailable = remember { Amethyst.instance.torPrefs.torType.value != TorType.OFF }
val backgroundColor = MaterialTheme.colorScheme.background.toArgb()
@@ -155,6 +144,23 @@ private fun EmbeddedWebAppTab(
remember(id, EmbeddedTabHost.rebuildEpoch) {
EmbeddedTabFactory.acquireWebApp(context, url, backgroundColor)
}
// Seeded from the controller, which outlives this screen: switching bottom-bar tabs disposes the screen
// but keeps the page, so coming back must show where the page is — not the start URL with no history.
var currentUrl by remember(controller) { mutableStateOf(controller.lastUrl ?: url) }
// The page's own <title>; null until the current document reports one (the sheet shows the host).
var pageTitle by remember(controller) { mutableStateOf(controller.lastTitle) }
var canGoBack by remember(controller) { mutableStateOf(controller.lastCanGoBack) }
var canGoForward by remember(controller) { mutableStateOf(controller.lastCanGoForward) }
var desktopSite by remember(controller) { mutableStateOf(controller.isDesktopSite) }
var textZoom by remember(controller) { mutableIntStateOf(controller.currentTextZoom) }
// The controller starts from this site's remembered Tor choice (some sites' servers reject Tor exits, so
// the user can opt one out and it must stick). Only meaningful when Tor is actually available.
var torOn by remember(controller) { mutableStateOf(proxyAvailable && controller.isTorOn) }
val apps by Amethyst.instance.favoriteApps.favorites
.collectAsStateWithLifecycle()
val isFavorite = remember(apps, currentUrl) { apps.any { it is FavoriteApp.WebApp && it.url == currentUrl } }
val isLoading by controller.isLoading
// Keep the URL/back callback fresh (cheap, needs the latest closure).
@@ -239,9 +245,12 @@ private fun EmbeddedWebAppTab(
val siteDecisions by WebSitePermissionRegistry.decisions.collectAsStateWithLifecycle()
val sitePermissions = remember(siteDecisions, currentUrl) { browserOrigin(currentUrl)?.let { siteDecisions[it] }.orEmpty() }
// Off for this site, yet on Tor because another open page needs it (Tor always wins in `:napplet`).
val torForced = controller.isTorForced
// Rebuilt only when a displayed value changes, so the tab layer isn't recomposed every frame.
val chrome =
remember(currentUrl, pageTitle, canGoBack, canGoForward, isLoading, torOn, proxyAvailable, isFavorite, desktopSite, textZoom, sitePermissions, candidates, controller) {
remember(currentUrl, pageTitle, canGoBack, canGoForward, isLoading, torOn, torForced, proxyAvailable, isFavorite, desktopSite, textZoom, sitePermissions, candidates, controller) {
EmbeddedTabChrome(
ui =
BrowserPillUi(
@@ -256,6 +265,7 @@ private fun EmbeddedWebAppTab(
canGoForward = canGoForward,
isLoading = isLoading,
torOn = if (proxyAvailable) torOn else null,
torForced = torForced,
hasSiteSettings = browserOrigin(currentUrl) != null,
),
isFavorite = isFavorite,
@@ -289,13 +299,19 @@ private fun EmbeddedWebAppTab(
SideEffect { EmbeddedTabHost.setActiveChrome(id, chrome) }
val bottomBarFlow = accountViewModel.account.settings.syncedSettings.navigation.bottomBarItems
val entryLifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(id) {
val token = EmbeddedTabHost.setActive(id)
EmbeddedTabHost.hold(id)
onDispose {
EmbeddedTabHost.clearActiveIfOwner(token)
EmbeddedTabHost.clearActiveChrome(id)
// Only bottom-row apps stay warm; anything else restarts when it leaves.
if (id !in bottomBarFlow.value.favoriteIds()) EmbeddedTabHost.evict(id)
// Only bottom-row apps stay warm; anything else restarts once the user actually leaves it — not
// when a screen is merely pushed on top, and not when a re-navigation to this same tab already
// composed a new screen on the same session.
if (EmbeddedTabHost.release(id)) {
EmbeddedTabHost.releaseWhenGone(id, entryLifecycle) { id in bottomBarFlow.value.favoriteIds() }
}
}
}
@@ -20,6 +20,9 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
import androidx.compose.runtime.IntState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.SnapshotStateList
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
@@ -31,9 +34,39 @@ import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
interface ConsoleBridge {
val consoleLogs: SnapshotStateList<ConsoleLine>
/**
* How many of [consoleLogs] are errors — the pill's badge. Kept as its own state so the tab layer
* reads a single int instead of counting the list, which subscribed it to EVERY log line: a page
* logging each frame recomposed the whole layer each frame.
*/
val consoleErrorCount: IntState
fun clearConsoleLogs()
}
/**
* A capped JavaScript console buffer that keeps its error count as separate state (see
* [ConsoleBridge.consoleErrorCount]). Main-thread only.
*/
class ConsoleBuffer(
private val max: Int,
) {
val lines = mutableStateListOf<ConsoleLine>()
private val errors = mutableIntStateOf(0)
val errorCount: IntState get() = errors
fun add(line: ConsoleLine) {
if (lines.size >= max && lines.removeAt(0).level == ConsoleLine.Level.ERROR) errors.intValue--
lines.add(line)
if (line.level == ConsoleLine.Level.ERROR) errors.intValue++
}
fun clear() {
lines.clear()
errors.intValue = 0
}
}
/** Maps a provider's console level (WebView's `ConsoleMessage.MessageLevel` name) onto the chrome's. */
fun consoleLevelOf(level: String): ConsoleLine.Level =
when (level) {
@@ -0,0 +1,96 @@
/*
* 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.embed
/**
* Decides when an embedded tab rebuilds itself after its sandbox-side surface died underneath it.
*
* Every WebView in `:napplet` shares one renderer process. When the OS reclaims it (memory pressure after
* a long session, the app sitting in the background) or it crashes, EVERY warm tab loses its WebView at
* once, and a surface whose remote session errored out paints nothing — both leave a black rectangle that
* never comes back on its own. The tab was already loaded, so no load overlay covers it either.
*
* Recovery is automatic but lazy: the visible tab rebuilds right away, a parked one only when it is next
* shown, so a renderer death doesn't rebuild every warm tab at once and push memory straight back up. A
* page that kills its renderer again right after an automatic rebuild is not rebuilt in a loop — the tab
* falls back to the error overlay and its Retry.
*
* Main-thread only. [now] is injectable for tests.
*/
class EmbeddedAutoRecovery(
private val now: () -> Long,
) {
enum class Decision {
/** Rebuild now: the tab is on screen. */
RECOVER_NOW,
/** The tab is parked; [onShown] will ask for the rebuild. */
DEFERRED,
/** It died again right after an automatic rebuild: stop and let the user retry. */
GIVE_UP,
}
private var shown = false
/** Whether the tab is the visible one (per the last [onShown] / [onHidden]). */
val isShown: Boolean get() = shown
private var pending = false
private var lastAutoRecoveryAt: Long? = null
/** The tab's surface died. */
fun onLost(): Decision {
if (!shown) {
pending = true
return Decision.DEFERRED
}
val last = lastAutoRecoveryAt
if (last != null && now() - last < LOOP_WINDOW_MS) {
pending = false
return Decision.GIVE_UP
}
lastAutoRecoveryAt = now()
return Decision.RECOVER_NOW
}
/** The tab became visible. Returns true when a deferred rebuild must run now. */
fun onShown(): Boolean {
shown = true
if (!pending) return false
pending = false
lastAutoRecoveryAt = now()
return true
}
fun onHidden() {
shown = false
}
/** The surface came back by other means (a fresh session, a user retry): nothing left to rebuild. */
fun clearPending() {
pending = false
}
companion object {
/** A second death this soon after an automatic rebuild means the page itself is killing it. */
const val LOOP_WINDOW_MS = 30_000L
}
}
@@ -62,10 +62,15 @@ class MagnifierUiState {
var lastRequestUptimeMs: Long = 0L
var awaitingFrame: Boolean = false
// Request stamp of the frame on screen. Frames decode off the main thread and can finish out of order,
// so an older one must not replace a newer one.
var shownFrameStamp: Long = Long.MIN_VALUE
fun hide() {
visible = false
image = null
awaitingFrame = false
shownFrameStamp = Long.MIN_VALUE
}
}
@@ -40,6 +40,13 @@ import androidx.privacysandbox.ui.client.view.SandboxedSdkView
interface EmbeddedSurfaceController {
fun attachView(view: SandboxedSdkView)
/**
* [view] left the composition. The controller outlives it (it lives in the process-scoped host), so it
* must let go of it — a view holds its Activity, and a warm controller still pointing at the view of an
* Activity the user backed out of would keep that whole Activity alive.
*/
fun detachView(view: SandboxedSdkView)
/** The session became the visible tab. */
fun onShown() {
// Optional hook: default no-op. Controllers that don't pause/resume applet JS need no action.
@@ -50,6 +57,22 @@ interface EmbeddedSurfaceController {
// Optional hook: default no-op. Controllers that don't pause/resume applet JS need no action.
}
/**
* The app left the screen ([visible] false) or came back. Even the visible tab has nobody looking at it
* while the app is in the background, so a controller stops anything that acts for the user right away.
*/
fun onAppVisibility(visible: Boolean) {
// Optional hook: default no-op (napplet screens pause on their own lifecycle).
}
/**
* The app has been in the background long enough that the rest of it winds down too (relays disconnect
* at the same point): [idle] true pauses even the visible tab's page; false when the app returns.
*/
fun onBackgroundIdle(idle: Boolean) {
// Optional hook: default no-op (napplet screens pause on their own lifecycle).
}
/** Permanently close the session (unbind the service); used on eviction. */
fun teardown()
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.model.ThemeType
import com.vitorpamplona.amethyst.commons.tor.TorType
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.napplet.NappletLaunchRegistry
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.EmbeddedWebAppController
@@ -50,6 +51,9 @@ object EmbeddedTabFactory {
fun nostrAppId(coordinate: String) = "nostr:$coordinate"
/** Tor's SOCKS port right now, or -1 while it is off or still starting. */
fun currentTorPort(): Int = Amethyst.instance.torManager.activePortOrNull.value ?: -1
/** Acquires (or returns) the warm browser controller for [url], routing over Tor per the site's choice. */
fun acquireWebApp(
context: Context,
@@ -57,8 +61,8 @@ object EmbeddedTabFactory {
backgroundColor: Int,
): EmbeddedWebAppController =
EmbeddedTabHost.acquire(webAppId(url)) {
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
val initialUseTor = proxyPort > 0 && WebAppNetworkRegistry.useTor(url)
// Tor ON, not "port known": the provider blocks a Tor page until the port is there (fails closed).
val initialUseTor = Amethyst.instance.torPrefs.torType.value != TorType.OFF && WebAppNetworkRegistry.useTor(url)
val themeType = Amethyst.instance.uiPrefs.value.theme.value
val theme =
when (themeType) {
@@ -69,7 +73,10 @@ object EmbeddedTabFactory {
if (nightMask == Configuration.UI_MODE_NIGHT_YES) "DARK" else "LIGHT"
}
}
EmbeddedWebAppController(context.applicationContext, proxyPort, initialUseTor, backgroundColor, theme).also { it.bind(url) }
EmbeddedWebAppController(context.applicationContext, ::currentTorPort, initialUseTor, backgroundColor, theme).also {
EmbeddedTabHost.takePageSnapshot(webAppId(url))?.let(it::restore)
it.bind(url)
}
} as EmbeddedWebAppController
/**
@@ -84,6 +91,11 @@ object EmbeddedTabFactory {
backgroundColor: Int,
): EmbeddedNostrAppController {
params.putInt(NappletHostContract.EXTRA_BG_COLOR, backgroundColor)
// The screen mints fresh params on every visit, but a warm tab keeps the session (and token) it was
// built with — give the unused fresh token back instead of leaving it registered.
if (EmbeddedTabHost.isWarm(nostrAppId(coordinate))) {
NappletLaunchRegistry.unregister(params.getString(NappletHostContract.EXTRA_LAUNCH_TOKEN))
}
return EmbeddedTabHost.acquire(nostrAppId(coordinate)) {
EmbeddedNostrAppController(context.applicationContext, params).also { it.bind() }
} as EmbeddedNostrAppController
@@ -21,12 +21,19 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
import android.os.Build
import android.os.Handler
import android.os.Looper
import androidx.annotation.RequiresApi
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Rect
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.EmbeddedWebAppController
/**
* Process-level holder of **warm embedded sessions** — the persistent-surface-layer half of keep-warm.
@@ -37,7 +44,7 @@ import androidx.compose.ui.geometry.Rect
*
* Warm-keep is scoped to **bottom-row apps**: a session is retained only while its app is a bottom-bar
* favorite (see [retainOnly], driven by the bottom-bar settings). A favorite opened outside the bottom
* row restarts when it leaves, and a low-memory trim ([evictAll]) drops everything.
* row restarts when it leaves, and a low-memory trim ([rebuildAll]) drops everything.
*
* State is Compose snapshot state so [EmbeddedTabLayer] recomposes as sessions / the active id / the
* content bounds change. Main-thread only.
@@ -109,13 +116,72 @@ object EmbeddedTabHost {
): EmbeddedSurfaceController {
warm.firstOrNull { it.id == id }?.let { return it.controller }
val controller = factory()
// A session built while the app is in the background (a rebuild, a preload) starts in that state.
if (!appVisible) controller.onAppVisibility(false)
if (backgroundIdle) controller.onBackgroundIdle(true)
warm.add(Warm(id, controller))
return controller
}
// ---- the app in the background ----
/** How long the app sits in the background before even the visible tab's page is paused (see there). */
const val BACKGROUND_PAUSE_MS = NappletHostContract.BACKGROUND_PAUSE_MS
private var appVisible = true
private var backgroundIdle = false
private val backgroundTimer = Handler(Looper.getMainLooper())
private val goIdle =
Runnable {
backgroundIdle = true
warm.forEach { it.controller.onBackgroundIdle(true) }
}
/** The app's UI stopped (went to the background). */
fun onAppStopped() {
if (!appVisible) return
appVisible = false
warm.forEach { it.controller.onAppVisibility(false) }
backgroundTimer.postDelayed(goIdle, BACKGROUND_PAUSE_MS)
}
/** The app's UI started again. */
fun onAppStarted() {
backgroundTimer.removeCallbacks(goIdle)
if (appVisible) return
appVisible = true
warm.forEach { it.controller.onAppVisibility(true) }
if (backgroundIdle) {
backgroundIdle = false
warm.forEach { it.controller.onBackgroundIdle(false) }
}
}
/** True if a warm session already exists for [id] (used by the preloader to skip re-acquiring). */
fun isWarm(id: String): Boolean = warm.any { it.id == id }
/** True if [controller] is still the warm session for [id] (not torn down or replaced by a rebuild). */
fun isWarm(
id: String,
controller: Any?,
): Boolean = warm.any { it.id == id && it.controller === controller }
/**
* The theme (dark or not) the warm sessions were built in. Kept here, next to the sessions, rather than in
* the watcher's `remember`: an Activity recreated while the process lives re-seeds a remembered value to
* the CURRENT theme, so a system dark-mode flip that happened meanwhile was never noticed and the warm
* pages stayed in the old theme.
*/
private var builtDark: Boolean? = null
/** Rebuilds every warm session when the resolved theme differs from the one they were built in. */
fun rebuildIfThemeChanged(dark: Boolean) {
val previous = builtDark
builtDark = dark
// The first report only records what the sessions (built from the same preference) already use.
if (previous != null && previous != dark) rebuildAll(keepPages = true)
}
/**
* Seeds [contentBounds] with an approximate full-content rect when no tab has reported real bounds
* yet, so surfaces preloaded before the user visits any tab lay out at a realistic viewport (and so
@@ -167,6 +233,7 @@ object EmbeddedTabHost {
fun takeKeyboardRestore(id: String): Boolean = keyboardUpOnLeave.remove(id)
fun evict(id: String) {
parked.remove(id)
val w = warm.firstOrNull { it.id == id } ?: return
if (activeId == id) activeId = null
keyboardUpOnLeave.remove(id)
@@ -174,36 +241,123 @@ object EmbeddedTabHost {
w.controller.teardown()
}
/**
* Tears down [id]'s warm session so its screen re-acquires a freshly built one (e.g. an nSite switched
* between Tor and the open web). Unlike [evict] this keeps [activeId]: the screen stays composed and
* never re-runs its `setActive`, so clearing it would park the new session off-screen — a blank tab.
*/
fun rebuild(id: String) {
val w = warm.firstOrNull { it.id == id } ?: return
keyboardUpOnLeave.remove(id)
warm.remove(w)
w.controller.teardown()
}
// How many composed screens currently show each id. Re-navigating to the same route composes the new
// screen (which acquires the SAME warm controller) before the old one disposes; counting lets the old
// one's disposal see that the tab is still in use instead of evicting the controller under the new one.
private val holders = mutableMapOf<String, Int>()
/** A screen showing [id] entered composition. Pair with [release]. */
fun hold(id: String) {
holders[id] = (holders[id] ?: 0) + 1
}
// Non-bar tabs whose screen left composition while their back-stack entry lives on — another screen
// was pushed on top (even the tab's own Site settings). They stay warm until EVERY such entry is
// destroyed: the same tab can sit in the back stack twice (opened again from inside itself), and popping
// the top one must not take the session from under the one still waiting below.
private val parked = mutableMapOf<String, MutableSet<Lifecycle>>()
private fun isParked(id: String) = !parked[id].isNullOrEmpty()
/**
* The last screen showing [id] left composition. A bottom-bar tab ([keepWarm]) stays warm. Any other tab
* goes once its back-stack entry ([entry]'s lifecycle) is destroyed — right away when it already is (the
* user left it), or later when merely covered by a pushed screen, so coming back doesn't restart the
* page.
*/
fun releaseWhenGone(
id: String,
entry: Lifecycle,
keepWarm: () -> Boolean,
) {
if (keepWarm()) return
fun gone() {
parked[id]?.let {
it.remove(entry)
if (it.isEmpty()) parked.remove(id)
}
// A screen may have come back to this tab meanwhile, another entry may still hold it, or it may
// have joined the bottom bar.
if ((holders[id] ?: 0) == 0 && !isParked(id) && !keepWarm()) evict(id)
}
if (entry.currentState == Lifecycle.State.DESTROYED) {
gone()
return
}
// Already watched from an earlier time this entry was covered.
if (!parked.getOrPut(id) { mutableSetOf() }.add(entry)) return
entry.addObserver(
object : LifecycleEventObserver {
override fun onStateChanged(
source: LifecycleOwner,
event: Lifecycle.Event,
) {
if (event != Lifecycle.Event.ON_DESTROY) return
entry.removeObserver(this)
gone()
}
},
)
}
/** A screen showing [id] left composition. Returns true when no other screen still shows it. */
fun release(id: String): Boolean {
val left = (holders[id] ?: 1) - 1
if (left <= 0) holders.remove(id) else holders[id] = left
return left <= 0
}
/** Drops every warm session whose id isn't in [keep] (bottom-row membership + the active tab). */
fun retainOnly(keep: Set<String>) {
warm
.filter { it.id !in keep }
.filter { it.id !in keep && !isParked(it.id) }
.forEach { evict(it.id) }
}
fun evictAll() {
activeId = null
keyboardUpOnLeave.clear()
val copy = warm.toList()
warm.clear()
copy.forEach { it.controller.teardown() }
}
/**
* Something a WebView can only pick up at construction changed (the theme, or the account): tear down
* every warm session and bump [rebuildEpoch] so the visible screen and the preloader re-acquire freshly
* built sessions. Unlike [evictAll] this keeps [activeId], so the visible tab re-activates the instant
* its screen re-acquires — the user just sees the current tab reload, not a blanked-out surface.
* Something a WebView can only pick up at construction changed (the theme, or the account), or the
* system asked for memory back: tear down every warm session and bump [rebuildEpoch] so the visible
* screen and the preloader re-acquire freshly built sessions. This keeps [activeId], so the visible tab
* re-activates the instant its screen re-acquires — the user just sees the current tab reload, not a
* blanked-out surface.
*
* [keepPages]: each browser tab's rebuilt controller resumes the page it showed, with the user's Tor, zoom
* and desktop choices ([EmbeddedWebAppController.PageSnapshot]). Android sends the memory trim routinely,
* about a minute into the background, so without this every pinned site came back on its start URL. Never
* across an account switch: the next account must not open the previous one's pages.
*/
fun rebuildAll() {
fun rebuildAll(keepPages: Boolean) {
// Every page is about to be rebuilt from scratch, so no field survives to restore a keyboard onto.
keyboardUpOnLeave.clear()
val copy = warm.toList()
warm.clear()
pageSnapshots.clear()
if (keepPages) {
copy.forEach { w -> (w.controller as? EmbeddedWebAppController)?.let { pageSnapshots[w.id] = it.snapshot() } }
}
copy.forEach { it.controller.teardown() }
rebuildEpoch += 1
}
// Page state carried from a torn-down browser tab to its rebuilt controller (see [rebuildAll]).
private val pageSnapshots = mutableMapOf<String, EmbeddedWebAppController.PageSnapshot>()
/** The page state [rebuildAll] saved for tab [id], handed over once. */
fun takePageSnapshot(id: String): EmbeddedWebAppController.PageSnapshot? = pageSnapshots.remove(id)
/**
* Account the warm sessions were built for, as the opaque WebView storage-profile name (null while
* logged out). Kept HERE, next to the sessions it describes, rather than in a composable's `remember`:
@@ -234,6 +388,6 @@ object EmbeddedTabHost {
builtForProfile = profileName
// Seeding on the first call (app start) must not bump the epoch: nothing is stale yet, and a
// needless bump would restart the preload sweep that is just getting going.
if (!isFirstCall) rebuildAll()
if (!isFirstCall) rebuildAll(keepPages = false)
}
}
@@ -56,13 +56,16 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.IntState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
@@ -81,17 +84,24 @@ import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
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.commons.browser.BrowserChrome
import com.vitorpamplona.amethyst.commons.browser.ui.EmbeddedLoadOverlay
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPill
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleSheet
import com.vitorpamplona.amethyst.commons.browser.ui.pill.FindInPagePill
import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler
import com.vitorpamplona.amethyst.napplethost.BrowserWebTools
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlin.math.roundToInt
@@ -146,6 +156,22 @@ private fun EmbeddedImeBridge.sendFieldOp(
fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
val activeId = EmbeddedTabHost.activeId
// Tell the warm tabs when the app leaves the screen and when it comes back. The host stops them acting for
// the user right away and pauses their pages on the same schedule the relays wind down on.
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer =
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_STOP -> EmbeddedTabHost.onAppStopped()
Lifecycle.Event.ON_START -> EmbeddedTabHost.onAppStarted()
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
// Keep only bottom-row apps warm (plus the active tab, even mid-removal). A favorite removed from
// the bar drops its warm session here.
LaunchedEffect(barFavoriteIds, activeId) {
@@ -157,13 +183,10 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
var layerSize by remember { mutableStateOf(IntSize.Zero) }
val density = LocalDensity.current
// While the soft keyboard is up (hosted by [RemoteImeView] in this window), shrink the active
// surface so its bottom clears the keyboard — the embedded WebView then reflows and scrolls the
// focused field into view. Only the portion of the keyboard that overlaps the surface counts.
// Use the *snapped* animation target rather than the animated `ime` inset: the cross-process surface
// resize is expensive (a SurfaceControlViewHost reconfigure each frame), so we resize once to the
// final height instead of on every frame of the keyboard slide-in/out.
val imeBottomPx = WindowInsets.imeAnimationTarget.getBottom(density)
// The keyboard's *snapped* target inset (not the animated one). Only the insets object is taken here;
// its value is read inside the effect below, so a keyboard showing or hiding doesn't recompose this whole
// layer (every surface, the pill, the selection overlay).
val imeTarget = WindowInsets.imeAnimationTarget
Box(
Modifier
@@ -195,8 +218,6 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
// SurfaceControlViewHost surface, and the first frame presented after that reconfigure
// stalls ~1s (the per-focus "freeze"). Keep the surface full-size and let the page bring
// the focused field above the keyboard via the shim's scrollIntoView on focus.
@Suppress("UNUSED_EXPRESSION")
imeBottomPx
Modifier
.absoluteOffset(left, (bounds.top - layerOrigin.y).toDp())
.size(bounds.width.toDp(), bounds.height.toDp())
@@ -219,6 +240,9 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
}
},
modifier = placement,
onRelease = { holder ->
(holder.getChildAt(0) as? SandboxedSdkView)?.let { session.controller.detachView(it) }
},
)
}
}
@@ -244,11 +268,13 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
activeController?.onLoadStatusChanged = { loadStatus = it }
onDispose { activeController?.onLoadStatusChanged = null }
}
// Safety net: nothing painted and nothing actively loading after a grace period → offer a retry.
// Safety net: nothing painted after a grace period → offer a retry. A load still in flight (a slow
// site, one over Tor) gets a longer budget before it's called stuck, instead of flipping to an error
// while it's still progressing.
LaunchedEffect(activeId, loadStatus) {
timedOut = false
if (!loadStatus.hasLoadedReal && !loadStatus.failed) {
delay(12_000)
delay(if (loadStatus.isLoading) 45_000 else 12_000)
timedOut = true
}
}
@@ -296,6 +322,12 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
findQuery = ""
}
// Switching tabs drops the find bar (its state is per tab), but the page it searched keeps its
// highlights until told otherwise — clear them on the tab being left.
DisposableEffect(findBridge) {
onDispose { if (findShowing) findBridge?.find("") }
}
val tabModifier =
with(density) {
Modifier
@@ -318,46 +350,48 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
}
val consoleLogs = consoleBridge?.consoleLogs
val ui =
val baseUi =
chrome.ui.copy(
chrome = chrome.ui.chrome.copy(hasFind = chrome.ui.chrome.hasFind && findBridge != null),
consoleShowing = consoleShowing,
consoleErrors = consoleLogs?.count { it.level == ConsoleLine.Level.ERROR } ?: 0,
)
Box(tabModifier) {
BrowserPill(
ui = ui,
expanded = pillExpanded,
onExpandedChange = { pillExpanded = it },
onEvent = { event ->
val action = (event as? BrowserPillEvent.Action)?.action
when {
action == BrowserChrome.Action.FIND_IN_PAGE && findBridge != null -> {
// One bottom panel at a time: find replaces the console.
consoleShowing = false
findShowing = true
WithConsoleErrors(baseUi, consoleBridge?.consoleErrorCount) { ui ->
BrowserPill(
ui = ui,
expanded = pillExpanded,
onExpandedChange = { pillExpanded = it },
onEvent = { event ->
val action = (event as? BrowserPillEvent.Action)?.action
when {
action == BrowserChrome.Action.FIND_IN_PAGE && findBridge != null -> {
// One bottom panel at a time: find replaces the console.
consoleShowing = false
findShowing = true
}
action == BrowserChrome.Action.CONSOLE && consoleBridge != null -> {
if (!consoleShowing) closeFind()
consoleShowing = !consoleShowing
}
else -> chrome.onEvent(event)
}
action == BrowserChrome.Action.CONSOLE && consoleBridge != null -> {
if (!consoleShowing) closeFind()
consoleShowing = !consoleShowing
}
else -> chrome.onEvent(event)
}
},
showClose = false,
suggestionsFor = chrome.suggestionsFor,
onPasteAndGo =
if (BrowserWebTools.clipboardHasText(context)) {
{
pillExpanded = false
BrowserWebTools.clipboardText(context)?.let { chrome.onEvent(BrowserPillEvent.Navigate(it)) }
}
} else {
null
},
modifier = Modifier.align(Alignment.TopCenter),
)
showClose = false,
suggestionsFor = chrome.suggestionsFor,
// A clipboard query is a binder call: only make it while the pill is open to use it.
onPasteAndGo =
if (pillExpanded && BrowserWebTools.clipboardHasText(context)) {
{
pillExpanded = false
BrowserWebTools.clipboardText(context)?.let { chrome.onEvent(BrowserPillEvent.Navigate(it)) }
}
} else {
null
},
modifier = Modifier.align(Alignment.TopCenter),
)
}
// Find in page: opened from the pill's Find tile.
if (findShowing && findBridge != null) {
@@ -407,8 +441,10 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
// doesn't pop the keyboard back over the page. A tab switch also collapses the insets but does NOT
// look like this: measured on device, the switch takes focus off the view in the same frame, so
// isMirroringPageField() is already false there and the mark this tab was owed survives.
LaunchedEffect(activeId, imeBottomPx) {
if (imeBottomPx == 0 && imeView.isMirroringPageField()) imeView.noteKeyboardDismissed()
LaunchedEffect(activeId) {
snapshotFlow { imeTarget.getBottom(density) }.collect { imeBottomPx ->
if (imeBottomPx == 0 && imeView.isMirroringPageField()) imeView.noteKeyboardDismissed()
}
}
DisposableEffect(imeBridge) {
val boundId = activeId
@@ -489,7 +525,9 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
// mid-typing — which is exactly the case this restore exists for. [wantsKeyboardForPageField]
// also answers the other half: only a keyboard THIS mirror holds counts, so typing in the
// browser's own address bar never arms a restore for a page field.
if (boundId != null) {
// Only for the session that is still warm under this id: after a rebuild (theme, account) or an
// eviction this disposal runs late, and a mark recorded now would be restored onto a fresh page.
if (boundId != null && EmbeddedTabHost.isWarm(boundId, imeBridge)) {
EmbeddedTabHost.noteKeyboardOnLeave(boundId, imeView.wantsKeyboardForPageField())
}
imeView.onPageBlur()
@@ -532,11 +570,19 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
// Source rect (surface px) = bubble px / zoom, so the provider-scaled frame lands ≈ bubble-sized.
val magSrcW = with(density) { (magBubble.width.toPx() / magZoom).roundToInt() }
val magSrcH = with(density) { (magBubble.height.toPx() / magZoom).roundToInt() }
val magScope = rememberCoroutineScope()
DisposableEffect(magProbe) {
magProbe?.onMagnifierFrame = { frame ->
if (magnifier.visible) {
magnifier.awaitingFrame = false
BitmapFactory.decodeByteArray(frame.bytes, 0, frame.bytes.size)?.let { magnifier.image = it.asImageBitmap() }
// Decode off the main thread: this runs for every frame of a handle drag.
magScope.launch {
val image = withContext(Dispatchers.Default) { BitmapFactory.decodeByteArray(frame.bytes, 0, frame.bytes.size)?.asImageBitmap() }
if (image != null && magnifier.visible && frame.requestStampNanos > magnifier.shownFrameStamp) {
magnifier.shownFrameStamp = frame.requestStampNanos
magnifier.image = image
}
}
}
}
onDispose {
@@ -783,8 +829,17 @@ private fun SelectionHandle(
val color = MaterialTheme.colorScheme.primary
val currentTip by rememberUpdatedState(tipPx)
val currentMagnify by rememberUpdatedState(onMagnify)
// The drag gesture is installed once (pointerInput(Unit)) and outlives recompositions, so everything it
// reads that can change mid-life (a rotation or resize moves the origin, the page's zoom changes the
// scale, the drag target captures the current bridge) is read through updated state.
val currentOriginX by rememberUpdatedState(surfaceOriginX)
val currentOriginY by rememberUpdatedState(surfaceOriginY)
val currentScale by rememberUpdatedState(scale)
val currentLineHalf by rememberUpdatedState(lineHalfPx)
val currentDragTo by rememberUpdatedState(onDragTo)
var dragTip by remember { mutableStateOf<Offset?>(null) }
val tip = dragTip ?: tipPx
EndDragOnDispose(isDragging = { dragTip != null }, onMagnify = { currentMagnify })
// Loupe capture: X follows the finger, Y is locked to the authoritative endpoint's line ([currentTip] is
// the foot, so lift by half the line height) — so the bubble shows the line being edited, not wherever the
@@ -792,7 +847,7 @@ private fun SelectionHandle(
fun magnify(
fingerPx: Offset,
active: Boolean = true,
) = currentMagnify?.invoke(active, fingerPx, fingerPx.x - surfaceOriginX, currentTip.y - surfaceOriginY - lineHalfPx)
) = currentMagnify?.invoke(active, fingerPx, fingerPx.x - currentOriginX, currentTip.y - currentOriginY - currentLineHalf)
// Place the box so its pointed corner lands on the tip: start = top-right corner, end = top-left corner.
val boxLeft = if (isStart) tip.x - sizePx else tip.x
Box(
@@ -809,7 +864,7 @@ private fun SelectionHandle(
change.consume()
val np = (dragTip ?: currentTip) + delta
dragTip = np
onDragTo((np.x - surfaceOriginX) / scale, (np.y - surfaceOriginY) / scale)
currentDragTo((np.x - currentOriginX) / currentScale, (np.y - currentOriginY) / currentScale)
magnify(np)
},
onDragEnd = {
@@ -870,6 +925,12 @@ private fun InsertionHandle(
val currentTip by rememberUpdatedState(tipPx)
val currentMagnify by rememberUpdatedState(onMagnify)
val currentTap by rememberUpdatedState(onTap)
// Read through updated state for the same reason as SelectionHandle: the gesture outlives recompositions.
val currentOriginX by rememberUpdatedState(surfaceOriginX)
val currentOriginY by rememberUpdatedState(surfaceOriginY)
val currentScale by rememberUpdatedState(scale)
val currentLineHalf by rememberUpdatedState(lineHalfPx)
val currentDragTo by rememberUpdatedState(onDragTo)
// Loupe capture: X follows the finger, Y is locked to the authoritative caret's line ([currentTip] is the
// caret foot, so lift by half the line height) — so the bubble shows the edited line, not wherever the
@@ -877,11 +938,12 @@ private fun InsertionHandle(
fun magnify(
fingerPx: Offset,
active: Boolean = true,
) = currentMagnify?.invoke(active, fingerPx, fingerPx.x - surfaceOriginX, currentTip.y - surfaceOriginY - lineHalfPx)
) = currentMagnify?.invoke(active, fingerPx, fingerPx.x - currentOriginX, currentTip.y - currentOriginY - currentLineHalf)
// Track the finger separately from what we draw: the handle renders at the AUTHORITATIVE caret (tipPx,
// which snaps to a character position as the move round-trips through the shim), while the finger drives
// the move. So the handle stays glued to the line/text and clamps to the field instead of trailing off.
var fingerPx by remember { mutableStateOf<Offset?>(null) }
EndDragOnDispose(isDragging = { fingerPx != null }, onMagnify = { currentMagnify })
Box(
Modifier
.absoluteOffset { IntOffset((tipPx.x - wPx / 2f).roundToInt(), tipPx.y.roundToInt()) }
@@ -909,6 +971,7 @@ private fun InsertionHandle(
if (!dragging && (change.position - down.position).getDistance() > viewConfiguration.touchSlop) {
dragging = true
fp = currentTip
fingerPx = fp
magnify(currentTip)
}
if (dragging) {
@@ -919,7 +982,7 @@ private fun InsertionHandle(
fp += change.positionChangeIgnoreConsumed()
change.consume()
fingerPx = fp
onDragTo((fp.x - surfaceOriginX) / scale, (fp.y - surfaceOriginY) / scale)
currentDragTo((fp.x - currentOriginX) / currentScale, (fp.y - currentOriginY) / currentScale)
magnify(fp)
}
}
@@ -999,6 +1062,9 @@ private fun SelectionToolbarItem(
label: String,
onClick: () -> Unit,
) {
// The gesture is keyed on the label only, so read the action through updated state: "Copy" captures the
// selected text, and a stale first lambda copied the previous selection.
val currentOnClick by rememberUpdatedState(onClick)
Text(
text = label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -1012,13 +1078,31 @@ private fun SelectionToolbarItem(
val up = waitForUpOrCancellation()
if (up != null) {
up.consume()
onClick()
currentOnClick()
}
}
}.padding(horizontal = 12.dp, vertical = 10.dp),
)
}
/**
* Ends a handle drag that is still in progress when the handle leaves composition — the selection collapsed
* or the page blurred under the finger. The gesture's own end/cancel callbacks never run then (its coroutine
* is just cancelled), which left the loupe on screen, the toolbar hidden and the drawer's edge swipe disabled
* app-wide ([EmbeddedSelectionDrag]).
*/
@Composable
private fun EndDragOnDispose(
isDragging: () -> Boolean,
onMagnify: () -> OnMagnify?,
) {
DisposableEffect(Unit) {
onDispose {
if (isDragging()) onMagnify()?.invoke(false, Offset.Zero, 0f, 0f)
}
}
}
/** One console line as plain text, for copying. */
private fun formatConsoleLine(line: ConsoleLine): String =
buildString {
@@ -1031,3 +1115,16 @@ private fun formatConsoleLine(line: ConsoleLine): String =
.append(')')
}
}
/**
* Reads the page's console error count in a scope of its own: a page that keeps logging errors then
* recomposes just the pill, not the whole tab layer around it.
*/
@Composable
private fun WithConsoleErrors(
ui: BrowserPillUi,
errors: IntState?,
content: @Composable (BrowserPillUi) -> Unit,
) {
content(ui.copy(consoleErrors = errors?.intValue ?: 0))
}
@@ -118,7 +118,13 @@ object EmbeddedTabPreloadSweeper {
.associateBy { it.id }
var stillPending = false
for (id in favoriteIds) {
val app = byId[id] ?: continue
// Not loaded from disk yet (the favorites store hydrates asynchronously): retry, rather than
// letting the sweep end having warmed nothing.
val app = byId[id]
if (app == null) {
stillPending = true
continue
}
if (!EmbeddedTabFactory.preload(context, app, backgroundColor)) stillPending = true
// Each preload may build + attach a WebView on this (main) thread; yield between favorites
// so the sweep doesn't monopolize the frame and jank the paint that follows.
@@ -26,8 +26,6 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.ThemeType
@@ -55,13 +53,8 @@ fun EmbeddedTabThemeWatcher() {
ThemeType.SYSTEM -> systemDark
}
// Holds the theme the warm surfaces were last built in; a mismatch (only after a real flip — the
// first composition seeds it equal) triggers exactly one rebuild.
val applied = remember { mutableStateOf(resolvedDark) }
// The host remembers the theme the warm surfaces were built in; a real flip triggers exactly one rebuild.
LaunchedEffect(resolvedDark) {
if (applied.value != resolvedDark) {
applied.value = resolvedDark
EmbeddedTabHost.rebuildAll()
}
EmbeddedTabHost.rebuildIfThemeChanged(resolvedDark)
}
}
@@ -34,29 +34,45 @@ import android.os.Looper
import android.os.Message
import android.os.Messenger
import android.os.SystemClock
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
import androidx.privacysandbox.ui.client.view.SandboxedSdkViewEventListener
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.favorite_notice_paid
import com.vitorpamplona.amethyst.commons.resources.favorite_notice_published
import com.vitorpamplona.amethyst.commons.resources.favorite_notice_uploaded
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.napplet.NappletLaunchRegistry
import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles
import com.vitorpamplona.amethyst.napplet.WebFileChooserCoordinator
import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleBridge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleBuffer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedAutoRecovery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedLoadStatus
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedMagnifierProbe
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSurfaceController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabFactory
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FindBridge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FindResult
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.MagnifierFrame
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.consoleLevelOf
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseImeEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.UUID
import java.util.concurrent.atomic.AtomicLong
/**
@@ -96,21 +112,50 @@ class EmbeddedNostrAppController(
// its own id on every message; the provider uses it to route controls/state/IME to this tab.
// Re-minted whenever the remote session is re-created (see [attachView]), so a late close() from the
// previous view can never reap the replacement.
private var sessionId: String = "napplet-${SESSION_SEQ.incrementAndGet()}"
private var sessionId: String = newSessionId()
// A parked tab can be hidden (paused) before the service even binds, so the pause message is
// dropped (no messenger yet). Remember the intent and replay it right after the session is created,
// otherwise an applet that was never shown comes up running in the background.
// What the provider was last told (see [syncPageState]). A parked tab can be hidden before the service
// even binds, when the message is dropped (no messenger yet), so both are replayed right after each
// session is created — otherwise an applet that was never shown comes up running, and acting, unwatched.
private var wantPaused = false
private var wantAttended = false
// The app is on screen / has been in the background long enough to pause even the visible tab.
private var appVisible = true
private var backgroundIdle = false
/** (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 var hasLoadedReal = false
// Brings the tab back when its sandbox-side surface dies (see [onSurfaceLost]).
private val recovery = EmbeddedAutoRecovery(SystemClock::elapsedRealtime)
// The remote session behind the current view errored out: only a brand-new session can repaint it.
private var sessionDead = false
// Set after the first connection, so a later onServiceConnected is recognised as `:napplet` coming back.
private var everConnected = false
// Set by [unbind]: nothing that arrives afterwards may act.
private var tornDown = false
// A `:napplet` restart found this tab hidden: its session is re-created when it is next shown.
private var createOnShow = false
// A create is in flight: the view's old session erroring out now is the one being replaced, not news.
private var awaitingReady = false
// The current session's surface has shown in the view at least once (see [retry]).
private var uiDisplayed = false
// Whether `:napplet` routes through Tor right now: another surface that needs Tor puts every page on it.
private val routedOverTor = mutableStateOf(false)
/** This nSite is set to the open web but goes through Tor anyway, because another open page needs Tor. */
val isTorForced: Boolean get() = !params.getBoolean(NappletHostContract.EXTRA_USE_TOR, true) && routedOverTor.value
/** Last known main-frame load state, so the tab layer renders the right overlay immediately. */
override var loadStatus: EmbeddedLoadStatus = EmbeddedLoadStatus()
private set
@@ -123,9 +168,23 @@ class EmbeddedNostrAppController(
override var onMagnifierFrame: ((MagnifierFrame) -> Unit)? = null
/** The app's console output, capped at [MAX_CONSOLE_LOGS] entries. */
override val consoleLogs = mutableStateListOf<ConsoleLine>()
private val console = ConsoleBuffer(MAX_CONSOLE_LOGS)
override val consoleLogs get() = console.lines
override val consoleErrorCount get() = console.errorCount
override fun clearConsoleLogs() = consoleLogs.clear()
override fun clearConsoleLogs() = console.clear()
// The user's text zoom. The provider forgets it whenever the session is re-created (a `:napplet`
// restart, a rearm), so it is re-sent with every create.
private var textZoom = BrowserChrome.DEFAULT_TEXT_ZOOM
/** The user's text zoom as last set, for a screen coming back to this tab. */
val currentTextZoom: Int get() = textZoom
// Whether the app can go back, kept here rather than in the tab's screen (which leaves composition on
// every bottom-bar switch), so coming back keeps Back working inside the app.
var lastCanGoBack = false
private set
private val _findResult = mutableStateOf<FindResult?>(null)
override val findResult: State<FindResult?> = _findResult
@@ -137,11 +196,28 @@ class EmbeddedNostrAppController(
service: IBinder?,
) {
serviceMessenger = Messenger(service)
if (everConnected) {
// `:napplet` died and was restarted. Re-creating the session IS the recovery (a fresh
// process has no session under any id), so nothing else is left pending; cover the
// surface until the new page paints. Only the visible tab rebuilds now: every warm tab
// reconnects at once, and rebuilding them all right after the OS reclaimed that memory
// would just push it back up. The rest re-create when next shown.
recovery.clearPending()
sessionDead = false
showRecovering()
everConnected = true
if (recovery.isShown) sendCreateSession() else createOnShow = true
return
}
everConnected = true
sendCreateSession()
}
override fun onServiceDisconnected(name: ComponentName?) {
// `:napplet` died (the OS reclaimed it, or it crashed). Its WebViews went with it; the
// system restarts the bound service and [onServiceConnected] re-creates the session.
serviceMessenger = null
showRecovering()
}
}
@@ -151,17 +227,24 @@ class EmbeddedNostrAppController(
}
fun unbind() {
// Tell the provider to drop this tab's session now: one created for a view that was disposed before
// it attached never gets the surface close that would otherwise clean it up.
send(NappletEmbedContract.MSG_CLOSE_SESSION)
tornDown = true
if (bound) {
runCatching { appContext.unbindService(connection) }
bound = false
}
// This controller's launch token dies with it (every session it re-creates reuses the token, so it
// can't be given back any earlier). Left registered, dead tokens crowd live ones out of the registry.
NappletLaunchRegistry.unregister(params.getString(NappletHostContract.EXTRA_LAUNCH_TOKEN))
// Drop refs so an evicted controller doesn't pin the surface view or the remote messenger.
serviceMessenger = null
sandboxedSdkView?.setEventListener(null)
sandboxedSdkView = null
pendingAdapter = null
adapterDelivered = false
onStateChanged = null
onNotice = null
onImeEvent = null
onMagnifierFrame = null
onLoadStatusChanged = null
@@ -187,6 +270,7 @@ class EmbeddedNostrAppController(
// Paint the surface placeholder in the app's theme background so there's no white flash before
// the remote WebView delivers its first frame.
view.setBackgroundColor(params.getInt(NappletHostContract.EXTRA_BG_COLOR, android.graphics.Color.WHITE))
view.setEventListener(surfaceListener(view))
val adapter = pendingAdapter
when {
adapter != null -> {
@@ -196,25 +280,147 @@ class EmbeddedNostrAppController(
}
// No adapter in hand and one was already spent on a previous (now disposed) view: the session
// behind it is gone, so this view would stay blank forever. Re-create it.
adapterDelivered -> {
// Mint a FRESH session id: the disposed view's Session.close() reaches the sandbox
// asynchronously and can land AFTER this create. Reusing the id would let that late close
// reap the session we just asked for, leaving the surface black.
sessionId = "napplet-${SESSION_SEQ.incrementAndGet()}"
adapterDelivered = false
sendCreateSession()
}
adapterDelivered -> rearmSession()
// else: the first session is still in flight; MSG_SESSION_READY will arm this view.
}
}
override fun onShown() = resume()
override fun detachView(view: SandboxedSdkView) {
if (sandboxedSdkView !== view) return
view.setEventListener(null)
sandboxedSdkView = null
}
override fun onHidden() = pause()
/**
* Asks the sandbox for a brand-new session; the [NappletEmbedContract.MSG_SESSION_READY] reply arms the
* current view with its adapter.
*
* Mints a FRESH session id: a disposed view's Session.close() reaches the sandbox asynchronously and can
* land AFTER the create. Reusing the id would let that late close reap the session we just asked for,
* leaving the surface black.
*/
private fun rearmSession() {
// The session being replaced may never have opened a surface (its view went away first), in which
// case no surface close will ever reach the provider for it.
send(NappletEmbedContract.MSG_CLOSE_SESSION)
sessionId = newSessionId()
// This create IS the re-creation a `:napplet` restart deferred to the next show.
createOnShow = false
adapterDelivered = false
sessionDead = false
_findResult.value = null
sendCreateSession()
}
/**
* Watches [view]'s remote session. A session that errors out (its provider failed, or `:napplet` died)
* leaves the view holding a dead client that never reopens: it paints nothing, forever, until it is
* handed a NEW adapter.
*/
private fun surfaceListener(view: SandboxedSdkView) =
object : SandboxedSdkViewEventListener {
override fun onUiDisplayed() {
// The load state reports when the page itself paints; this only says the surface opened.
if (sandboxedSdkView === view) uiDisplayed = true
}
override fun onUiError(error: Throwable) {
// A view this controller has since moved past (disposed, replaced) is not ours to revive, and an
// error landing while a new session is on its way is the old one dying: that create already
// is the rebuild.
if (sandboxedSdkView === view && !awaitingReady) onSurfaceLost(sessionDead = true)
}
override fun onUiClosed() {
// Nothing to do: closes are ours (a view disposed, or an adapter replaced on purpose).
}
}
/**
* The tab's page is gone: its WebView's renderer died ([sessionDead] false — the session lives on, and a
* MSG_RELOAD rebuilds the WebView inside it), or the whole remote session errored out ([sessionDead]
* true — only a new session can repaint the view). Either way the surface is a black rectangle that would
* stay that way, so rebuild it, as [EmbeddedAutoRecovery] allows.
*/
private fun onSurfaceLost(sessionDead: Boolean) {
if (sessionDead) this.sessionDead = true
hasLoadedReal = false
// `:napplet` itself is down: its restart re-creates the session (see [onServiceConnected]).
if (serviceMessenger?.binder?.isBinderAlive != true) {
showRecovering()
return
}
when (recovery.onLost()) {
EmbeddedAutoRecovery.Decision.RECOVER_NOW -> recover()
EmbeddedAutoRecovery.Decision.DEFERRED -> showRecovering()
EmbeddedAutoRecovery.Decision.GIVE_UP -> publishLoadStatus(EmbeddedLoadStatus(failed = true))
}
}
private fun recover() {
showRecovering()
if (sessionDead) rearmSession() else reload()
}
/** Covers the surface with the loading spinner until the rebuilt page paints. */
private fun showRecovering() {
hasLoadedReal = false
publishLoadStatus(EmbeddedLoadStatus(isLoading = true))
}
override fun onShown() {
val deferredRecovery = recovery.onShown()
syncPageState()
if (createOnShow) {
createOnShow = false
sendCreateSession()
} else if (deferredRecovery) {
recover()
}
}
override fun onHidden() {
recovery.onHidden()
syncPageState()
}
override fun onAppVisibility(visible: Boolean) {
appVisible = visible
syncPageState()
}
override fun onBackgroundIdle(idle: Boolean) {
backgroundIdle = idle
syncPageState()
}
/**
* Tells the provider what the applet may do now — the same schedule as a website tab:
* - paused (JS-driven animations, media, geolocation) while parked off-screen, or once the app has sat in
* the background as long as the relays get (EmbeddedTabHost.BACKGROUND_PAUSE_MS), so a quick trip to
* another app doesn't interrupt it;
* - attended only while it's the visible tab AND the app is on screen. The provider holds its requests
* that act for the user (publish, pay, upload…) while it isn't — at once, not after the grace: even an
* "allow always" napplet can't act on the user's behalf while they aren't looking.
*/
private fun syncPageState() {
val pause = !recovery.isShown || backgroundIdle
if (pause != wantPaused) {
wantPaused = pause
send(if (pause) NappletEmbedContract.MSG_PAUSE else NappletEmbedContract.MSG_RESUME)
}
val attended = recovery.isShown && appVisible
if (attended != wantAttended) {
wantAttended = attended
send(NappletEmbedContract.MSG_SET_ATTENDED) { putBoolean(NappletEmbedContract.KEY_ATTENDED, attended) }
}
}
override fun teardown() = unbind()
private fun sendCreateSession() {
awaitingReady = true
uiDisplayed = false
val msg =
Message.obtain(null, NappletEmbedContract.MSG_CREATE_SESSION).apply {
replyTo = incoming
@@ -226,19 +432,31 @@ class EmbeddedNostrAppController(
// [attachView]) must land in the CURRENT account's jar, never the one this
// controller was originally built for.
putString(NappletHostContract.EXTRA_WEBVIEW_PROFILE, NappletWebViewProfiles.current())
// Likewise Tor's port: it may have come up (or moved) since [params] were minted.
putInt(NappletHostContract.EXTRA_PROXY_PORT, EmbeddedTabFactory.currentTorPort())
}
}
runCatching { serviceMessenger?.send(msg) }
// Replay a pause that was requested before we had a messenger to send it on (parked-before-bound),
// so a never-shown applet doesn't start running. Messenger preserves order, so PAUSE lands after
// CREATE in the host.
// Replay a pause / the attended state decided before we had a messenger to send it on
// (parked-before-bound), so a never-shown applet doesn't start running or acting. Messenger preserves
// order, so these land after CREATE in the host.
if (wantPaused) send(NappletEmbedContract.MSG_PAUSE)
// Always: a new session starts unattended, so a tab created in view must say it is being watched.
send(NappletEmbedContract.MSG_SET_ATTENDED) { putBoolean(NappletEmbedContract.KEY_ATTENDED, wantAttended) }
if (textZoom != BrowserChrome.DEFAULT_TEXT_ZOOM) setTextZoom(textZoom)
}
private fun onServiceMessage(msg: Message): Boolean {
// Nothing may act on a torn-down tab (a late file-chooser request would still open a picker), nor on
// what a session this controller has since replaced still had in flight — a stale SESSION_READY
// would re-arm the view with that dead session's adapter.
if (tornDown) return true
val from = msg.data?.getString(NappletEmbedContract.KEY_SESSION_ID)
if (from != null && from != sessionId) return true
when (msg.what) {
NappletEmbedContract.MSG_SESSION_READY -> {
val coreLibInfo = msg.data?.getBundle(NappletEmbedContract.KEY_CORE_LIB_INFO) ?: return true
awaitingReady = false
val adapter = SandboxedUiAdapterFactory.createFromCoreLibInfo(coreLibInfo)
val view = sandboxedSdkView
if (view != null) {
@@ -250,11 +468,12 @@ class EmbeddedNostrAppController(
}
NappletEmbedContract.MSG_STATE -> {
val canGoBack = msg.data?.getBoolean(NappletEmbedContract.KEY_CAN_GO_BACK, false) ?: false
lastCanGoBack = canGoBack
onStateChanged?.invoke(canGoBack)
}
NappletEmbedContract.MSG_NOTICE -> {
val notice = msg.data?.getString(NappletEmbedContract.KEY_NOTICE) ?: return true
onNotice?.invoke(notice)
showNotice(notice)
}
NappletEmbedContract.MSG_IME_EVENT -> {
val payload = msg.data?.getString(NappletEmbedContract.KEY_IME_PAYLOAD) ?: return true
@@ -263,7 +482,11 @@ class EmbeddedNostrAppController(
NappletEmbedContract.MSG_LOAD_STATE -> {
val isLoading = msg.data?.getBoolean(NappletEmbedContract.KEY_IS_LOADING, false) ?: false
val failed = msg.data?.getBoolean(NappletEmbedContract.KEY_LOAD_FAILED, false) ?: false
onLoadState(isLoading, failed)
if (msg.data?.getBoolean(NappletEmbedContract.KEY_RENDERER_GONE, false) == true) {
onSurfaceLost(sessionDead = false)
} else {
onLoadState(isLoading, failed)
}
}
NappletEmbedContract.MSG_FILE_CHOOSER_REQUEST -> {
val data = msg.data ?: return true
@@ -284,14 +507,14 @@ class EmbeddedNostrAppController(
}
}
}
NappletEmbedContract.MSG_ROUTE -> routedOverTor.value = msg.data?.getBoolean(NappletEmbedContract.KEY_ROUTE_TOR, false) ?: false
NappletEmbedContract.MSG_FIND_RESULT -> {
val data = msg.data ?: return true
_findResult.value = FindResult(data.getInt(NappletEmbedContract.KEY_FIND_ACTIVE), data.getInt(NappletEmbedContract.KEY_FIND_TOTAL))
}
NappletEmbedContract.MSG_CONSOLE_LOG -> {
val data = msg.data ?: return true
if (consoleLogs.size >= MAX_CONSOLE_LOGS) consoleLogs.removeAt(0)
consoleLogs.add(
console.add(
ConsoleLine(
consoleLevelOf(data.getString(NappletEmbedContract.KEY_CONSOLE_LEVEL).orEmpty()),
data.getString(NappletEmbedContract.KEY_CONSOLE_MESSAGE).orEmpty(),
@@ -337,7 +560,7 @@ class EmbeddedNostrAppController(
fun back() = send(NappletEmbedContract.MSG_BACK)
fun reload() = send(NappletEmbedContract.MSG_RELOAD)
fun reload() = send(NappletEmbedContract.MSG_RELOAD) { putInt(NappletHostContract.EXTRA_PROXY_PORT, EmbeddedTabFactory.currentTorPort()) }
override fun find(query: String) {
if (query.isEmpty()) _findResult.value = null
@@ -346,13 +569,17 @@ class EmbeddedNostrAppController(
override fun findNext(forward: Boolean) = send(NappletEmbedContract.MSG_FIND_NEXT) { putBoolean(NappletEmbedContract.KEY_FIND_FORWARD, forward) }
fun setTextZoom(percent: Int) = send(NappletEmbedContract.MSG_SET_TEXT_ZOOM) { putInt(NappletEmbedContract.KEY_TEXT_ZOOM, percent) }
fun setTextZoom(percent: Int) {
textZoom = percent
send(NappletEmbedContract.MSG_SET_TEXT_ZOOM) { putInt(NappletEmbedContract.KEY_TEXT_ZOOM, percent) }
}
/** User-triggered recovery for a stuck or failed session: reload the verified content from scratch. */
override fun retry() {
hasLoadedReal = false
publishLoadStatus(EmbeddedLoadStatus(isLoading = true))
reload()
recovery.clearPending()
showRecovering()
// A surface that never opened has nothing to reload: only a new session can paint it.
if (sessionDead || (sandboxedSdkView != null && !uiDisplayed)) rearmSession() else reload()
}
private fun onLoadState(
@@ -368,15 +595,24 @@ class EmbeddedNostrAppController(
onLoadStatusChanged?.invoke(status)
}
/** Pause/resume the applet's JS when the tab leaves/returns to the foreground (background gating). */
fun pause() {
wantPaused = true
send(NappletEmbedContract.MSG_PAUSE)
}
fun resume() {
wantPaused = false
send(NappletEmbedContract.MSG_RESUME)
/**
* A granted "allow always" sensitive op just ran (one of NappletEmbedContract.NOTICE_*): tell the user.
* Shown from here, on the app's own scope, rather than by the tab's screen: the op can complete after
* the user has left the tab, when that screen — and the coroutine scope it would have toasted from — is
* already gone, and the notice was silently dropped.
*/
private fun showNotice(notice: String) {
val res =
when (notice) {
NappletEmbedContract.NOTICE_PUBLISHED -> Res.string.favorite_notice_published
NappletEmbedContract.NOTICE_UPLOADED -> Res.string.favorite_notice_uploaded
NappletEmbedContract.NOTICE_PAID -> Res.string.favorite_notice_paid
else -> return
}
Amethyst.instance.applicationIOScope.launch {
val text = loadStringRes(res)
withContext(Dispatchers.Main) { Toast.makeText(appContext, text, Toast.LENGTH_SHORT).show() }
}
}
private inline fun send(
@@ -397,6 +633,12 @@ class EmbeddedNostrAppController(
private companion object {
private val SESSION_SEQ = AtomicLong()
// The provider outlives this process's restarts (and this counter with them): without a per-process
// nonce a fresh main process would hand out ids a still-running `:napplet` already holds.
private val PROCESS_NONCE = UUID.randomUUID().toString().take(8)
private fun newSessionId() = "napplet-$PROCESS_NONCE-${SESSION_SEQ.incrementAndGet()}"
private const val MAX_CONSOLE_LOGS = 200
}
}
@@ -21,7 +21,6 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites
import android.os.Build
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@@ -40,7 +39,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -52,8 +50,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
@@ -69,11 +65,8 @@ import com.vitorpamplona.amethyst.commons.resources.browser_unsupported
import com.vitorpamplona.amethyst.commons.resources.favorite_app_still_loading
import com.vitorpamplona.amethyst.commons.resources.favorite_app_unavailable
import com.vitorpamplona.amethyst.commons.resources.favorite_apps
import com.vitorpamplona.amethyst.commons.resources.favorite_notice_paid
import com.vitorpamplona.amethyst.commons.resources.favorite_notice_published
import com.vitorpamplona.amethyst.commons.resources.favorite_notice_uploaded
import com.vitorpamplona.amethyst.commons.tor.TorType
import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -81,13 +74,10 @@ import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry
import com.vitorpamplona.amethyst.napplethost.HostProfile
import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabChrome
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabFactory
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
/**
* A **Nostr app** — an nSite or nApplet, reached by [coordinate] (favorited or not) — rendered as an
@@ -149,13 +139,12 @@ private fun EmbeddedNostrAppTab(
val capLabels = params.getStringArrayList(NappletHostContract.EXTRA_CAP_LABELS).orEmpty()
val profile = HostProfile.fromName(params.getString(NappletHostContract.EXTRA_HOST_PROFILE))
val useTor = params.getBoolean(NappletHostContract.EXTRA_USE_TOR, true)
// Only nSites have a route of their own to choose, and only when Tor is running.
val torOn = if (profile.exposesNetwork && params.getInt(NappletHostContract.EXTRA_PROXY_PORT, -1) > 0) useTor else null
// Only nSites have a route of their own to choose, and only when Tor is on.
val torType by Amethyst.instance.torPrefs.torType
.collectAsStateWithLifecycle()
val torOn = if (profile.exposesNetwork && torType != TorType.OFF) useTor else null
val scope = rememberCoroutineScope()
var canGoBack by remember { mutableStateOf(false) }
var showAccess by remember { mutableStateOf(false) }
var textZoom by remember(coordinate) { mutableIntStateOf(BrowserChrome.DEFAULT_TEXT_ZOOM) }
val apps by Amethyst.instance.favoriteApps.favorites
.collectAsStateWithLifecycle()
@@ -165,24 +154,26 @@ private fun EmbeddedNostrAppTab(
remember(id, EmbeddedTabHost.rebuildEpoch, networkEpoch) {
EmbeddedTabFactory.acquireNostrApp(context, coordinate, params, backgroundColor)
}
// Seeded from the controller, which outlives this screen (it leaves composition on every bottom-bar
// switch), so coming back keeps Back working inside the app and the pill showing the zoom in effect.
var canGoBack by remember(controller) { mutableStateOf(controller.lastCanGoBack) }
var textZoom by remember(controller) { mutableIntStateOf(controller.currentTextZoom) }
// Keep the controller callbacks fresh (cheap, need the latest closures).
SideEffect {
controller.onStateChanged = { canGoBack = it }
controller.onNotice = { notice ->
noticeResId(notice)?.let { res ->
scope.launch { Toast.makeText(context, loadStringRes(res), Toast.LENGTH_SHORT).show() }
}
}
}
// The permission-ledger key is the addressable coordinate without its kind prefix (`pubkey:dtag`),
// matching how the Connected Apps screen keys napplet/nsite grants (see NappletIdentity.coordinate).
val permissionCoordinate = remember(coordinate) { coordinate.substringAfter(':') }
// Off for this site, yet on Tor because another open page needs it (Tor always wins in `:napplet`).
val torForced = controller.isTorForced
// Stable per app (title/coordinate/isFavorite don't change often), so the tab layer isn't recomposed every frame.
val chrome =
remember(title, coordinate, isFavorite, torOn, textZoom, controller) {
remember(title, coordinate, isFavorite, torOn, torForced, textZoom, controller) {
EmbeddedTabChrome(
ui =
BrowserPillUi(
@@ -194,6 +185,7 @@ private fun EmbeddedNostrAppTab(
url = "",
startUrl = "",
torOn = torOn,
torForced = torForced,
hasAccessInfo = true,
),
isFavorite = isFavorite,
@@ -212,7 +204,7 @@ private fun EmbeddedNostrAppTab(
BrowserChrome.Action.TOR -> {
// Persist the new route, then rebuild the session so it loads that way.
NappletNetworkRegistry.set(permissionCoordinate, !useTor)
EmbeddedTabHost.evict(id)
EmbeddedTabHost.rebuild(id)
networkEpoch++
}
BrowserChrome.Action.SITE_SETTINGS -> nav.nav(Route.ConnectedAppDetail(permissionCoordinate))
@@ -235,32 +227,25 @@ private fun EmbeddedNostrAppTab(
SideEffect { EmbeddedTabHost.setActiveChrome(id, chrome) }
val bottomBarFlow = accountViewModel.account.settings.syncedSettings.navigation.bottomBarItems
val entryLifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(id) {
val token = EmbeddedTabHost.setActive(id)
EmbeddedTabHost.hold(id)
onDispose {
EmbeddedTabHost.clearActiveIfOwner(token)
EmbeddedTabHost.clearActiveChrome(id)
// Only bottom-row apps stay warm; anything else restarts when it leaves.
if (id !in bottomBarFlow.value.favoriteIds()) EmbeddedTabHost.evict(id)
// Only bottom-row apps stay warm; anything else restarts once the user actually leaves it — not
// when a screen is merely pushed on top, and not when a re-navigation to this same tab already
// composed a new screen on the same session.
if (EmbeddedTabHost.release(id)) {
EmbeddedTabHost.releaseWhenGone(id, entryLifecycle) { id in bottomBarFlow.value.favoriteIds() }
}
}
}
// Pause the applet's JS while the app is backgrounded (parity with NappletHostActivity's onPause):
// an "allow always" napplet can't act on the user's behalf when they aren't looking. (The tab layer
// separately pauses it whenever it isn't the visible tab.)
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) }
}
// No lifecycle handling here: the tab layer tells every warm tab when the app leaves the screen
// (EmbeddedTabHost.onAppStopped), which holds the applet's acting requests at once and pauses its page on
// the relays' 30 s schedule.
PlatformBackHandler(enabled = canGoBack) { controller.back() }
@@ -327,11 +312,3 @@ private fun UnavailableTab(
}
}
}
private fun noticeResId(notice: String): StringResource? =
when (notice) {
NappletEmbedContract.NOTICE_PUBLISHED -> Res.string.favorite_notice_published
NappletEmbedContract.NOTICE_UPLOADED -> Res.string.favorite_notice_uploaded
NappletEmbedContract.NOTICE_PAID -> Res.string.favorite_notice_paid
else -> null
}
@@ -0,0 +1,67 @@
/*
* 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.embed
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
import org.junit.Assert.assertEquals
import org.junit.Test
class ConsoleBufferTest {
private fun line(level: ConsoleLine.Level) = ConsoleLine(level, "m", "s", 1)
@Test
fun countsErrors() {
val buffer = ConsoleBuffer(max = 10)
buffer.add(line(ConsoleLine.Level.LOG))
buffer.add(line(ConsoleLine.Level.ERROR))
buffer.add(line(ConsoleLine.Level.ERROR))
assertEquals(2, buffer.errorCount.intValue)
assertEquals(3, buffer.lines.size)
}
@Test
fun evictingAnErrorLowersTheCount() {
val buffer = ConsoleBuffer(max = 2)
buffer.add(line(ConsoleLine.Level.ERROR))
buffer.add(line(ConsoleLine.Level.LOG))
buffer.add(line(ConsoleLine.Level.LOG))
assertEquals(0, buffer.errorCount.intValue)
assertEquals(2, buffer.lines.size)
}
@Test
fun evictingANonErrorKeepsTheCount() {
val buffer = ConsoleBuffer(max = 2)
buffer.add(line(ConsoleLine.Level.LOG))
buffer.add(line(ConsoleLine.Level.ERROR))
buffer.add(line(ConsoleLine.Level.ERROR))
assertEquals(2, buffer.errorCount.intValue)
}
@Test
fun clearResets() {
val buffer = ConsoleBuffer(max = 2)
buffer.add(line(ConsoleLine.Level.ERROR))
buffer.clear()
assertEquals(0, buffer.errorCount.intValue)
assertEquals(0, buffer.lines.size)
}
}
@@ -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.ui.screen.loggedIn.embed
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedAutoRecovery.Decision
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class EmbeddedAutoRecoveryTest {
private var clock = 1_000L
private val recovery = EmbeddedAutoRecovery { clock }
@Test
fun visibleTabRebuildsRightAway() {
recovery.onShown()
assertEquals(Decision.RECOVER_NOW, recovery.onLost())
}
@Test
fun parkedTabWaitsUntilShown() {
assertEquals(Decision.DEFERRED, recovery.onLost())
assertTrue(recovery.onShown())
// Only once: coming back again doesn't rebuild a live tab.
recovery.onHidden()
assertFalse(recovery.onShown())
}
@Test
fun parkedTabRecoveredByOtherMeansDoesNotRebuild() {
assertEquals(Decision.DEFERRED, recovery.onLost())
recovery.clearPending()
assertFalse(recovery.onShown())
}
@Test
fun secondDeathRightAfterAnAutoRebuildGivesUp() {
recovery.onShown()
assertEquals(Decision.RECOVER_NOW, recovery.onLost())
clock += 5_000
assertEquals(Decision.GIVE_UP, recovery.onLost())
}
@Test
fun deathAfterADeferredRebuildAlsoGivesUp() {
assertEquals(Decision.DEFERRED, recovery.onLost())
assertTrue(recovery.onShown())
clock += 1_000
assertEquals(Decision.GIVE_UP, recovery.onLost())
}
@Test
fun laterDeathsRecoverAgain() {
recovery.onShown()
assertEquals(Decision.RECOVER_NOW, recovery.onLost())
clock += EmbeddedAutoRecovery.LOOP_WINDOW_MS
assertEquals(Decision.RECOVER_NOW, recovery.onLost())
}
}
@@ -91,6 +91,11 @@ object BrowserChrome {
val isLoading: Boolean = false,
/** Tor routing state, or null when this surface offers no Tor choice. */
val torOn: Boolean? = null,
/**
* The site is set to the open web ([torOn] false) but still goes through Tor, because another open page
* needs Tor and the app's pages share one route (Tor always wins). Shown so the user knows why.
*/
val torForced: Boolean = false,
/** Whether the star is offered at all. */
val canFavorite: Boolean = true,
/** Whether an editable permissions screen exists for this surface. */
@@ -0,0 +1,47 @@
/*
* 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.commons.napplet
/**
* The napplet / website requests that ACT for the user or use their key — publish, pay, upload, notify,
* broadcast to other napplets, and a website's NIP-07 sign / encrypt / decrypt — as opposed to reading.
* A host holds these while the user isn't looking at the surface (it's parked off-screen, or the app is in
* the background): pausing the WebView stops animations and media but not JavaScript, so without this an
* "allow always" napplet or site could keep publishing, paying, signing or decrypting while the user looks
* elsewhere. Reads (`getPublicKey`, relay queries, …) keep flowing, so a preloaded surface still fills in.
*/
object NappletActingRequests {
private val ACTING =
setOf(
"relay.publish",
"relay.publishEncrypted",
"value.payInvoice",
"upload.upload",
"notify.create",
"inc.emit",
// NIP-07 (website posture). Decrypt counts too: it hands the page plaintext it couldn't read.
"nostr.signEvent",
"nostr.nip44Encrypt",
"nostr.nip44Decrypt",
)
fun actsForUser(requestType: String?): Boolean = requestType in ACTING
}
@@ -0,0 +1,56 @@
/*
* 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.commons.napplet
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withTimeoutOrNull
/**
* The broker's view of which surfaces the user is looking at right now, as each surface reports it.
*
* The surfaces hold a page's acting requests (sign, encrypt, decrypt…) themselves while nobody is looking,
* but relay reads decrypt on the broker side: an encrypted event a relay pushes to a parked page's
* subscription, or returns for its query, would be decrypted with the user's key and handed over unwatched.
* The broker checks here first and waits until the page is attended again.
*
* Unattended until a surface says otherwise, so one that never reports can't read unwatched.
*/
class NappletAttendance<K : Any> {
private val attended = MutableStateFlow<Set<K>>(emptySet())
fun set(
owner: K,
isAttended: Boolean,
) = attended.update { if (isAttended) it + owner else it - owner }
fun isAttended(owner: K): Boolean = owner in attended.value
/** Waits up to [timeoutMs] for [owner] to be attended; false when it wasn't in time. */
suspend fun awaitAttended(
owner: K,
timeoutMs: Long,
): Boolean = withTimeoutOrNull(timeoutMs) { attended.first { owner in it } } != null
/** [owner] went away. */
fun forget(owner: K) = set(owner, false)
}
@@ -0,0 +1,126 @@
/*
* 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.commons.napplet
import com.vitorpamplona.amethyst.commons.util.withString
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/**
* Keeps a browser surface's NIP-07 traffic with the document that started it.
*
* A browser tab has one broker channel but hosts a sequence of documents: every navigation brings a new
* page, with a new bridge reply proxy [P], and each page numbers its requests from scratch (`r0`, `r1`, …).
* Delivering broker replies to "whichever page posted last" would hand a.com's signature or decrypted
* message to b.com once the user navigates while a consent sheet is up (b.com's own `r0` would even
* resolve with it).
*
* So every page gets a document sequence number. [brokerIdFor] stamps it on the page's request id before
* the request leaves for the broker, and [resolve] only turns a reply back into the page's id — and hands
* back the proxy to post it on — when it belongs to the document on screen now. Replies for a replaced
* document are dropped. The stamp is deterministic per (document, page id), so a later message that
* reuses a request's id (a cancel) still reaches the same broker-side request.
*
* Relay subscriptions get the same treatment: a page names its own (`s0`, …), and the broker pushes their
* events — decrypted DMs included — keyed by that name. [stampSubscription] stamps the document on the
* `subId` of what the page sends, and [resolvePush] only lets a push through, with the page's own name
* back, when it is for the document on screen now.
*
* Single-threaded: call from the WebView's (main) thread.
*/
class NappletBridgeDocuments<P : Any> {
private var current: P? = null
private var document = 0L
/** The bridge reply proxy of the document on screen, or null before the first message. */
val currentProxy: P? get() = current
/**
* Records that a main-frame bridge message arrived through [proxy]. Returns true when it came from a
* NEW document replacing an earlier one — the caller then drops the old page's broker state.
*/
fun onMessage(proxy: P): Boolean {
if (current === proxy) return false
val replaced = current != null
current = proxy
document++
return replaced
}
/** The id to send to the broker for the current document's request [pageId]. */
fun brokerIdFor(pageId: String): String = "$document$SEPARATOR$pageId"
/**
* Resolves a broker reply's [brokerId] into the page's own id and the proxy to post it on, or null when
* the reply belongs to a document that is gone (or was never stamped by [brokerIdFor]).
*/
fun resolve(brokerId: String): Pair<String, P>? {
val proxy = current ?: return null
val cut = brokerId.indexOf(SEPARATOR)
if (cut <= 0 || brokerId.substring(0, cut).toLongOrNull() != document) return null
return brokerId.substring(cut + 1) to proxy
}
/**
* [envelope] with the current document stamped on its `subId` (`relay.subscribe`, `relay.close`), or
* null when it carries none and goes to the broker unchanged.
*/
fun stampSubscription(envelope: JsonObject): JsonObject? {
val subId = envelope.quotedString(SUB_ID) ?: return null
return envelope.withString(SUB_ID, brokerIdFor(subId))
}
/**
* A broker push to hand to the page on screen: unchanged when it isn't for a subscription, with the page's
* own `subId` back when it is for one this document opened, or null — drop it — when it is for a
* subscription of a document that is gone (or when nothing is on screen).
*/
fun resolvePush(push: JsonObject): JsonObject? {
if (current == null) return null
val brokerSubId = push.quotedString(SUB_ID) ?: return push
val cut = brokerSubId.indexOf(SEPARATOR)
if (cut <= 0 || brokerSubId.substring(0, cut).toLongOrNull() != document) return null
return push.withString(SUB_ID, brokerSubId.substring(cut + 1))
}
private fun JsonObject.quotedString(key: String): String? = (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content
/**
* A main-frame navigation began: whatever document was on screen is on its way out, even if the new one
* never talks to the bridge. Returns true when there was one — the caller then drops its broker state.
*/
fun onNavigation(): Boolean {
val had = current != null
clear()
return had
}
/** The surface went away (session closed, renderer died): nothing on screen can receive a reply. */
fun clear() {
current = null
document++
}
private companion object {
const val SEPARATOR = ':'
const val SUB_ID = "subId"
}
}
@@ -0,0 +1,93 @@
/*
* 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.commons.napplet
/**
* The requests a page made to act for the user (sign, encrypt, decrypt, publish, pay…) while nobody was
* looking at it, held until someone is (see [NappletActingRequests]).
*
* Bounded both ways, so an unattended page can neither grow the queue without end nor have the user come
* back to a pile of stale prompts: past [cap] a new request is handed straight back to be failed, and one
* held longer than [maxAgeMs] is failed instead of sent — by [expire], or when [drain] finds it on the user's
* return. Either way the page's promise settles with an error rather than hanging.
*
* Single-threaded: call from the main thread.
*/
class NappletHeldRequests<T>(
private val clock: () -> Long,
private val cap: Int = MAX_HELD,
private val maxAgeMs: Long = MAX_AGE_MS,
) {
private class Held<T>(
val item: T,
val heldAt: Long,
)
private val items = ArrayDeque<Held<T>>()
val size: Int get() = items.size
/** Holds [item], or hands it back (for the caller to fail) when [cap] requests are already waiting. */
fun hold(item: T): T? {
if (items.size >= cap) return item
items.addLast(Held(item, clock()))
return null
}
/** Removes and returns the requests held longer than [maxAgeMs] (oldest first). */
fun expire(): List<T> {
val now = clock()
val expired = mutableListOf<T>()
while (items.isNotEmpty() && now - items.first().heldAt >= maxAgeMs) expired += items.removeFirst().item
return expired
}
/** Removes everything held: the requests still fresh enough to send, and the ones to fail instead. */
fun drain(): Drained<T> {
val expired = expire()
val fresh = items.map { it.item }
items.clear()
return Drained(fresh, expired)
}
/** Drops everything held (the page or surface is gone) and returns it. */
fun clear(): List<T> {
val all = items.map { it.item }
items.clear()
return all
}
data class Drained<T>(
val send: List<T>,
val fail: List<T>,
)
companion object {
/** At most this many requests wait for the user at once. */
const val MAX_HELD = 32
/** A held request older than this is failed rather than sent: the moment it was made for has passed. */
const val MAX_AGE_MS = 120_000L
const val TOO_MANY = "Too many requests are waiting for the user."
const val EXPIRED = "The request timed out while the user was away."
}
}
@@ -49,6 +49,9 @@ class NappletIdentityWatch(
boundPubKey: String,
push: (String) -> Unit,
) {
// A surface re-created under the same launch token (a tab re-arming its session) starts a new watch
// with its own sink; replace the old stream rather than keep pushing to the gone surface's Messenger.
jobs.remove(watchId)?.cancel()
jobs.getOrPut(watchId) {
val id = watchId
scope
@@ -63,6 +66,11 @@ class NappletIdentityWatch(
}
}
/** Stops the watch started under [watchId], if any. */
fun stop(watchId: String) {
jobs.remove(watchId)?.cancel()
}
fun stopAll() {
jobs.snapshot().values.forEach { it.cancel() }
jobs.clear()
@@ -0,0 +1,77 @@
/*
* 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.commons.napplet
/**
* Decides the ONE proxy route shared by every WebView in the sandbox process.
*
* Android's WebView proxy override is process-global, yet Tor is chosen per surface (per site): a
* browser tab, an nSite, a full-screen page. When each surface set or cleared the override for itself,
* the last one to do so won for everyone — opening an open-web page silently moved an already-open Tor
* page onto the open web, with no reload and nothing on screen to say so.
*
* So every live surface files a claim, and the route is derived from all of them: **Tor always wins.**
* While ANY claim wants Tor, the whole process goes through Tor (the most recent Tor claim's port — a Tor
* restart can move it), open-web surfaces included; with no Tor claim at all there is no proxy.
*
* There are deliberately no per-host exemptions. Exempting an open-web page's host would let a Tor page
* reach that host directly — and an attacker who got one page onto the open web (a site opened before Tor
* was up, say) could then have a Tor page load `https://x.attacker.com/<id>` and tie the user's real IP to
* the Tor session. The cost is that an open-web page goes through Tor while another open page needs it;
* surfaces show why (see [Route.usesTor]).
*
* Not thread-safe: the caller serializes access (the sandbox touches it only on its main thread).
*/
class NappletProxyClaims {
/** The route to apply: through Tor on [torPort] when [usesTor], else no proxy. */
data class Route(
val torPort: Int,
) {
val usesTor: Boolean get() = torPort > 0
}
// Insertion-ordered; a re-claim moves the owner to the end, so the last entry is the latest claim.
// Each value is the Tor SOCKS port the owner wants, or [NO_PROXY] for the open web.
private val claims = LinkedHashMap<Any, Int>()
/** Files (or replaces) [owner]'s claim — Tor on [torPort] (> 0), or [NO_PROXY] — and returns the route. */
fun claim(
owner: Any,
torPort: Int,
): Route {
claims.remove(owner)
claims[owner] = torPort
return route()
}
/** Withdraws [owner]'s claim (the surface is gone) and returns the resulting route. */
fun release(owner: Any): Route {
claims.remove(owner)
return route()
}
fun route(): Route = Route(claims.values.lastOrNull { it > 0 } ?: NO_PROXY)
companion object {
const val NO_PROXY = -1
val DIRECT = Route(NO_PROXY)
}
}
@@ -0,0 +1,48 @@
/*
* 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.commons.napplet
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class NappletActingRequestsTest {
@Test
fun actingRequestsAreHeld() {
listOf(
"relay.publish",
"relay.publishEncrypted",
"value.payInvoice",
"upload.upload",
"notify.create",
"inc.emit",
"nostr.signEvent",
"nostr.nip44Encrypt",
"nostr.nip44Decrypt",
).forEach { assertTrue(NappletActingRequests.actsForUser(it), it) }
}
@Test
fun readsFlow() {
listOf("identity.getPublicKey", "identity.getRelays", "relay.query", "relay.subscribe", "relay.close", "storage.get", "resource.bytes", "theme.get", null)
.forEach { assertFalse(NappletActingRequests.actsForUser(it), it.toString()) }
}
}
@@ -0,0 +1,57 @@
/*
* 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.commons.napplet
import kotlinx.coroutines.async
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class NappletAttendanceTest {
private val attendance = NappletAttendance<String>()
@Test
fun unattendedUntilTold() {
assertFalse(attendance.isAttended("tab"))
attendance.set("tab", true)
assertTrue(attendance.isAttended("tab"))
attendance.forget("tab")
assertFalse(attendance.isAttended("tab"))
}
@Test
fun awaitReturnsOnceTheUserIsBack() =
runTest {
val waiting = async { attendance.awaitAttended("tab", 10_000) }
testScheduler.advanceTimeBy(1_000)
attendance.set("other", true)
testScheduler.advanceTimeBy(1_000)
attendance.set("tab", true)
assertTrue(waiting.await())
}
@Test
fun awaitGivesUpAfterTheTimeout() =
runTest {
assertFalse(attendance.awaitAttended("tab", 5_000))
}
}
@@ -0,0 +1,155 @@
/*
* 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.commons.napplet
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
import com.vitorpamplona.amethyst.commons.util.stringOrNull
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertSame
import kotlin.test.assertTrue
class NappletBridgeDocumentsTest {
private class Proxy(
val name: String,
)
private val docs = NappletBridgeDocuments<Proxy>()
private val a = Proxy("a.com")
private val b = Proxy("b.com")
@Test
fun replyReachesTheDocumentThatAsked() {
assertFalse(docs.onMessage(a))
val id = docs.brokerIdFor("r0")
val (pageId, proxy) = docs.resolve(id)!!
assertEquals("r0", pageId)
assertSame(a, proxy)
}
@Test
fun sameDocumentKeepsItsRequests() {
docs.onMessage(a)
val id = docs.brokerIdFor("r0")
assertFalse(docs.onMessage(a))
assertSame(a, docs.resolve(id)!!.second)
}
@Test
fun replyForANavigatedAwayDocumentIsDropped() {
docs.onMessage(a)
val aRequest = docs.brokerIdFor("r0")
assertTrue(docs.onMessage(b))
// b.com numbers its own requests from r0 too; a.com's late reply must not resolve it.
val bRequest = docs.brokerIdFor("r0")
assertNull(docs.resolve(aRequest))
assertSame(b, docs.resolve(bRequest)!!.second)
}
@Test
fun returningToAnEarlierProxyIsStillANewDocument() {
docs.onMessage(a)
val first = docs.brokerIdFor("r0")
docs.onMessage(b)
docs.onMessage(a)
assertNull(docs.resolve(first))
}
@Test
fun pageIdsMayContainTheSeparator() {
docs.onMessage(a)
assertEquals("fire:7", docs.resolve(docs.brokerIdFor("fire:7"))!!.first)
}
@Test
fun clearedSurfaceResolvesNothing() {
docs.onMessage(a)
val id = docs.brokerIdFor("r0")
docs.clear()
assertNull(docs.resolve(id))
assertNull(docs.currentProxy)
}
@Test
fun unstampedIdsAreRejected() {
docs.onMessage(a)
assertNull(docs.resolve("r0"))
assertNull(docs.resolve(":r0"))
}
private fun json(raw: String) = parseJsonObjectOrNull(raw)!!
@Test
fun subscriptionPushesReachTheDocumentThatSubscribed() {
docs.onMessage(a)
val stamped = docs.stampSubscription(json("""{"type":"relay.subscribe","id":"r0","subId":"s0"}"""))!!
val brokerSubId = stamped.stringOrNull("subId")!!
val push = docs.resolvePush(json("""{"type":"relay.event","subId":"$brokerSubId"}"""))!!
assertEquals("s0", push.stringOrNull("subId"))
}
@Test
fun subscriptionPushesForANavigatedAwayDocumentAreDropped() {
docs.onMessage(a)
val brokerSubId = docs.stampSubscription(json("""{"type":"relay.subscribe","subId":"s0"}"""))!!.stringOrNull("subId")!!
docs.onMessage(b)
// b.com names its own subscription s0 too: a.com's decrypted events must not reach it.
docs.stampSubscription(json("""{"type":"relay.subscribe","subId":"s0"}"""))
assertNull(docs.resolvePush(json("""{"type":"relay.event","subId":"$brokerSubId"}""")))
}
@Test
fun closeIsStampedLikeTheSubscribeItEnds() {
docs.onMessage(a)
val open = docs.stampSubscription(json("""{"type":"relay.subscribe","subId":"s0"}"""))!!
val close = docs.stampSubscription(json("""{"type":"relay.close","subId":"s0"}"""))!!
assertEquals(open.stringOrNull("subId"), close.stringOrNull("subId"))
}
@Test
fun pushesWithoutASubscriptionGoToThePageOnScreen() {
assertNull(docs.resolvePush(json("""{"type":"identity.changed"}""")))
docs.onMessage(a)
assertEquals("identity.changed", docs.resolvePush(json("""{"type":"identity.changed"}"""))!!.stringOrNull("type"))
assertNull(docs.stampSubscription(json("""{"type":"nostr.signEvent","id":"r0"}""")))
}
@Test
fun unstampedSubscriptionPushesAreDropped() {
docs.onMessage(a)
assertNull(docs.resolvePush(json("""{"type":"relay.event","subId":"s0"}""")))
}
@Test
fun navigationEndsTheDocumentEvenIfTheNextNeverTalks() {
docs.onMessage(a)
val request = docs.brokerIdFor("r0")
assertTrue(docs.onNavigation())
assertNull(docs.resolve(request))
assertNull(docs.resolvePush(json("""{"type":"identity.changed"}""")))
// Nothing on screen talked yet: a second navigation has nothing to release.
assertFalse(docs.onNavigation())
// The next page's first message is not a "replacement": its predecessor was already released.
assertFalse(docs.onMessage(b))
}
}
@@ -0,0 +1,82 @@
/*
* 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.commons.napplet
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class NappletHeldRequestsTest {
private var now = 0L
private val held = NappletHeldRequests<String>(clock = { now }, cap = 3, maxAgeMs = 1_000)
@Test
fun heldRequestsAreSentWhenTheUserIsBack() {
assertNull(held.hold("a"))
assertNull(held.hold("b"))
val drained = held.drain()
assertEquals(listOf("a", "b"), drained.send)
assertTrue(drained.fail.isEmpty())
assertEquals(0, held.size)
}
@Test
fun pastTheCapANewRequestIsHandedBack() {
held.hold("a")
held.hold("b")
held.hold("c")
assertEquals("d", held.hold("d"))
assertEquals(3, held.size)
}
@Test
fun staleRequestsAreFailedNotSent() {
held.hold("old")
now = 600
held.hold("new")
now = 1_200
val drained = held.drain()
assertEquals(listOf("new"), drained.send)
assertEquals(listOf("old"), drained.fail)
}
@Test
fun expireRemovesOnlyTheStaleOnes() {
held.hold("old")
now = 600
held.hold("new")
now = 1_000
assertEquals(listOf("old"), held.expire())
assertEquals(1, held.size)
now = 1_600
assertEquals(listOf("new"), held.expire())
assertEquals(0, held.size)
}
@Test
fun clearReturnsEverything() {
held.hold("a")
held.hold("b")
assertEquals(listOf("a", "b"), held.clear())
assertEquals(0, held.size)
}
}
@@ -0,0 +1,73 @@
/*
* 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.commons.napplet
import com.vitorpamplona.amethyst.commons.napplet.NappletProxyClaims.Companion.DIRECT
import com.vitorpamplona.amethyst.commons.napplet.NappletProxyClaims.Companion.NO_PROXY
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class NappletProxyClaimsTest {
private val claims = NappletProxyClaims()
private val torTab = Any()
private val openTab = Any()
@Test
fun noClaimsMeansNoProxy() {
assertEquals(DIRECT, claims.route())
}
@Test
fun anOpenWebSurfaceDoesNotDowngradeATorOne() {
claims.claim(torTab, 9050)
// The open-web page opening later must not clear Tor for the page already on it.
assertEquals(9050, claims.claim(openTab, NO_PROXY).torPort)
}
@Test
fun orderDoesNotMatter() {
claims.claim(openTab, NO_PROXY)
assertTrue(claims.claim(torTab, 9050).usesTor)
}
@Test
fun releasingTheLastTorSurfaceGoesDirect() {
claims.claim(torTab, 9050)
claims.claim(openTab, NO_PROXY)
assertEquals(DIRECT, claims.release(torTab))
}
@Test
fun switchingASurfaceOffTorReleasesTheProxy() {
claims.claim(torTab, 9050)
assertEquals(DIRECT, claims.claim(torTab, NO_PROXY))
}
@Test
fun theLatestTorPortWins() {
claims.claim(torTab, 9050)
val other = Any()
assertEquals(9150, claims.claim(other, 9150).torPort)
// Re-claiming moves an owner to the end, making its port the latest.
assertEquals(9050, claims.claim(torTab, 9050).torPort)
}
}
@@ -5686,6 +5686,7 @@
<string name="browser_pill_tor_title">Onion routing</string>
<string name="browser_pill_tor_on">The site can't see your IP address</string>
<string name="browser_pill_tor_off">The site can see your IP address</string>
<string name="browser_pill_tor_forced">Off for this site, but another open page uses Tor, so this one goes through Tor too</string>
<string name="browser_pill_site_settings">Site settings</string>
<string name="browser_pill_site_settings_none">Nothing allowed yet</string>
<string name="browser_pill_access">What it can access</string>
@@ -90,6 +90,7 @@ import com.vitorpamplona.amethyst.commons.resources.browser_pill_text_larger
import com.vitorpamplona.amethyst.commons.resources.browser_pill_text_reset
import com.vitorpamplona.amethyst.commons.resources.browser_pill_text_smaller
import com.vitorpamplona.amethyst.commons.resources.browser_pill_text_value
import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_forced
import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_off
import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_on
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -487,7 +488,14 @@ private fun PrivacyCard(
icon = { PillActionIcon(Action.TOR, tint = if (on) MaterialTheme.colorScheme.onTertiaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, size = 22.dp) },
iconContainer = if (on) MaterialTheme.colorScheme.tertiaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest,
title = stringRes(pillLabelFor(Action.TOR)),
supporting = stringRes(if (on) Res.string.browser_pill_tor_on else Res.string.browser_pill_tor_off),
supporting =
stringRes(
when {
on -> Res.string.browser_pill_tor_on
ui.chrome.torForced -> Res.string.browser_pill_tor_forced
else -> Res.string.browser_pill_tor_off
},
),
onClick = { onAction(Action.TOR) },
) { Switch(checked = on, onCheckedChange = { onAction(Action.TOR) }) }
}
@@ -111,6 +111,7 @@ import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_microphone
import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_once
import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_title
import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_tor_note
import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_forced
import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_off
import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_on
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -372,7 +373,14 @@ fun PageInfoSheet(
icon = { PillActionIcon(BrowserChrome.Action.TOR, tint = if (tor) MaterialTheme.colorScheme.onTertiaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, size = 22.dp) },
iconContainer = if (tor) MaterialTheme.colorScheme.tertiaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest,
title = stringRes(if (tor) Res.string.browser_pill_info_tor else Res.string.browser_pill_info_open),
supporting = stringRes(if (tor) Res.string.browser_pill_tor_on else Res.string.browser_pill_tor_off),
supporting =
stringRes(
when {
tor -> Res.string.browser_pill_tor_on
ui.chrome.torForced -> Res.string.browser_pill_tor_forced
else -> Res.string.browser_pill_tor_off
},
),
onClick = null,
)
}
@@ -57,6 +57,7 @@ import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -119,6 +120,8 @@ import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.StringResource
/** How many of the most recent history entries the idle browser home surfaces under "Recent". */
@@ -213,19 +216,27 @@ private fun BrowserLauncher(
// Drop ones already pinned — they show under Favorites, not twice.
val favoriteCoordinates = remember(apps) { apps.filterIsInstance<FavoriteApp.NostrApp>().mapTo(HashSet()) { it.coordinate } }
val followedNsites =
remember(nsiteNotes, nsiteFollows, favoriteCoordinates) {
nsiteNotes.toDiscoverApps(nsiteFollows::matchAuthor, favoriteCoordinates)
}
val followedNapplets =
remember(nappletNotes, nappletFollows, favoriteCoordinates) {
nappletNotes.toDiscoverApps(nappletFollows::matchAuthor, favoriteCoordinates)
}
// Built off the main thread: these walk every cached nsite/napplet note, and the note lists re-emit as
// relays deliver.
val followedNsites by produceState(emptyList<DiscoverNostrApp>(), nsiteNotes, nsiteFollows, favoriteCoordinates) {
value = withContext(Dispatchers.Default) { nsiteNotes.toDiscoverApps(nsiteFollows::matchAuthor, favoriteCoordinates) }
}
val followedNapplets by produceState(emptyList<DiscoverNostrApp>(), nappletNotes, nappletFollows, favoriteCoordinates) {
value = withContext(Dispatchers.Default) { nappletNotes.toDiscoverApps(nappletFollows::matchAuthor, favoriteCoordinates) }
}
// What the user actually typed, excluding any selected ghost-completion suffix (selection.min is the
// caret when collapsed, or the start of the highlighted suffix when a completion is showing).
val typed = field.text.take(field.selection.min.coerceIn(0, field.text.length))
val suggestions = remember(typed, candidates) { OmniboxSuggestions.rank(typed, candidates, limit = 12) }
// One ranking per typed text: an appended character is ranked in onValueChange (for the inline
// completion) and then again for this list on the recomposition that follows — keep the last one.
val lastRanking = remember(candidates) { arrayOfNulls<Pair<String, List<OmniboxSuggestions.Suggestion>>>(1) }
fun ranked(text: String): List<OmniboxSuggestions.Suggestion> =
lastRanking[0]?.takeIf { it.first == text }?.second
?: OmniboxSuggestions.rank(text, candidates, limit = 12).also { lastRanking[0] = text to it }
val suggestions = remember(typed, candidates) { ranked(typed) }
fun open(text: String) {
val target = OmniboxInput.resolve(text) ?: return
@@ -256,7 +267,8 @@ private fun BrowserLauncher(
newText.length > prevTyped.length &&
newText.startsWith(prevTyped)
if (appended) {
val completion = OmniboxSuggestions.completion(newText, OmniboxSuggestions.rank(newText, candidates))
// The completion has always looked at the top 8 (rank's default limit).
val completion = OmniboxSuggestions.completion(newText, ranked(newText).take(8))
if (completion != null) {
// Keep the user's own casing for the typed prefix; append only the remaining suffix.
val full = newText + completion.substring(newText.length)
@@ -0,0 +1,64 @@
/*
* 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.os.Message
import androidx.webkit.JavaScriptReplyProxy
import com.vitorpamplona.amethyst.commons.napplet.NappletBridgeDocuments
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
import com.vitorpamplona.amethyst.commons.util.withString
import kotlinx.serialization.json.JsonObject
/**
* Answers a queued broker [request] (a [NappletIpc.MSG_REQUEST] that never left) with a failure, delivered
* to the page that made it — the same reply shape the broker uses, so the page's promise rejects with
* [reason] instead of waiting forever. Dropped if that page has since been navigated away from.
*/
fun NappletBridgeDocuments<JavaScriptReplyProxy>.failRequest(
request: Message,
reason: String,
) {
val data = request.data ?: return
val brokerId = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return
val raw = data.getString(NappletIpc.KEY_PAYLOAD) ?: return
val (pageId, proxy) = resolve(brokerId) ?: return
val type = runCatching { NappletProtocolJson.readType(raw) }.getOrNull() ?: "napplet"
val reply = parseJsonObjectOrNull(NappletProtocolJson.encodeResponse(type, NappletResponse.Failed(reason))) ?: JsonObject(emptyMap())
runCatching { proxy.postMessage(reply.withString("id", pageId).toString()) }
}
/**
* Answers a napplet's queued broker [request] with a failure on [this] proxy. A napplet's request ids aren't
* stamped per document (its shell never navigates), so the id goes back as the page sent it.
*/
fun JavaScriptReplyProxy.failRequest(
request: Message,
reason: String,
) {
val data = request.data ?: return
val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return
val raw = data.getString(NappletIpc.KEY_PAYLOAD) ?: return
val type = runCatching { NappletProtocolJson.readType(raw) }.getOrNull() ?: "napplet"
val reply = parseJsonObjectOrNull(NappletProtocolJson.encodeResponse(type, NappletResponse.Failed(reason))) ?: JsonObject(emptyMap())
runCatching { postMessage(reply.withString("id", id).toString()) }
}
@@ -25,7 +25,9 @@ import android.content.MutableContextWrapper
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.webkit.RenderProcessGoneDetail
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.webkit.JavaScriptReplyProxy
import androidx.webkit.WebMessageCompat
import androidx.webkit.WebViewCompat
@@ -122,6 +124,20 @@ object BrowserPopups {
}
WebViewCompat.addDocumentStartJavaScript(webView, BrowserWebTools.browserStartScript(shimJs, imeProxy = false), setOf("*"))
val token = UUID.randomUUID().toString()
// Until an Activity adopts it (and installs its own client), a parked popup still shares the one
// `:napplet` renderer. With no client its renderer death falls to the default — returning false, which
// kills the whole process and every embedded tab in it. Drop just the popup instead.
webView.webViewClient =
object : WebViewClient() {
override fun onRenderProcessGone(
view: WebView,
detail: RenderProcessGoneDetail,
): Boolean {
pending.remove(token)
view.destroy()
return true
}
}
pending[token] = entry
main.postDelayed({
pending.remove(token)?.let { orphan ->
@@ -0,0 +1,90 @@
/*
* 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.graphics.Bitmap
import android.graphics.Canvas
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.webkit.WebView
import androidx.annotation.RequiresApi
import androidx.core.graphics.createBitmap
import java.io.ByteArrayOutputStream
import java.util.concurrent.Executors
/**
* Captures the selection loupe's magnified slice of an embedded page for the main process.
*
* Host-side `PixelCopy` can't read the sandbox surface, so the page is drawn here. That draw has to happen
* on the main thread — the one thread every sandboxed surface in `:napplet` renders on — so everything else
* is kept off it: the frame is capped at [MAX_SIDE_PX] a side (a large box at high zoom used to allocate
* tens of MB and produce a frame too big for the binder, which was silently dropped), and the encode runs on
* a background thread as lossy WebP (a quality-100 PNG, per drag frame, on the main thread, was the cost).
*/
@RequiresApi(Build.VERSION_CODES.R)
internal object MagnifierCapture {
private const val MAX_SIDE_PX = 512
private const val QUALITY = 85
private val encoder = Executors.newSingleThreadExecutor { Thread(it, "napplet-magnifier").apply { isDaemon = true } }
private val main = Handler(Looper.getMainLooper())
/**
* Draws the [boxW]×[boxH] source rect centered on ([cx], [cy]) (view px) of [webView] at [zoom] over
* [bgColor], then — on the main thread, once encoded — hands [deliver] the image bytes, their size, and
* the capture time in ms. Main thread only.
*/
fun capture(
webView: WebView,
bgColor: Int,
cx: Float,
cy: Float,
boxW: Int,
boxH: Int,
zoom: Float,
deliver: (bytes: ByteArray, width: Int, height: Int, captureMs: Double) -> Unit,
) {
val t0 = SystemClock.elapsedRealtimeNanos()
val scale = minOf(zoom, MAX_SIDE_PX.toFloat() / boxW, MAX_SIDE_PX.toFloat() / boxH)
val outW = (boxW * scale).toInt().coerceAtLeast(1)
val outH = (boxH * scale).toInt().coerceAtLeast(1)
val bitmap = createBitmap(outW, outH)
val canvas = Canvas(bitmap)
canvas.drawColor(bgColor)
// Map the source rect (centered on cx,cy in view px) into the scaled output bitmap.
canvas.scale(scale, scale)
canvas.translate(-(cx - boxW / 2f), -(cy - boxH / 2f))
webView.draw(canvas)
encoder.execute {
val bytes =
ByteArrayOutputStream().use { out ->
bitmap.compress(Bitmap.CompressFormat.WEBP_LOSSY, QUALITY, out)
out.toByteArray()
}
bitmap.recycle()
val captureMs = (SystemClock.elapsedRealtimeNanos() - t0) / 1_000_000.0
main.post { deliver(bytes, outW, outH, captureMs) }
}
}
}
@@ -40,6 +40,7 @@ import android.os.IBinder
import android.os.Looper
import android.os.Message
import android.os.Messenger
import android.os.SystemClock
import android.util.TypedValue
import android.view.ContextMenu
import android.view.Gravity
@@ -75,8 +76,6 @@ import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.webkit.JavaScriptReplyProxy
import androidx.webkit.ProxyConfig
import androidx.webkit.ProxyController
import androidx.webkit.WebMessageCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
@@ -90,7 +89,12 @@ import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageDialogType
import com.vitorpamplona.amethyst.commons.napplet.NappletActingRequests
import com.vitorpamplona.amethyst.commons.napplet.NappletBridgeDocuments
import com.vitorpamplona.amethyst.commons.napplet.NappletHeldRequests
import com.vitorpamplona.amethyst.commons.napplet.NappletProxyClaims
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
import com.vitorpamplona.amethyst.commons.util.stringOrNull
import com.vitorpamplona.amethyst.commons.util.withString
@@ -98,7 +102,6 @@ import com.vitorpamplona.quartz.utils.Log
import kotlinx.serialization.json.JsonObject
import java.io.ByteArrayOutputStream
import java.lang.ref.WeakReference
import java.util.concurrent.Executor
import com.vitorpamplona.amethyst.commons.R as CommonsR
/**
@@ -221,7 +224,10 @@ class NappletBrowserActivity : ComponentActivity() {
}
private val pendingBrokerRequests = mutableListOf<Message>()
private var bridgeReplyProxy: JavaScriptReplyProxy? = null
// The page on screen's bridge reply proxy, and which document each broker reply belongs to: a reply
// for a page the user has navigated away from must never land in the next one.
private val bridge = NappletBridgeDocuments<JavaScriptReplyProxy>()
private var fireSeq = 0
private val originTokens = mutableMapOf<String, String>()
private val pendingByOrigin = mutableMapOf<String, MutableList<Message>>()
@@ -229,6 +235,11 @@ class NappletBrowserActivity : ComponentActivity() {
private val downloadCooldown = DownloadCooldown()
private var preparingDownload = false
// The page's requests that act for the user (NIP-07 sign / encrypt / decrypt) made while this window was in
// the background, as (origin, request): sent on the next resume, so a site can't sign — even with
// "allow always" — while nobody is looking at it.
private val heldWhileAway = NappletHeldRequests<Pair<String, Message>>(SystemClock::elapsedRealtime)
/**
* Back walks out of fullscreen video, then the find bar, then the page's history, then leaves. Enabled
* only while one of those applies, so the system back (and its predictive animation) otherwise acts
@@ -265,6 +276,7 @@ class NappletBrowserActivity : ComponentActivity() {
pendingBrokerRequests.forEach { sendToBroker(it) }
pendingBrokerRequests.clear()
if (resumed) setBrokerForeground(true)
reportAttended()
}
override fun onServiceDisconnected(name: ComponentName?) {
@@ -303,6 +315,14 @@ class NappletBrowserActivity : ComponentActivity() {
}
title = intent.getStringExtra(EXTRA_TITLE).orEmpty()
// Fail closed: Tor is on but its port isn't known yet (still starting). This window can't learn it
// later, so refuse now rather than sit blank — or go out on the open web.
if (popup == null && useTor && proxyPort <= 0) {
Toast.makeText(this, R.string.napplet_route_blocked, Toast.LENGTH_LONG).show()
finish()
return
}
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
Toast.makeText(this, getString(R.string.napplet_webview_too_old), Toast.LENGTH_LONG).show()
finish()
@@ -310,7 +330,13 @@ class NappletBrowserActivity : ComponentActivity() {
}
shimJs = readContractAsset(NappletWebContract.SHIM_JS_PATH).decodeToString()
applyWebViewProxy(if (useTor) proxyPort else -1)
// Which route is really in effect: an open-web page goes through Tor while another page needs it.
WebViewProxyPolicy.observeRoute(this) {
routedOverTor = it
updateChromeState { copy(torForced = !useTor && it) }
}
// A popup's WebView is already loading: its route is claimed now, not before a load.
if (popup != null) claimRoute()
bindService(Intent().setClassName(this, NappletHostContract.BROKER_SERVICE_CLASS), brokerConnection, BIND_AUTO_CREATE)
onBackPressedDispatcher.addCallback(this, backCallback)
@@ -335,7 +361,8 @@ class NappletBrowserActivity : ComponentActivity() {
contentFrame.addView(wv, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
if (popup == null) {
loadingView = buildLoadingView().also { contentFrame.addView(it) }
wv.loadUrl(startUrl)
// Wait for this page's route to be in effect before the first request leaves.
claimRoute { if (webView === wv) wv.loadUrl(startUrl) }
} else {
wv.url?.let { if (it.isNotBlank() && it != "about:blank") startUrl = it }
}
@@ -406,31 +433,59 @@ class NappletBrowserActivity : ComponentActivity() {
}
}
private val expireHeld =
Runnable {
if (!isDestroyed) heldWhileAway.expire().forEach { (_, request) -> bridge.failRequest(request, NappletHeldRequests.EXPIRED) }
}
override fun onResume() {
super.onResume()
webView?.onResume()
resumed = true
reportAttended()
val held = heldWhileAway.drain()
held.fail.forEach { (_, request) -> bridge.failRequest(request, NappletHeldRequests.EXPIRED) }
held.send.forEach { (origin, request) -> dispatchToBroker(origin, request) }
heartbeatHandler.removeCallbacks(heartbeat)
heartbeat.run()
}
override fun onStart() {
super.onStart()
// Back within the grace: the page was never paused.
heartbeatHandler.removeCallbacks(backgroundPause)
}
override fun onStop() {
// Out of sight: pause the page after the same grace the rest of the app gets
// (NappletHostContract.BACKGROUND_PAUSE_MS), so a quick trip to another app doesn't interrupt it.
// NIP-07 sign / encrypt / decrypt is already held while not resumed (see [heldWhileAway]).
heartbeatHandler.postDelayed(backgroundPause, NappletHostContract.BACKGROUND_PAUSE_MS)
super.onStop()
}
// Only pause THIS activity's WebView (onPause is per-WebView). Do NOT call pauseTimers(): it is
// process-global — it freezes JS/layout/parsing timers for EVERY WebView in `:napplet`, including the
// embedded ones in NappletBrowserService, which have no resume of their own. That left the embed frozen
// (dead page/connection) after returning from a full-screen excursion.
private val backgroundPause = Runnable { if (!isDestroyed) webView?.onPause() }
override fun onPause() {
// Only pause THIS activity's WebView (onPause is per-WebView). Do NOT call pauseTimers(): it is
// process-global — it freezes JS/layout/parsing timers for EVERY WebView in `:napplet`, including
// the embedded ones in NappletBrowserService, which have no resume of their own. That left the
// embed frozen (dead page/connection) after returning from a full-screen excursion.
webView?.onPause()
resumed = false
reportAttended()
heartbeatHandler.removeCallbacks(heartbeat)
setBrokerForeground(false)
super.onPause()
}
override fun onDestroy() {
heartbeatHandler.removeCallbacks(backgroundPause)
heartbeatHandler.removeCallbacks(expireHeld)
// Tell the broker to drop every reference to our reply Messenger BEFORE unbinding — a retained
// Messenger is a binder, and it would pin this Activity (and its WebView) in `:napplet` for the
// life of the process. `unbindService` alone does not release it. See [replyMessenger].
releaseFromBroker()
WebViewProxyPolicy.release(this)
runCatching { unbindService(brokerConnection) }
// A picker still up when the browser is torn down would otherwise leave its callback unanswered.
pendingFileChooser.cancel()
@@ -483,7 +538,12 @@ class NappletBrowserActivity : ComponentActivity() {
val msg =
Message.obtain(null, NappletIpc.MSG_RELEASE_CLIENT).apply {
replyTo = replyMessenger
data = Bundle().apply { putString(NappletIpc.KEY_LAUNCH_TOKEN, leaseKey) }
data =
Bundle().apply {
putString(NappletIpc.KEY_LAUNCH_TOKEN, leaseKey)
// This activity's per-origin tokens die with it (a recreated activity mints its own).
if (originTokens.isNotEmpty()) putStringArray(NappletIpc.KEY_RELEASED_TOKENS, originTokens.values.toTypedArray())
}
}
runCatching { broker.send(msg) }
}
@@ -678,6 +738,9 @@ class NappletBrowserActivity : ComponentActivity() {
// A fresh main-frame navigation: arm history gating and show the new address.
pendingMainFrameUrl = url
mainFrameLoadFailed = false
// The page is being replaced: nothing it asked for (replies, subscription pushes) may reach the next
// one, even a next one that never talks to the bridge.
if (bridge.onNavigation()) releasePage()
// A window a page opened takes its first real page as its home ("scope").
if (startUrl == "about:blank" && url.startsWith("http")) startUrl = url
// Re-arm favicon capture when the host changes, so a same-host in-page nav doesn't re-send.
@@ -765,6 +828,9 @@ class NappletBrowserActivity : ComponentActivity() {
Log.w(TAG) { "Renderer gone (crashed=${detail.didCrash()}); offering a reload of $lastUrl" }
exitFullscreen()
destroyWebView()
// The page died with its renderer: nothing is left to receive its replies or pushes.
releasePage()
bridge.clear()
showCrashView(lastUrl)
return true
}
@@ -886,12 +952,12 @@ class NappletBrowserActivity : ComponentActivity() {
/** Loads a user-typed address from "Edit address", forcing Tor for `.onion` when available. */
private fun loadAddress(text: String) {
val resolved = OmniboxInput.resolve(text) ?: return
if (resolved.forceTor && proxyPort > 0 && !useTor) {
if (resolved.forceTor && !useTor) {
useTor = true
applyWebViewProxy(proxyPort)
updateChromeState { copy(torOn = true) }
updateChromeState { copy(torOn = true, torForced = false) }
}
webView?.loadUrl(resolved.url)
// An onion must not leave before the Tor route it just claimed is in place.
claimRoute { webView?.loadUrl(resolved.url) }
}
// ---- bridge: page <-> native (mirror of NappletBrowserService.onBridgeMessage) ----
@@ -904,7 +970,8 @@ class NappletBrowserActivity : ComponentActivity() {
replyProxy: JavaScriptReplyProxy,
) {
if (!isMainFrame) return
bridgeReplyProxy = replyProxy
// A new document replaced the page: whatever the old one had open with the broker is dead.
if (bridge.onMessage(replyProxy)) releasePage()
val raw = message.data ?: return
val envelope = parseJsonObjectOrNull(raw) ?: return
@@ -930,17 +997,40 @@ class NappletBrowserActivity : ComponentActivity() {
}
}
val id = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${fireSeq++}" }
val pageId = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${fireSeq++}" }
val id = bridge.brokerIdFor(pageId)
// A relay subscription is named by the page, and its pushes (decrypted events included) come back
// under that name: stamp this document on it so the next page can never receive them.
val outgoing = bridge.stampSubscription(envelope)?.toString() ?: raw
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_PAYLOAD, outgoing)
}
}
// In the background: a sign / encrypt / decrypt waits until the user is back on this window.
if (!resumed && NappletActingRequests.actsForUser(runCatching { NappletProtocolJson.readType(raw) }.getOrNull())) {
val refused = heldWhileAway.hold(origin to msg)
if (refused != null) {
bridge.failRequest(refused.second, NappletHeldRequests.TOO_MANY)
} else {
// Settle it with an error if nobody comes back for it, so the page isn't left waiting forever.
heartbeatHandler.postDelayed(expireHeld, NappletHeldRequests.MAX_AGE_MS)
}
return
}
dispatchToBroker(origin, msg)
}
/** Sends [msg] with [origin]'s launch token, minting the token first if the origin has none yet. */
private fun dispatchToBroker(
origin: String,
msg: Message,
) {
val token = originTokens[origin]
if (token != null) {
msg.data.putString(NappletIpc.KEY_LAUNCH_TOKEN, token)
@@ -1016,14 +1106,61 @@ class NappletBrowserActivity : ComponentActivity() {
private fun requestBrowserToken(origin: String) {
if (!mintInFlight.add(origin)) return
// The broker may never answer (it died mid-mint): fail the origin's queued calls rather than let the
// page wait forever.
Handler(Looper.getMainLooper()).postDelayed({
if (!isDestroyed && origin in mintInFlight) failMint(origin, MINT_TIMED_OUT)
}, NappletIpc.MINT_TIMEOUT_MS)
val msg =
Message.obtain(null, NappletIpc.MSG_MINT_BROWSER_TOKEN).apply {
replyTo = replyMessenger
data = Bundle().apply { putString(NappletIpc.KEY_BROWSER_ORIGIN, origin) }
data =
Bundle().apply {
putString(NappletIpc.KEY_BROWSER_ORIGIN, origin)
putString(NappletIpc.KEY_WEBVIEW_PROFILE, webViewProfile)
}
}
queueToBroker(msg)
}
/**
* The page is gone (navigated away, or its renderer died): drop its requests still waiting for a token
* or the broker, and have the broker close the live relay / inc subscriptions it opened — otherwise
* their events would keep streaming into whatever page comes next. Unlike [releaseFromBroker] this keeps
* the surface's foreground lease: the activity itself is still up.
*/
private fun releasePage() {
pendingByOrigin.clear()
heldWhileAway.clear()
// A mint the broker never answered (none is sent while logged out) would otherwise block the
// origin for good; the next page asks again.
mintInFlight.clear()
pendingBrokerRequests.removeAll { it.what == NappletIpc.MSG_REQUEST }
val broker = brokerMessenger ?: return
runCatching { broker.send(Message.obtain(null, NappletIpc.MSG_RELEASE_CLIENT).apply { replyTo = replyMessenger }) }
// The release forgets this window's attendance along with the rest; the next page is watched the same.
if (resumed) reportAttended()
}
/** Tells the broker whether this window is being looked at, which gates decrypting its relay reads. */
private fun reportAttended() {
queueToBroker(
Message.obtain(null, NappletIpc.MSG_SET_ATTENDED).apply {
replyTo = replyMessenger
data = Bundle().apply { putBoolean(NappletIpc.KEY_ATTENDED, resumed) }
},
)
}
/** A token for [origin] won't come: answer each call queued behind it with a failure, and allow a retry. */
private fun failMint(
origin: String,
reason: String,
) {
mintInFlight.remove(origin)
pendingByOrigin.remove(origin)?.forEach { queued -> bridge.failRequest(queued, reason) }
}
/** Sends now when the broker is bound, else queues until it is. */
private fun queueToBroker(msg: Message) {
if (brokerMessenger != null) sendToBroker(msg) else pendingBrokerRequests.add(msg)
@@ -1040,15 +1177,25 @@ class NappletBrowserActivity : ComponentActivity() {
private fun onBrokerReply(msg: Message): Boolean {
val data = msg.data ?: return true
when (msg.what) {
NappletIpc.MSG_TOKEN_UNKNOWN -> {
// The broker no longer knows this token (evicted): forget it so the origin re-mints.
val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) ?: return true
originTokens.values.removeAll { it == token }
}
NappletIpc.MSG_RESPONSE -> {
val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true
val brokerId = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
val result = (parseJsonObjectOrNull(payload) ?: JsonObject(emptyMap())).withString("id", id)
bridgeReplyProxy?.postMessage(result.toString())
// Null when the page that asked has been navigated away from: drop it rather than hand
// one site's answer (a signature, a decryption) to the next.
val (pageId, proxy) = bridge.resolve(brokerId) ?: return true
val result = (parseJsonObjectOrNull(payload) ?: JsonObject(emptyMap())).withString("id", pageId)
runCatching { proxy.postMessage(result.toString()) }
}
NappletIpc.MSG_PUSH -> {
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
bridgeReplyProxy?.postMessage(payload)
// Dropped when it is for a subscription a replaced document opened.
val push = parseJsonObjectOrNull(payload)?.let { bridge.resolvePush(it) } ?: return true
runCatching { bridge.currentProxy?.postMessage(push.toString()) }
}
NappletIpc.MSG_WEB_FAVORITE_STATE -> {
val url = data.getString(NappletIpc.KEY_FAVORITE_URL) ?: return true
@@ -1065,7 +1212,12 @@ class NappletBrowserActivity : ComponentActivity() {
}
NappletIpc.MSG_BROWSER_TOKEN -> {
val origin = data.getString(NappletIpc.KEY_BROWSER_ORIGIN) ?: return true
val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) ?: return true
val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN)
if (token == null) {
// Refused (no account signed in): the page's calls fail now instead of hanging.
failMint(origin, NOT_SIGNED_IN)
return true
}
originTokens[origin] = token
mintInFlight.remove(origin)
pendingByOrigin.remove(origin)?.forEach { queued ->
@@ -1361,29 +1513,38 @@ class NappletBrowserActivity : ComponentActivity() {
// ---- network ----
/** Whether the process route is Tor right now, whatever this page asked for. */
private var routedOverTor = false
/**
* Routes WebView traffic through the Tor SOCKS proxy when [port] > 0, else clears the override.
* Process-global (this `:napplet` process hosts only sandbox WebViews) and best-effort.
* Files this page's Tor / open-web choice with the process-wide [WebViewProxyPolicy] (the override is
* shared by every WebView in `:napplet`, so no surface sets it directly); [onReady] runs once the shared
* route is in effect. Fails closed: a page that wants Tor loads nothing until Tor's port is known and the
* route is really applied.
*/
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) }
private fun claimRoute(onReady: () -> Unit = {}) {
if (useTor && proxyPort <= 0) {
showRouteBlocked()
return
}
WebViewProxyPolicy.claim(
owner = this,
torPort = if (useTor) proxyPort else NappletProxyClaims.NO_PROXY,
onFailed = { showRouteBlocked() },
onReady = onReady,
)
}
private fun showRouteBlocked() {
if (!isDestroyed) Toast.makeText(this, R.string.napplet_route_blocked, Toast.LENGTH_LONG).show()
}
/** Persists the per-host Tor choice in the main process and re-applies it to the live WebView. */
private fun setNetworkMode(newUseTor: Boolean) {
useTor = newUseTor
applyWebViewProxy(if (useTor) proxyPort else -1)
webView?.reload()
updateChromeState { copy(torOn = useTor) }
// Reload only once the new route is in effect, or the reload would go out the old way.
claimRoute { webView?.reload() }
updateChromeState { copy(torOn = useTor, torForced = !useTor && routedOverTor) }
// Key the persisted choice on the host actually displayed (which may differ from startUrl after
// in-page navigation), so the preference sticks to the right site.
val host = runCatching { currentUrl().toUri().host }.getOrNull()?.takeIf { it.isNotBlank() } ?: return
@@ -1422,6 +1583,7 @@ class NappletBrowserActivity : ComponentActivity() {
url = startUrl,
startUrl = startUrl,
torOn = if (proxyPort > 0) useTor else null,
torForced = !useTor && routedOverTor,
),
isFavorite = intent.getBooleanExtra(EXTRA_IS_FAVORITE, false),
defaultBrowserName = DefaultBrowser.label(this),
@@ -1669,7 +1831,7 @@ class NappletBrowserActivity : ComponentActivity() {
crashView = null
val wv = buildWebView()
contentFrame.addView(wv, 0, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
wv.loadUrl(url)
claimRoute { if (webView === wv) wv.loadUrl(url) }
}
},
)
@@ -1722,7 +1884,8 @@ class NappletBrowserActivity : ComponentActivity() {
companion object {
private const val TAG = "NappletBrowserActivity"
private const val NOT_SIGNED_IN = "Sign in to Amethyst to use this site's Nostr features."
private const val MINT_TIMED_OUT = "Amethyst didn't answer. Reload the page to try again."
private const val ACTIVITY_CLASS = "com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity"
/** How often a resumed browser renews its foreground lease (well under the broker's 90s TTL). */
@@ -70,7 +70,9 @@ object NappletBrowserContract {
* Provider → client: the main-frame load state changed. Carries [KEY_IS_LOADING] (a navigation is in
* flight), [KEY_LOAD_FAILED] (the main frame errored), and [KEY_URL] (the page it settled on). Lets
* the main process draw a loading spinner / error overlay over the embedded surface, and recover a
* favorite whose session came up on a blank page (re-navigate to its real URL).
* favorite whose session came up on a blank page (re-navigate to its real URL). [KEY_RENDERER_GONE]
* marks a failure caused by the WebView renderer dying: the tab has no WebView left, so the client
* must [MSG_RELOAD] (which rebuilds it on the page it was showing).
*/
const val MSG_LOAD_STATE = 10
@@ -200,6 +202,45 @@ object NappletBrowserContract {
/** Provider → client: withdraw the [MSG_DOWNLOAD_CONSENT] card [KEY_DOWNLOAD_ID] (its tab closed). */
const val MSG_DOWNLOAD_CANCEL = 36
/**
* Client → provider: the tab left the screen (parked off-screen by the tab layer). The page's WebView is
* paused — animations, media and geolocation stop — so warm tabs in the background don't keep burning
* CPU and battery. Mirrors [NappletEmbedContract.MSG_PAUSE] for napplets.
*/
const val MSG_PAUSE = 39
/** Client → provider: the tab is the visible one again; resume its WebView. */
const val MSG_RESUME = 40
/**
* Client → provider: the tab was torn down (evicted, or rebuilt for a theme/account change). Drops the
* session and its WebView even if the surface never opened — a session created for a view that was
* disposed before it attached would otherwise sit in the provider forever, pinning its client.
*/
const val MSG_CLOSE_SESSION = 41
/**
* Client → provider: whether the user is looking at this tab ([KEY_ENABLED]) — it's the visible tab AND
* the app is on screen. While not, the provider holds the page's requests that act for the user or use
* their key (NIP-07 sign / encrypt / decrypt) and sends them once the user is back, so a parked or
* backgrounded site can't sign — even with "allow always" — while nobody is watching. Reads still flow.
*/
const val MSG_SET_ATTENDED = 37
/**
* Provider → client: whether the process's pages currently go through Tor ([KEY_USE_TOR]). Sent on every
* change. Tor always wins process-wide, so a tab set to the open web can still be on Tor because another
* open page needs it — the client shows why.
*/
const val MSG_ROUTE = 38
/**
* On [MSG_LOAD_STATE]: the page was not loaded because its network route can't be honored (Tor wanted but
* not running yet, this WebView can't proxy, or applying the proxy failed). Nothing went out; the client
* shows the error + Retry even over a page that loaded before.
*/
const val KEY_ROUTE_BLOCKED = "routeBlocked"
const val KEY_CAN_GO_FORWARD = "canGoForward"
const val KEY_FIND_QUERY = "findQuery"
const val KEY_FIND_FORWARD = "findForward"
@@ -266,6 +307,7 @@ object NappletBrowserContract {
const val KEY_IS_LOADING = "isLoading"
const val KEY_LOAD_FAILED = "loadFailed"
const val KEY_RENDERER_GONE = "rendererGone"
const val KEY_CONSOLE_LEVEL = "consoleLevel"
const val KEY_CONSOLE_MESSAGE = "consoleMessage"
@@ -26,7 +26,6 @@ import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.net.Uri
import android.os.Build
@@ -53,28 +52,28 @@ import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import androidx.annotation.RequiresApi
import androidx.core.graphics.createBitmap
import androidx.core.graphics.scale
import androidx.core.net.toUri
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.browser.BrowserChrome
import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
import com.vitorpamplona.amethyst.commons.browser.DownloadCooldown
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.napplet.NappletActingRequests
import com.vitorpamplona.amethyst.commons.napplet.NappletBridgeDocuments
import com.vitorpamplona.amethyst.commons.napplet.NappletHeldRequests
import com.vitorpamplona.amethyst.commons.napplet.NappletProxyClaims
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
import com.vitorpamplona.amethyst.commons.util.stringOrNull
import com.vitorpamplona.amethyst.commons.util.withString
import com.vitorpamplona.quartz.utils.Log
import kotlinx.serialization.json.JsonObject
import java.io.ByteArrayOutputStream
import java.util.concurrent.Executor
/**
* Provider for the **embedded** in-app browser. Runs in the keyless `:napplet` process: it hosts the
@@ -103,7 +102,7 @@ class NappletBrowserService : Service() {
val sessionId: String,
var clientMessenger: Messenger?,
val url: String,
val proxyPort: Int,
var proxyPort: Int,
var useTor: Boolean,
val bgColor: Int,
val themeType: String,
@@ -112,6 +111,19 @@ class NappletBrowserService : Service() {
) {
var webView: WebView? = null
// The page the renderer was showing when it died, so the rebuild lands back where the user was.
var recoverUrl: String? = null
// The client's last pause/resume. A parked tab can be paused before its WebView exists (the WebView
// is only built when the surface opens), so the flag is applied to every WebView built for the tab.
var paused = false
// Whether the user is looking at this tab (see NappletBrowserContract.MSG_SET_ATTENDED), and the page's
// requests that act for the user held while they aren't: (origin, request), sent when they're back.
// Unattended until the client says otherwise: it sends its state right after every create.
var attended = false
val heldWhileAway = NappletHeldRequests<Pair<String, Message>>(SystemClock::elapsedRealtime)
// The session's root view (holds the WebView, and the page's fullscreen view when it has one).
var container: FrameLayout? = null
var customView: View? = null
@@ -125,7 +137,9 @@ class NappletBrowserService : Service() {
var textZoom = BrowserChrome.DEFAULT_TEXT_ZOOM
var desktopSite = false
var bridgeReplyProxy: JavaScriptReplyProxy? = null
// The page on screen's bridge reply proxy, and which document each broker reply belongs to: a
// reply for a page the tab has navigated away from must never land in the next one.
val bridge = NappletBridgeDocuments<JavaScriptReplyProxy>()
var fireSeq = 0
// The in-flight `<input type="file">` pick for this surface. The picker itself runs in the main
@@ -153,6 +167,17 @@ class NappletBrowserService : Service() {
var preparingDownload = false
val replyMessenger = Messenger(Handler(Looper.getMainLooper()) { onBrokerReply(this, it) })
/**
* Sends [msg] to this tab's client stamped with the session it belongs to, so the client can drop what
* a session it has since replaced still had in flight (a late file-chooser request, a stale
* SESSION_READY that would re-arm its view with a dead adapter). Returns whether it was delivered.
*/
fun toClient(msg: Message): Boolean {
val client = clientMessenger ?: return false
msg.data.putString(NappletBrowserContract.KEY_SESSION_ID, sessionId)
return runCatching { client.send(msg) }.isSuccess
}
}
private val tabs = mutableMapOf<String, BrowserTab>()
@@ -188,6 +213,8 @@ class NappletBrowserService : Service() {
brokerMessenger = Messenger(service)
pendingBrokerRequests.forEach { sendToBroker(it) }
pendingBrokerRequests.clear()
// A broker that restarted knows nothing about who is watching.
tabs.values.forEach { if (it.attended) reportAttended(it) }
}
override fun onServiceDisconnected(name: ComponentName?) {
@@ -198,19 +225,33 @@ class NappletBrowserService : Service() {
override fun onBind(intent: Intent?): IBinder = incoming.binder
override fun onDestroy() {
// Release before unbinding, while the broker can still hear it.
tabs.values.forEach {
it.fileChooser.cancel()
cancelPending(it)
releasePage(it, closing = true)
WebViewProxyPolicy.release(it)
it.webView?.destroy()
}
tabs.clear()
if (brokerBound) {
runCatching { unbindService(brokerConnection) }
brokerBound = false
}
tabs.values.forEach {
it.fileChooser.cancel()
cancelPending(it)
it.webView?.destroy()
}
tabs.clear()
super.onDestroy()
}
/** The client re-reads Tor's port on every load it asks for: Tor may have come up (or moved) since the tab was made. */
private fun refreshProxyPort(
tab: BrowserTab,
msg: Message,
) {
msg.data
?.getInt(NappletBrowserContract.KEY_PROXY_PORT, 0)
?.takeIf { it != 0 }
?.let { tab.proxyPort = it }
}
private fun tabFor(msg: Message): BrowserTab? = msg.data?.getString(NappletBrowserContract.KEY_SESSION_ID)?.let { tabs[it] }
private fun onClientMessage(msg: Message): Boolean {
@@ -218,6 +259,9 @@ class NappletBrowserService : Service() {
NappletBrowserContract.MSG_CREATE_SESSION -> {
val data = msg.data ?: return true
val sessionId = data.getString(NappletBrowserContract.KEY_SESSION_ID) ?: return true
// A re-sent create for an id that is still live (a client that lost track of it) must not
// strand the old tab's WebView, broker state and proxy claim with nothing left to close them.
tabs[sessionId]?.let(::closeTab)
val tab =
BrowserTab(
sessionId = sessionId,
@@ -239,10 +283,39 @@ class NappletBrowserService : Service() {
}
NappletBrowserContract.MSG_NAVIGATE -> {
val tab = tabFor(msg) ?: return true
refreshProxyPort(tab, msg)
val url = normalizeUrl(msg.data?.getString(NappletBrowserContract.KEY_URL).orEmpty())
// A renderer crash destroyed this tab's WebView; the user's retry builds a fresh one.
if (tab.webView == null) rebuildWebView(tab, url) else tab.webView?.loadUrl(url)
val wv = tab.webView
if (wv == null) {
rebuildWebView(tab, url)
} else {
// Right after a Tor toggle the new route may still be applying; don't let this load race it
// (nor go out at all when Tor is wanted but unavailable).
claimRoute(tab) { if (tab.webView === wv) wv.loadUrl(url) }
}
}
NappletBrowserContract.MSG_CLOSE_SESSION -> tabFor(msg)?.let(::closeTab)
NappletBrowserContract.MSG_SET_ATTENDED -> {
val tab = tabFor(msg) ?: return true
tab.attended = msg.data?.getBoolean(NappletBrowserContract.KEY_ENABLED, false) ?: false
reportAttended(tab)
if (tab.attended) {
val held = tab.heldWhileAway.drain()
held.fail.forEach { (_, request) -> tab.bridge.failRequest(request, NappletHeldRequests.EXPIRED) }
held.send.forEach { (origin, request) -> dispatchToBroker(tab, origin, request) }
}
}
NappletBrowserContract.MSG_PAUSE ->
tabFor(msg)?.let {
it.paused = true
it.webView?.onPause()
}
NappletBrowserContract.MSG_RESUME ->
tabFor(msg)?.let {
it.paused = false
it.webView?.onResume()
}
NappletBrowserContract.MSG_FORWARD -> tabFor(msg)?.webView?.let { if (it.canGoForward()) it.goForward() }
NappletBrowserContract.MSG_STOP -> tabFor(msg)?.webView?.stopLoading()
NappletBrowserContract.MSG_FIND -> {
@@ -321,21 +394,25 @@ class NappletBrowserService : Service() {
if (allowed) pending.offer.save(this)
}
NappletBrowserContract.MSG_EXIT_FULLSCREEN -> tabFor(msg)?.let { exitFullscreen(it) }
NappletBrowserContract.MSG_RELOAD -> tabFor(msg)?.webView?.reload()
NappletBrowserContract.MSG_RELOAD -> {
val tab = tabFor(msg) ?: return true
refreshProxyPort(tab, msg)
// A renderer death destroyed this tab's WebView: rebuild it on the page it was showing.
if (tab.webView == null) rebuildWebView(tab, tab.recoverUrl ?: tab.url) else claimRoute(tab) { tab.webView?.reload() }
}
NappletBrowserContract.MSG_BACK -> tabFor(msg)?.webView?.let { if (it.canGoBack()) it.goBack() }
NappletBrowserContract.MSG_IME_OP -> {
val tab = tabFor(msg) ?: return true
val payload = msg.data?.getString(NappletBrowserContract.KEY_IME_PAYLOAD) ?: return true
tab.bridgeReplyProxy?.postMessage(payload)
runCatching { tab.bridge.currentProxy?.postMessage(payload) }
}
NappletBrowserContract.MSG_SET_TOR -> {
val tab = tabFor(msg) ?: return true
tab.useTor = msg.data?.getBoolean(NappletBrowserContract.KEY_USE_TOR, false) ?: false
// Reload only after the proxy override actually applies — setProxyOverride is async, so
// reloading immediately would re-fetch through the old route. NB: the override is
// process-global (Android has no per-WebView proxy), so it affects every tab; we only
// reload the one the user toggled.
applyWebViewProxy(if (tab.useTor) tab.proxyPort else -1) { tab.webView?.reload() }
refreshProxyPort(tab, msg)
// Reload only after the route actually applies — the override is async, so reloading
// immediately would re-fetch through the old route.
claimRoute(tab) { tab.webView?.reload() }
}
NappletBrowserContract.MSG_MAGNIFIER_REQUEST -> onMagnifierRequest(msg)
NappletBrowserContract.MSG_FILE_CHOOSER_RESULT -> {
@@ -352,48 +429,34 @@ class NappletBrowserService : Service() {
return true
}
// One reusable output bitmap per tab would be ideal, but loupe size is fixed per drag; createBitmap each
// frame is cheap next to the draw. Source rect is in view px (== surface px, the SCVH is 1:1).
// Source rect is in view px (== surface px, the SCVH is 1:1). See MagnifierCapture for the threading.
private fun onMagnifierRequest(msg: Message) {
val tab = tabFor(msg) ?: return
val wv = tab.webView ?: return
val data = msg.data ?: return
val cx = data.getFloat(NappletBrowserContract.KEY_MAG_X)
val cy = data.getFloat(NappletBrowserContract.KEY_MAG_Y)
val boxW = data.getInt(NappletBrowserContract.KEY_MAG_BOX_W, 150).coerceIn(16, 1024)
val boxH = data.getInt(NappletBrowserContract.KEY_MAG_BOX_H, 84).coerceIn(16, 1024)
val zoom = data.getFloat(NappletBrowserContract.KEY_MAG_ZOOM, 1.6f).coerceIn(1f, 4f)
val reqT = data.getLong(NappletBrowserContract.KEY_MAG_REQ_T)
val outW = (boxW * zoom).toInt().coerceAtLeast(1)
val outH = (boxH * zoom).toInt().coerceAtLeast(1)
val t0 = SystemClock.elapsedRealtimeNanos()
val bitmap = createBitmap(outW, outH)
val canvas = Canvas(bitmap)
canvas.drawColor(tab.bgColor)
// Map the source rect (centered on cx,cy in view px) into the zoomed output bitmap.
canvas.scale(zoom, zoom)
canvas.translate(-(cx - boxW / 2f), -(cy - boxH / 2f))
wv.draw(canvas)
val baos = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos)
val bytes = baos.toByteArray()
bitmap.recycle()
val captureMs = (SystemClock.elapsedRealtimeNanos() - t0) / 1_000_000.0
val reply =
Message.obtain(null, NappletBrowserContract.MSG_MAGNIFIER_FRAME).apply {
this.data =
Bundle().apply {
putByteArray(NappletBrowserContract.KEY_MAG_BYTES, bytes)
putInt(NappletBrowserContract.KEY_MAG_W, outW)
putInt(NappletBrowserContract.KEY_MAG_H, outH)
putDouble(NappletBrowserContract.KEY_MAG_CAPTURE_MS, captureMs)
putLong(NappletBrowserContract.KEY_MAG_REQ_T, reqT)
}
}
runCatching { tab.clientMessenger?.send(reply) }
MagnifierCapture.capture(
webView = wv,
bgColor = tab.bgColor,
cx = data.getFloat(NappletBrowserContract.KEY_MAG_X),
cy = data.getFloat(NappletBrowserContract.KEY_MAG_Y),
boxW = data.getInt(NappletBrowserContract.KEY_MAG_BOX_W, 150).coerceIn(16, 1024),
boxH = data.getInt(NappletBrowserContract.KEY_MAG_BOX_H, 84).coerceIn(16, 1024),
zoom = data.getFloat(NappletBrowserContract.KEY_MAG_ZOOM, 1.6f).coerceIn(1f, 4f),
) { bytes, outW, outH, captureMs ->
val reply =
Message.obtain(null, NappletBrowserContract.MSG_MAGNIFIER_FRAME).apply {
this.data =
Bundle().apply {
putByteArray(NappletBrowserContract.KEY_MAG_BYTES, bytes)
putInt(NappletBrowserContract.KEY_MAG_W, outW)
putInt(NappletBrowserContract.KEY_MAG_H, outH)
putDouble(NappletBrowserContract.KEY_MAG_CAPTURE_MS, captureMs)
putLong(NappletBrowserContract.KEY_MAG_REQ_T, reqT)
}
}
tab.toClient(reply)
}
}
/** Builds the SandboxedUiAdapter for [tab] and ships its cross-process handle (coreLibInfo) to the client. */
@@ -405,7 +468,7 @@ class NappletBrowserService : Service() {
Message.obtain(null, NappletBrowserContract.MSG_SESSION_READY).apply {
data = Bundle().apply { putBundle(NappletBrowserContract.KEY_CORE_LIB_INFO, coreLibInfo) }
}
runCatching { tab.clientMessenger?.send(reply) }
tab.toClient(reply)
}
/**
@@ -421,8 +484,56 @@ class NappletBrowserService : Service() {
// The session may have been closed between MSG_CREATE_SESSION and this posted call — fail rather
// than build a WebView that no tab tracks (it would leak).
val tab = tabs[sessionId] ?: error("No browser tab for session $sessionId")
// A session re-opened on this tab (the client's view detached and re-attached) before the old one's
// close landed: that session's WebView is still here. Destroy it now — its close will be ignored
// (see onSessionClosed), and overwriting it would leak it.
tab.webView?.let { stale ->
(stale.parent as? ViewGroup)?.removeView(stale)
stale.destroy()
tab.webView = null
}
tab.container = container
return buildTabWebView(context, tab).also { it.loadUrl(tab.url) }
val wv = buildTabWebView(context, tab)
claimRoute(tab) { if (tab.webView === wv) wv.loadUrl(tab.url) }
return wv
}
/**
* Files [tab]'s Tor / open-web choice with the process-wide [WebViewProxyPolicy] and runs [onReady] (the
* load) once the shared route is in effect. Fails closed: a tab that wants Tor while Tor has no port yet
* doesn't claim or load at all, and a route that can't be applied blocks the load too — either way the
* client hears [NappletBrowserContract.KEY_ROUTE_BLOCKED] and offers Retry. Also keeps the client told
* which route is really in effect ([NappletBrowserContract.MSG_ROUTE]).
*/
private fun claimRoute(
tab: BrowserTab,
onReady: () -> Unit = {},
) {
WebViewProxyPolicy.observeRoute(tab) { usesTor ->
if (tabs[tab.sessionId] === tab) sendToClient(tab, NappletBrowserContract.MSG_ROUTE) { putBoolean(NappletBrowserContract.KEY_USE_TOR, usesTor) }
}
if (tab.useTor && tab.proxyPort <= 0) {
reportRouteBlocked(tab)
return
}
WebViewProxyPolicy.claim(
owner = tab,
torPort = if (tab.useTor) tab.proxyPort else NappletProxyClaims.NO_PROXY,
onFailed = { reportRouteBlocked(tab) },
onReady = onReady,
)
}
/** The page wasn't loaded because its route can't be honored: tell the client, which shows the error. */
private fun reportRouteBlocked(tab: BrowserTab) {
if (tabs[tab.sessionId] !== tab) return
tab.loadFailed = true
sendToClient(tab, NappletBrowserContract.MSG_LOAD_STATE) {
putBoolean(NappletBrowserContract.KEY_IS_LOADING, false)
putBoolean(NappletBrowserContract.KEY_LOAD_FAILED, true)
putBoolean(NappletBrowserContract.KEY_ROUTE_BLOCKED, true)
putString(NappletBrowserContract.KEY_URL, tab.webView?.url ?: tab.url)
}
}
/** Builds [tab]'s WebView with every client, bridge and script wired, without loading anything. */
@@ -439,7 +550,6 @@ class NappletBrowserService : Service() {
// Theme the pre-load background so a blank/loading page shows Amethyst's background, not white.
wv.setBackgroundColor(tab.bgColor)
wv.dropSystemBarInsets()
applyWebViewProxy(if (tab.useTor) tab.proxyPort else -1)
WebViewCompat.addWebMessageListener(wv, NappletWebContract.BRIDGE_NAME, setOf("*")) { view, message, sourceOrigin, isMainFrame, replyProxy ->
onBridgeMessage(tab, view, message, sourceOrigin, isMainFrame, replyProxy)
}
@@ -450,6 +560,7 @@ class NappletBrowserService : Service() {
BrowserWebTools.setTextZoom(wv, tab.textZoom)
if (tab.desktopSite) BrowserWebTools.setDesktopMode(wv, true)
tab.webView = wv
if (tab.paused) wv.onPause()
return wv
}
@@ -461,19 +572,33 @@ class NappletBrowserService : Service() {
val container = tab.container ?: return
val wv = buildTabWebView(container.context, tab)
container.addView(wv, 0, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
wv.loadUrl(url)
claimRoute(tab) { if (tab.webView === wv) wv.loadUrl(url) }
}
/** A session closed: drop the tab and destroy its own WebView (never a sibling's). */
fun onSessionClosed(sessionId: String) {
val tab = tabs.remove(sessionId) ?: return
tab.bridgeReplyProxy = null
fun onSessionClosed(
sessionId: String,
container: FrameLayout,
) {
// Only the session that currently owns the tab may close it. A late close from a session that was
// already replaced by a re-open would otherwise reap the live one — its WebView destroyed under a
// client that had just been told the session opened, leaving the surface black for good.
tabs[sessionId]?.takeIf { it.container === container }?.let(::closeTab)
}
/** Drops [tab] and everything it holds (its WebView, broker state, proxy claim). */
private fun closeTab(tab: BrowserTab) {
// By identity: a replaced tab closing late must not take its replacement (same id) with it.
tabs.remove(tab.sessionId, tab)
WebViewProxyPolicy.release(tab)
releasePage(tab, closing = true)
tab.bridge.clear()
// Release a picker still waiting on this surface before its WebView goes away.
tab.fileChooser.cancel()
cancelPending(tab)
// An unanswered download card dies with its tab: nothing is saved, its bytes are freed, and the
// client is told so its card doesn't linger with a Save that does nothing.
pendingDownloads.entries.filter { it.value.sessionId == sessionId }.forEach { (id, _) ->
pendingDownloads.entries.filter { it.value.sessionId == tab.sessionId }.forEach { (id, _) ->
pendingDownloads.remove(id)
sendToClient(tab, NappletBrowserContract.MSG_DOWNLOAD_CANCEL) { putLong(NappletBrowserContract.KEY_DOWNLOAD_ID, id) }
}
@@ -519,9 +644,8 @@ class NappletBrowserService : Service() {
what: Int,
crossinline block: Bundle.() -> Unit,
): Boolean {
val client = tab.clientMessenger ?: return false
val message = Message.obtain(null, what).apply { data = Bundle().apply(block) }
return runCatching { client.send(message) }.isSuccess
return tab.toClient(message)
}
private fun pushFindResult(
@@ -723,10 +847,14 @@ class NappletBrowserService : Service() {
val wanted = request.resources.mapNotNull(::sitePermissionFor).toSet()
val id =
relayPermissionRequest(tab, BrowserChrome.originOf(request.origin.toString()), wanted) { granted ->
// Answered: nothing left for a cancellation to withdraw.
pendingWebPermissions.remove(request)
val resources = request.resources.filter { sitePermissionFor(it) in granted }.toTypedArray()
if (resources.isEmpty()) request.deny() else request.grant(resources)
}
if (id != null) pendingWebPermissions[request] = id
// Only track it while it is still waiting on the user — it may already have been answered inline
// (nothing to ask, or the client unreachable), and an entry kept after that would never be removed.
if (id != null && tab?.permissionRequests?.containsKey(id) == true) pendingWebPermissions[request] = id
}
override fun onPermissionRequestCanceled(request: PermissionRequest) {
@@ -796,7 +924,7 @@ class NappletBrowserService : Service() {
putString(NappletBrowserContract.KEY_FILE_CHOOSER_TITLE, params.title?.toString())
}
}
if (runCatching { client.send(msg) }.isFailure) tab.fileChooser.cancel()
if (!tab.toClient(msg)) tab.fileChooser.cancel()
return true
}
@@ -817,7 +945,7 @@ class NappletBrowserService : Service() {
putInt(NappletBrowserContract.KEY_CONSOLE_LINE, line)
}
}
runCatching { tab.clientMessenger?.send(msg) }
tab.toClient(msg)
}
/** Loads live web pages in-WebView (http/https) and hands other schemes to the system on a user tap. */
@@ -841,6 +969,9 @@ class NappletBrowserService : Service() {
) {
// A new main-frame navigation cleared any prior error, and lifts "block this page's dialogs".
tab?.loadFailed = false
// The page is being replaced: nothing it asked for (replies, subscription pushes) may reach the next
// one, even a next one that never talks to the bridge.
if (tab != null && tab.bridge.onNavigation()) releasePage(tab)
tab?.jsDialogsOnPage = 0
tab?.jsDialogsBlocked = false
// Re-arm favicon capture when the host changes, so a same-host in-page nav doesn't re-send.
@@ -879,19 +1010,26 @@ class NappletBrowserService : Service() {
/**
* The renderer died. It is shared by every WebView in `:napplet`, and an unhandled crash kills the
* whole process — every other tab included. Drop just this tab's WebView and report the load as
* failed; the tab's retry (MSG_NAVIGATE) builds a fresh WebView in the same surface.
* whole process — every other tab included. Drop just this tab's WebView and report it gone
* ([NappletBrowserContract.KEY_RENDERER_GONE]); the client's MSG_RELOAD (or a MSG_NAVIGATE) builds
* a fresh WebView in the same surface.
*/
override fun onRenderProcessGone(
view: WebView,
detail: RenderProcessGoneDetail,
): Boolean {
Log.w(TAG) { "Renderer gone (crashed=${detail.didCrash()}) for an embedded tab" }
val lastUrl = view.url
(view.parent as? ViewGroup)?.removeView(view)
view.destroy()
val tab = tab ?: return true
if (tab.webView === view) {
tab.webView = null
tab.recoverUrl = lastUrl?.takeIf { it.isNotBlank() && it != ABOUT_BLANK } ?: tab.recoverUrl
// The page died with its renderer: its broker subscriptions and pick have no one to serve.
releasePage(tab)
tab.bridge.clear()
tab.fileChooser.cancel()
tab.customView?.let { tab.container?.removeView(it) }
tab.customView = null
tab.customViewCallback = null
@@ -900,6 +1038,7 @@ class NappletBrowserService : Service() {
sendToClient(tab, NappletBrowserContract.MSG_LOAD_STATE) {
putBoolean(NappletBrowserContract.KEY_IS_LOADING, false)
putBoolean(NappletBrowserContract.KEY_LOAD_FAILED, true)
putBoolean(NappletBrowserContract.KEY_RENDERER_GONE, true)
putString(NappletBrowserContract.KEY_URL, tab.url)
}
}
@@ -922,7 +1061,7 @@ class NappletBrowserService : Service() {
putString(NappletBrowserContract.KEY_URL, view.url.orEmpty())
}
}
runCatching { tab?.clientMessenger?.send(message) }
tab?.toClient(message)
}
private fun pushUrl(
@@ -943,35 +1082,7 @@ class NappletBrowserService : Service() {
title?.let { putString(NappletBrowserContract.KEY_TITLE, it) }
}
}
runCatching { tab?.clientMessenger?.send(message) }
}
/**
* Routes WebView traffic through the Tor SOCKS proxy when [port] > 0, else clears the override.
* [onApplied] runs on the main thread once the override is in effect (the WebKit callback is async,
* so callers that reload must wait for it). Process-global (this `:napplet` process hosts only
* sandbox WebViews) and best-effort.
*/
private fun applyWebViewProxy(
port: Int,
onApplied: () -> Unit = {},
) {
if (!WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) {
onApplied()
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) { onApplied() }
} else {
ProxyController.getInstance().clearProxyOverride(executor) { onApplied() }
}
}.onFailure {
Log.w(TAG, "Failed to apply WebView proxy override", it)
onApplied()
}
tab?.toClient(message)
}
/**
@@ -987,7 +1098,8 @@ class NappletBrowserService : Service() {
replyProxy: JavaScriptReplyProxy,
) {
if (!isMainFrame) return
tab.bridgeReplyProxy = replyProxy
// A new document replaced the page: whatever the old one had open with the broker is dead.
if (tab.bridge.onMessage(replyProxy)) releasePage(tab)
val raw = message.data ?: return
val envelope = parseJsonObjectOrNull(raw) ?: return
@@ -1015,21 +1127,53 @@ class NappletBrowserService : Service() {
Message.obtain(null, NappletBrowserContract.MSG_IME_EVENT).apply {
data = Bundle().apply { putString(NappletBrowserContract.KEY_IME_PAYLOAD, raw) }
}
runCatching { tab.clientMessenger?.send(reply) }
tab.toClient(reply)
return
}
val id = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${tab.fireSeq++}" }
val pageId = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${tab.fireSeq++}" }
val id = tab.bridge.brokerIdFor(pageId)
// A relay subscription is named by the page, and its pushes (decrypted events included) come back
// under that name: stamp this document on it so the next page can never receive them.
val outgoing = tab.bridge.stampSubscription(envelope)?.toString() ?: raw
val msg =
Message.obtain(null, NappletIpc.MSG_REQUEST).apply {
replyTo = tab.replyMessenger
data =
Bundle().apply {
putString(NappletIpc.KEY_REQUEST_ID, id)
putString(NappletIpc.KEY_PAYLOAD, raw)
putString(NappletIpc.KEY_PAYLOAD, outgoing)
}
}
// Nobody is looking at this tab (it's parked, or the app is in the background): a sign / encrypt /
// decrypt waits until they are, even when "allow always" would let it through without a prompt.
if (!tab.attended && NappletActingRequests.actsForUser(runCatching { NappletProtocolJson.readType(raw) }.getOrNull())) {
val refused = tab.heldWhileAway.hold(origin to msg)
if (refused != null) {
tab.bridge.failRequest(refused.second, NappletHeldRequests.TOO_MANY)
} else {
// Settle it with an error if nobody comes back for it, so the page isn't left waiting forever.
heldExpiry.postDelayed({ expireHeld(tab) }, NappletHeldRequests.MAX_AGE_MS)
}
return
}
dispatchToBroker(tab, origin, msg)
}
private val heldExpiry = Handler(Looper.getMainLooper())
private fun expireHeld(tab: BrowserTab) {
if (tabs[tab.sessionId] !== tab) return
tab.heldWhileAway.expire().forEach { (_, request) -> tab.bridge.failRequest(request, NappletHeldRequests.EXPIRED) }
}
/** Sends [msg] with [origin]'s launch token, minting the token first if the origin has none yet. */
private fun dispatchToBroker(
tab: BrowserTab,
origin: String,
msg: Message,
) {
val token = tab.originTokens[origin]
if (token != null) {
msg.data.putString(NappletIpc.KEY_LAUNCH_TOKEN, token)
@@ -1040,6 +1184,60 @@ class NappletBrowserService : Service() {
}
}
private val mintTimeouts = Handler(Looper.getMainLooper())
/** A token for [origin] won't come: answer each call queued behind it with a failure, and allow a retry. */
private fun failMint(
tab: BrowserTab,
origin: String,
reason: String,
) {
tab.mintInFlight.remove(origin)
tab.pendingByOrigin.remove(origin)?.forEach { queued -> tab.bridge.failRequest(queued, reason) }
}
/**
* The page on [tab] is gone (navigated away, renderer died, session closed): drop its requests still
* waiting for a token or the broker, and have the broker close the live relay / inc subscriptions it
* opened — otherwise their events would keep streaming into whatever page comes next.
*
* [closing]: the whole tab is going away, so its per-origin launch tokens are given back too (a closed
* tab never re-uses them; a new session mints its own). Otherwise they'd sit in the broker's registry
* until evicted, pushing live tabs' tokens out first.
*/
private fun releasePage(
tab: BrowserTab,
closing: Boolean = false,
) {
tab.pendingByOrigin.clear()
tab.heldWhileAway.clear()
// A mint the broker never answered (none is sent while logged out) would otherwise block the
// origin for the tab's life; the next page asks again.
tab.mintInFlight.clear()
pendingBrokerRequests.removeAll { it.what == NappletIpc.MSG_REQUEST && it.replyTo == tab.replyMessenger }
val release =
Message.obtain(null, NappletIpc.MSG_RELEASE_CLIENT).apply {
replyTo = tab.replyMessenger
if (closing && tab.originTokens.isNotEmpty()) {
data = Bundle().apply { putStringArray(NappletIpc.KEY_RELEASED_TOKENS, tab.originTokens.values.toTypedArray()) }
}
}
if (closing) tab.originTokens.clear()
if (brokerMessenger != null) sendToBroker(release)
// The release forgets the tab's attendance along with the rest; the next page is watched just the same.
if (!closing && tab.attended) reportAttended(tab)
}
/** Tells the broker whether [tab] is being looked at, which gates decrypting its relay reads. */
private fun reportAttended(tab: BrowserTab) {
val msg =
Message.obtain(null, NappletIpc.MSG_SET_ATTENDED).apply {
replyTo = tab.replyMessenger
data = Bundle().apply { putBoolean(NappletIpc.KEY_ATTENDED, tab.attended) }
}
if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg)
}
/** `scheme://host[:port]` of the WebView-reported origin, or null when it has no usable one. */
private fun trustedOrigin(sourceOrigin: Uri): String? {
val scheme = sourceOrigin.scheme ?: return null
@@ -1117,10 +1315,19 @@ class NappletBrowserService : Service() {
origin: String,
) {
if (!tab.mintInFlight.add(origin)) return
// The broker may never answer (it died mid-mint): fail the origin's queued calls rather than let the
// page wait forever.
mintTimeouts.postDelayed({
if (tabs[tab.sessionId] === tab && origin in tab.mintInFlight) failMint(tab, origin, MINT_TIMED_OUT)
}, NappletIpc.MINT_TIMEOUT_MS)
val msg =
Message.obtain(null, NappletIpc.MSG_MINT_BROWSER_TOKEN).apply {
replyTo = tab.replyMessenger
data = Bundle().apply { putString(NappletIpc.KEY_BROWSER_ORIGIN, origin) }
data =
Bundle().apply {
putString(NappletIpc.KEY_BROWSER_ORIGIN, origin)
putString(NappletIpc.KEY_WEBVIEW_PROFILE, tab.webViewProfile)
}
}
if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg)
}
@@ -1198,18 +1405,33 @@ class NappletBrowserService : Service() {
val data = msg.data ?: return true
when (msg.what) {
NappletIpc.MSG_RESPONSE -> {
val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true
val brokerId = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
val result = (parseJsonObjectOrNull(payload) ?: JsonObject(emptyMap())).withString("id", id)
runCatching { tab.bridgeReplyProxy?.postMessage(result.toString()) }
// Null when the page that asked has been navigated away from: drop it rather than hand
// one site's answer (a signature, a decryption) to the next.
val (pageId, proxy) = tab.bridge.resolve(brokerId) ?: return true
val result = (parseJsonObjectOrNull(payload) ?: JsonObject(emptyMap())).withString("id", pageId)
runCatching { proxy.postMessage(result.toString()) }
}
NappletIpc.MSG_PUSH -> {
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
runCatching { tab.bridgeReplyProxy?.postMessage(payload) }
// Dropped when it is for a subscription a replaced document opened.
val push = parseJsonObjectOrNull(payload)?.let { tab.bridge.resolvePush(it) } ?: return true
runCatching { tab.bridge.currentProxy?.postMessage(push.toString()) }
}
NappletIpc.MSG_TOKEN_UNKNOWN -> {
// The broker no longer knows this token (evicted): forget it so the origin re-mints.
val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) ?: return true
tab.originTokens.values.removeAll { it == token }
}
NappletIpc.MSG_BROWSER_TOKEN -> {
val origin = data.getString(NappletIpc.KEY_BROWSER_ORIGIN) ?: return true
val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) ?: return true
val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN)
if (token == null) {
// Refused (no account signed in): the page's calls fail now instead of hanging.
failMint(tab, origin, NOT_SIGNED_IN)
return true
}
tab.originTokens[origin] = token
tab.mintInFlight.remove(origin)
tab.pendingByOrigin.remove(origin)?.forEach { queued ->
@@ -1229,6 +1451,8 @@ class NappletBrowserService : Service() {
private companion object {
private const val TAG = "NappletBrowserService"
private const val NOT_SIGNED_IN = "Sign in to Amethyst to use this site's Nostr features."
private const val MINT_TIMED_OUT = "Amethyst didn't answer. Reload the page to try again."
private const val ABOUT_BLANK = "about:blank"
/** Max favicon edge (px) before sending over IPC — keeps the PNG tiny, well under the Binder limit. */
@@ -111,8 +111,13 @@ private class BrowserSession(
}
override fun close() {
// The library may call close() off the main thread; WebView.destroy() (and the tabs mutation)
// must run on the main thread.
Handler(Looper.getMainLooper()).post { service.onSessionClosed(sessionId) }
// WebView.destroy() (and the tabs mutation) must run on the main thread. The library usually calls
// this there already; post only when it doesn't. The container identifies THIS session, so a close
// that lands after the tab was re-opened can't tear down its successor.
if (Looper.myLooper() == Looper.getMainLooper()) {
service.onSessionClosed(sessionId, container)
} else {
Handler(Looper.getMainLooper()).post { service.onSessionClosed(sessionId, container) }
}
}
}
@@ -81,6 +81,8 @@ object NappletEmbedContract {
* Provider → client: the main-frame load state changed. Carries [KEY_IS_LOADING] (a load is in
* flight) and [KEY_LOAD_FAILED] (the main frame errored). Lets the main process draw a loading
* spinner / error+retry overlay over the embedded surface instead of a bare black/white void.
* [KEY_RENDERER_GONE] marks a failure caused by the WebView renderer dying: the tab has no WebView
* left, so the client must [MSG_RELOAD] (which rebuilds it) — the page on screen is gone, not failed.
*/
const val MSG_LOAD_STATE = 15
@@ -133,6 +135,29 @@ object NappletEmbedContract {
*/
const val MSG_CONSOLE_LOG = 24
/**
* Client → provider: the tab was torn down (evicted, or rebuilt for a theme/account change). Drops the
* session and its WebView even if the surface never opened — a session created for a view that was
* disposed before it attached would otherwise sit in the provider forever, pinning its client.
*/
const val MSG_CLOSE_SESSION = 25
/**
* Client → provider: whether the user is looking at this napplet ([KEY_ATTENDED]) — it's the visible tab
* AND the app is on screen. While not, the provider holds the napplet's requests that act for the user
* (publish, pay, upload, notify, `inc.emit`) and sends them once the user is back. Separate from
* [MSG_PAUSE]: the page itself is only paused a while after the app leaves the screen, but nothing may
* act on the user's behalf the moment they stop watching.
*/
const val MSG_SET_ATTENDED = 26
/**
* Provider → client: whether the process's pages currently go through Tor ([KEY_ROUTE_TOR]). Sent on every
* change for an nSite (it has off-origin traffic of its own). Tor always wins process-wide, so an nSite
* set to the open web can still be on Tor because another open page needs it — the client shows why.
*/
const val MSG_ROUTE = 27
const val KEY_FIND_QUERY = "findQuery"
const val KEY_FIND_FORWARD = "findForward"
const val KEY_FIND_ACTIVE = "findActive"
@@ -171,6 +196,16 @@ object NappletEmbedContract {
const val KEY_CAN_GO_BACK = "canGoBack"
const val KEY_IS_LOADING = "isLoading"
const val KEY_LOAD_FAILED = "loadFailed"
const val KEY_RENDERER_GONE = "rendererGone"
const val KEY_ROUTE_TOR = "routeTor"
/**
* On [MSG_LOAD_STATE]: the applet was not loaded because its network route can't be honored (Tor wanted
* but not running yet, this WebView can't proxy, or applying the proxy failed). Nothing went out; the
* client offers Retry, whose [MSG_RELOAD] carries the current Tor port
* ([NappletHostContract.EXTRA_PROXY_PORT]).
*/
const val KEY_ROUTE_BLOCKED = "routeBlocked"
const val KEY_NOTICE = "notice"
const val KEY_IME_PAYLOAD = "imePayload"
@@ -180,6 +215,7 @@ object NappletEmbedContract {
* so this scopes a control to the right surface and routes state/notices/IME back to the right tab.
*/
const val KEY_SESSION_ID = "sessionId"
const val KEY_ATTENDED = "attended"
const val NOTICE_PUBLISHED = "published"
const val NOTICE_UPLOADED = "uploaded"
@@ -33,6 +33,7 @@ import android.os.IBinder
import android.os.Looper
import android.os.Message
import android.os.Messenger
import android.os.SystemClock
import android.util.TypedValue
import android.view.Gravity
import android.view.KeyEvent
@@ -58,8 +59,6 @@ import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.core.content.ContextCompat
import androidx.webkit.JavaScriptReplyProxy
import androidx.webkit.ProxyConfig
import androidx.webkit.ProxyController
import androidx.webkit.WebMessageCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
@@ -68,6 +67,9 @@ import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
import com.vitorpamplona.amethyst.commons.napplet.NappletActingRequests
import com.vitorpamplona.amethyst.commons.napplet.NappletHeldRequests
import com.vitorpamplona.amethyst.commons.napplet.NappletProxyClaims
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.util.booleanOrNull
@@ -89,7 +91,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import java.lang.ref.WeakReference
import java.util.concurrent.Executor
import com.vitorpamplona.amethyst.commons.R as CommonsR
/**
@@ -155,6 +156,9 @@ class NappletHostActivity : ComponentActivity() {
private var proxyPort: Int = -1
/** Whether the process route is Tor right now, whatever this nSite asked for. */
private var routedOverTor = false
// The resource edge (shell + verified blobs); built in onCreate once the manifest is parsed.
private lateinit var contentServer: NappletContentServer
@@ -233,6 +237,15 @@ class NappletHostActivity : ComponentActivity() {
// the broker binds after this surface is already resumed (bindService is async).
private var resumed = false
// Requests that act for the user (publish, pay, upload…) made while this napplet was in the background.
// Pausing the WebView doesn't stop JavaScript, so they are held here and sent on the next resume.
private val heldWhilePaused = NappletHeldRequests<Message>(SystemClock::elapsedRealtime)
private val expireHeld =
Runnable {
if (!isDestroyed) heldWhilePaused.expire().forEach { bridgeReplyProxy?.failRequest(it, NappletHeldRequests.EXPIRED) }
}
// Renews the broker's foreground lease while resumed. If this process dies, the heartbeat stops and
// the broker reaps the stale lease, so a crash can't pin the main process's network up forever.
private var foregroundHeartbeat: Job? = null
@@ -249,6 +262,7 @@ class NappletHostActivity : ComponentActivity() {
// If we're already foreground by the time the broker binds, report it now so the
// main-process resource hold is acquired for this session.
if (resumed) setBrokerForeground(true)
reportAttended()
}
override fun onServiceDisconnected(name: ComponentName?) {
@@ -265,6 +279,12 @@ class NappletHostActivity : ComponentActivity() {
finish()
return
}
// Fail closed: Tor is on but its port isn't known yet, so nothing (blobs or web traffic) may go out.
if (useTor && proxyPort <= 0) {
Toast.makeText(this, R.string.napplet_route_blocked, Toast.LENGTH_LONG).show()
finish()
return
}
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
Toast.makeText(this, getString(R.string.napplet_webview_too_old), Toast.LENGTH_LONG).show()
@@ -303,7 +323,15 @@ class NappletHostActivity : ComponentActivity() {
// Route the WebView's own (off-origin) traffic through Tor for an nSite, unless this site was
// opted out to the open web. Set process-wide before any page navigation; the shell + blobs are
// served from cache via shouldInterceptRequest, so only the site's external requests hit this.
if (profile.exposesNetwork) applyWebViewProxy(effectiveProxy)
if (profile.exposesNetwork) {
// An open-web nSite still goes through Tor while another surface needs it: say so in the chrome.
WebViewProxyPolicy.observeRoute(this) {
routedOverTor = it
chrome?.let { c -> c.ui = c.ui.copy(chrome = c.ui.chrome.copy(torForced = !useTor && it)) }
}
// Start applying now, overlapping the index probe; the load itself waits in mountWebView.
WebViewProxyPolicy.claim(this, effectiveProxy)
}
// Origin-restricted bridge: only the trusted shell page (main frame) can reach native.
WebViewCompat.addWebMessageListener(
webView,
@@ -368,10 +396,30 @@ class NappletHostActivity : ComponentActivity() {
contentFrame.addView(webView, 0, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
if (!started) {
started = true
webView.loadUrl(NappletWebContract.SHELL_URL)
// Wait for the route to be in effect: a Tor nSite's first off-origin request must not leave early,
// and must not leave at all if the route can't be applied.
if (profile.exposesNetwork) {
WebViewProxyPolicy.claim(
owner = this,
torPort = if (useTor) proxyPort else NappletProxyClaims.NO_PROXY,
onFailed = { if (!isDestroyed) Toast.makeText(this, R.string.napplet_route_blocked, Toast.LENGTH_LONG).show() },
) { if (!isDestroyed) webView.loadUrl(NappletWebContract.SHELL_URL) }
} else {
webView.loadUrl(NappletWebContract.SHELL_URL)
}
}
}
private val backgroundPauseHandler = Handler(Looper.getMainLooper())
// webView.onPause() pauses THIS WebView (animations, media, geolocation). Do NOT call pauseTimers(): it's
// process-global and freezes EVERY WebView in `:napplet`, including the embedded browser/napplet surfaces,
// which never resume.
private val backgroundPause =
Runnable {
if (!isDestroyed && this::webView.isInitialized && !webViewGone) webView.onPause()
}
override fun onResume() {
super.onResume()
if (this::webView.isInitialized && !webViewGone) {
@@ -381,21 +429,34 @@ class NappletHostActivity : ComponentActivity() {
// hold the main process resumed (Tor/relays/AUTH) while this napplet/nSite is in front, and
// keep renewing that lease so a crash here can't pin the network up forever.
resumed = true
reportAttended()
startForegroundHeartbeat()
val held = heldWhilePaused.drain()
held.fail.forEach { bridgeReplyProxy?.failRequest(it, NappletHeldRequests.EXPIRED) }
held.send.forEach { if (brokerMessenger == null) pendingRequests.add(it) else sendToBroker(it) }
}
override fun onStart() {
super.onStart()
// Back within the grace: the page was never paused.
backgroundPauseHandler.removeCallbacks(backgroundPause)
}
override fun onStop() {
// Out of sight: pause the page after the same grace the rest of the app gets
// (NappletHostContract.BACKGROUND_PAUSE_MS), so a quick trip to another app doesn't interrupt it.
// Anything that acts for the user is already held (see [resumed]).
backgroundPauseHandler.postDelayed(backgroundPause, NappletHostContract.BACKGROUND_PAUSE_MS)
super.onStop()
}
override fun onPause() {
// Foreground-only: stop the applet's JS/timers in the background so it cannot fire a
// sign/decrypt/pay request whose consent prompt would surface over (and be confused with)
// Amethyst's own UI. Requests only happen while the user is looking at this napplet.
if (this::webView.isInitialized && !webViewGone) {
// webView.onPause() pauses THIS WebView's JS/DOM (the security goal — a backgrounded napplet can't
// fire a sign/decrypt/pay request). Do NOT call pauseTimers(): it's process-global and freezes
// EVERY WebView in `:napplet`, including the embedded browser/napplet surfaces, which never resume.
webView.onPause()
}
// No longer foreground: stop renewing and let the main process resume normal background scaling.
// Foreground-only for requests: while not resumed, the applet's requests that act for the user
// (sign/publish/pay…) are held until the user is back, so their consent prompt can't surface over
// (and be confused with) Amethyst's own UI and an "allow always" napplet can't act unwatched. The page
// itself keeps running until onStop's grace runs out.
resumed = false
reportAttended()
stopForegroundHeartbeat()
setBrokerForeground(false)
super.onPause()
@@ -433,6 +494,16 @@ class NappletHostActivity : ComponentActivity() {
runCatching { broker.send(msg) }
}
/** Tells the broker whether this napplet is being looked at, which gates decrypting its relay reads. */
private fun reportAttended() {
val msg =
Message.obtain(null, NappletIpc.MSG_SET_ATTENDED).apply {
replyTo = replyMessenger
data = Bundle().apply { putBoolean(NappletIpc.KEY_ATTENDED, resumed) }
}
if (brokerMessenger == null) pendingRequests.add(msg) else sendToBroker(msg)
}
/** Reports this surface's foreground state to the broker so it can hold the main process resumed. */
private fun setBrokerForeground(foreground: Boolean) {
@@ -451,10 +522,13 @@ class NappletHostActivity : ComponentActivity() {
}
override fun onDestroy() {
backgroundPauseHandler.removeCallbacks(backgroundPause)
backgroundPauseHandler.removeCallbacks(expireHeld)
uiScope.cancel()
// Drop the broker's references to our reply Messenger BEFORE unbinding — a retained Messenger is a
// binder and would pin this Activity (and its WebView) for the life of the `:napplet` process.
releaseFromBroker()
WebViewProxyPolicy.release(this)
// unbind is in runCatching: if the index never resolved we never bound the broker.
runCatching { unbindService(brokerConnection) }
keyActions.clear()
@@ -569,25 +643,6 @@ class NappletHostActivity : ComponentActivity() {
webView.webChromeClient = NappletWebChromeClient()
}
/**
* Routes this process's WebView traffic through the Tor SOCKS proxy when [port] > 0, else clears any
* override so the site loads over the open web. Process-global (this `:napplet` process hosts only
* applet/site WebViews) and best-effort: a device whose WebView can't honor a SOCKS proxy falls back
* to direct — verified on-device, since SOCKS-over-WebView support varies by WebView version.
*/
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; everything else 404s. */
private inner class NappletWebViewClient : WebViewClient() {
override fun shouldInterceptRequest(
@@ -787,6 +842,17 @@ class NappletHostActivity : ComponentActivity() {
}
}
// In the background: an act on the user's behalf waits until they're looking at this napplet again.
if (!resumed && NappletActingRequests.actsForUser(runCatching { NappletProtocolJson.readType(raw) }.getOrNull())) {
val refused = heldWhilePaused.hold(msg)
if (refused != null) {
bridgeReplyProxy?.failRequest(refused, NappletHeldRequests.TOO_MANY)
} else {
// Settle it with an error if nobody comes back for it, so the applet isn't left waiting forever.
backgroundPauseHandler.postDelayed(expireHeld, NappletHeldRequests.MAX_AGE_MS)
}
return
}
val messenger = brokerMessenger
if (messenger == null) {
pendingRequests.add(msg)
@@ -940,6 +1006,7 @@ class NappletHostActivity : ComponentActivity() {
// Website-mode nSites can re-route over Tor; switching rebuilds the session, so the
// row taps through to a full relaunch rather than toggling inline.
torOn = if (profile.exposesNetwork && proxyPort > 0) useTor else null,
torForced = !useTor && routedOverTor,
canFavorite = false,
hasAccessInfo = true,
),
@@ -26,6 +26,21 @@ package com.vitorpamplona.amethyst.napplethost
* `:amethyst` depends on `:nappletHost`, never the other way around.
*/
object NappletHostContract {
/**
* How long a napplet or website page keeps running after the app leaves the screen before its WebView is
* paused — embedded tabs and the full-screen hosts alike. The same grace the rest of the app gets: relays
* disconnect 30 s after the UI stops (RelayProxyClientConnector's `WhileSubscribed(30000)`), so a quick
* trip to another app (a 2FA code, a password manager) doesn't interrupt a page, while one left behind
* stops running. Requests that act for the user are held from the first moment regardless.
*
* An upper bound, not a promise: on Android 14+ the cached-app freezer suspends `:napplet` (and its
* renderers) about 10 s after the app leaves the screen, since nothing keeps that process in the
* foreground. Measured on an API 36 emulator: frozen 12 s after Home, media stopped with it. So on those
* devices a page stops within seconds, and this pause lands (on the next unfreeze) on a page that
* already stopped.
*/
const val BACKGROUND_PAUSE_MS = 30_000L
const val EXTRA_PATHS = "napplet_paths"
const val EXTRA_HASHES = "napplet_hashes"
const val EXTRA_SERVERS = "napplet_servers"
@@ -26,7 +26,6 @@ import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.graphics.Bitmap
import android.graphics.Canvas
import android.net.Uri
import android.os.Build
import android.os.Bundle
@@ -52,17 +51,17 @@ import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import androidx.annotation.RequiresApi
import androidx.core.graphics.createBitmap
import androidx.core.net.toUri
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.browser.BrowserChrome
import com.vitorpamplona.amethyst.commons.napplet.NappletActingRequests
import com.vitorpamplona.amethyst.commons.napplet.NappletHeldRequests
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.util.booleanOrNull
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
import com.vitorpamplona.amethyst.commons.util.stringOrNull
@@ -72,8 +71,6 @@ import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlinx.serialization.json.JsonObject
import java.io.ByteArrayOutputStream
import java.util.concurrent.Executor
/**
* Provider for an **embedded** nsite/napplet tab — the in-app-tab counterpart of [NappletHostActivity].
@@ -107,7 +104,7 @@ class NappletHostService : Service() {
val launchToken: String,
val profile: HostProfile,
val useTor: Boolean,
val proxyPort: Int,
var proxyPort: Int,
val bgColor: Int,
val themeType: String,
// Opaque per-account WebView storage-profile name (see NappletWebViewProfile).
@@ -121,6 +118,22 @@ class NappletHostService : Service() {
// The session's root view; a WebView lost to a renderer crash is rebuilt inside it on retry.
var container: FrameLayout? = null
// The client's last pause/resume. A parked tab is paused before its WebView exists (the WebView is
// only built when the surface opens), so the flag is applied to every WebView built for the tab.
var paused = false
// The applet wasn't built because its route can't be honored (Tor wanted, no port): a retry rebuilds it.
var routeBlocked = false
// Whether the user is looking at this napplet (NappletEmbedContract.MSG_SET_ATTENDED). Requests that act
// for the user (publish, pay, upload…) made while they aren't — or while the page is paused — are held
// here and sent when they're back: pausing the WebView doesn't stop JavaScript. Unattended until the
// client says otherwise: it sends its state right after every create.
var attended = false
val heldWhilePaused = NappletHeldRequests<Message>(SystemClock::elapsedRealtime)
val mayAct: Boolean get() = attended && !paused
var bridgeReplyProxy: JavaScriptReplyProxy? = null
var fireSeq = 0
@@ -135,6 +148,17 @@ class NappletHostService : Service() {
// The user's text size, re-applied when a renderer crash forces a fresh WebView.
var textZoom = BrowserChrome.DEFAULT_TEXT_ZOOM
val replyMessenger = Messenger(Handler(Looper.getMainLooper()) { onBrokerReply(this, it) })
/**
* Sends [msg] to this tab's client stamped with the session it belongs to, so the client can drop what
* a session it has since replaced still had in flight (a late file-chooser request, a stale
* SESSION_READY that would re-arm its view with a dead adapter). Returns whether it was delivered.
*/
fun toClient(msg: Message): Boolean {
val client = clientMessenger ?: return false
msg.data.putString(NappletEmbedContract.KEY_SESSION_ID, sessionId)
return runCatching { client.send(msg) }.isSuccess
}
}
private val tabs = mutableMapOf<String, NappletTab>()
@@ -157,6 +181,8 @@ class NappletHostService : Service() {
brokerMessenger = Messenger(service)
pendingBrokerRequests.forEach { sendToBroker(it) }
pendingBrokerRequests.clear()
// A broker that restarted knows nothing about who is watching.
tabs.values.forEach { if (it.attended) reportAttended(it) }
}
override fun onServiceDisconnected(name: ComponentName?) {
@@ -167,16 +193,19 @@ class NappletHostService : Service() {
override fun onBind(intent: Intent?): IBinder = incoming.binder
override fun onDestroy() {
if (brokerBound) {
runCatching { unbindService(brokerConnection) }
brokerBound = false
}
// Release before unbinding, while the broker can still hear it.
tabs.values.forEach {
WebViewProxyPolicy.release(it)
releaseFromBroker(it)
it.fileChooser.cancel()
it.contentServer?.close()
it.webView?.destroy()
}
tabs.clear()
if (brokerBound) {
runCatching { unbindService(brokerConnection) }
brokerBound = false
}
super.onDestroy()
}
@@ -188,6 +217,9 @@ class NappletHostService : Service() {
when (msg.what) {
NappletEmbedContract.MSG_CREATE_SESSION -> {
val tab = buildTab(msg) ?: return true
// A re-sent create for an id that is still live must not strand the old tab's WebView, content
// server and broker state with nothing left to close them.
tabs[tab.sessionId]?.let(::closeTab)
tabs[tab.sessionId] = tab
// Bind the broker once; a re-sent MSG_CREATE_SESSION must not leak a second binding.
if (!brokerBound) {
@@ -198,9 +230,15 @@ class NappletHostService : Service() {
NappletEmbedContract.MSG_BACK -> tabFor(msg)?.webView?.let { if (it.canGoBack()) it.goBack() }
NappletEmbedContract.MSG_RELOAD -> {
val tab = tabFor(msg) ?: return true
// The client re-reads Tor's port on a retry: it may have come up (or moved) since the tab was made.
msg.data
?.getInt(NappletHostContract.EXTRA_PROXY_PORT, 0)
?.takeIf { it != 0 }
?.let { tab.proxyPort = it }
val container = tab.container
// After a renderer crash the tab has no WebView: the retry builds a fresh one.
if (tab.webView == null && container != null) {
// After a renderer crash the tab has no WebView, and a tab blocked on its route has only a blank
// placeholder: the retry builds a fresh one.
if ((tab.webView == null || tab.routeBlocked) && container != null) {
val wv = createHostWebView(container.context, tab.sessionId, container)
container.addView(wv, 0, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
} else {
@@ -210,12 +248,29 @@ class NappletHostService : Service() {
// onPause()/onResume() are per-WebView (pause/resume THIS surface's JS/DOM). Do NOT call
// pauseTimers()/resumeTimers(): they are process-global and would freeze/thaw every WebView in
// `:napplet` (the browser embed + other napplets), whose lifecycles are independent of this one.
NappletEmbedContract.MSG_PAUSE -> tabFor(msg)?.webView?.onPause()
NappletEmbedContract.MSG_RESUME -> tabFor(msg)?.webView?.onResume()
NappletEmbedContract.MSG_CLOSE_SESSION -> tabFor(msg)?.let(::closeTab)
NappletEmbedContract.MSG_PAUSE ->
tabFor(msg)?.let {
it.paused = true
it.webView?.onPause()
}
NappletEmbedContract.MSG_RESUME ->
tabFor(msg)?.let {
it.paused = false
it.webView?.onResume()
releaseHeld(it)
}
NappletEmbedContract.MSG_SET_ATTENDED ->
tabFor(msg)?.let {
it.attended = msg.data?.getBoolean(NappletEmbedContract.KEY_ATTENDED, false) ?: false
reportAttended(it)
releaseHeld(it)
}
NappletEmbedContract.MSG_IME_OP -> {
val tab = tabFor(msg) ?: return true
val payload = msg.data?.getString(NappletEmbedContract.KEY_IME_PAYLOAD) ?: return true
tab.bridgeReplyProxy?.postMessage(payload)
// The proxy can belong to a page that has already gone away; that must not crash the sandbox.
runCatching { tab.bridgeReplyProxy?.postMessage(payload) }
}
NappletEmbedContract.MSG_MAGNIFIER_REQUEST -> onMagnifierRequest(msg)
NappletEmbedContract.MSG_FIND -> {
@@ -286,41 +341,29 @@ class NappletHostService : Service() {
val tab = tabFor(msg) ?: return
val wv = tab.webView ?: return
val data = msg.data ?: return
val cx = data.getFloat(NappletEmbedContract.KEY_MAG_X)
val cy = data.getFloat(NappletEmbedContract.KEY_MAG_Y)
val boxW = data.getInt(NappletEmbedContract.KEY_MAG_BOX_W, 150).coerceIn(16, 1024)
val boxH = data.getInt(NappletEmbedContract.KEY_MAG_BOX_H, 84).coerceIn(16, 1024)
val zoom = data.getFloat(NappletEmbedContract.KEY_MAG_ZOOM, 1.6f).coerceIn(1f, 4f)
val reqT = data.getLong(NappletEmbedContract.KEY_MAG_REQ_T)
val outW = (boxW * zoom).toInt().coerceAtLeast(1)
val outH = (boxH * zoom).toInt().coerceAtLeast(1)
val t0 = SystemClock.elapsedRealtimeNanos()
val bitmap = createBitmap(outW, outH)
val canvas = Canvas(bitmap)
canvas.drawColor(tab.bgColor)
canvas.scale(zoom, zoom)
canvas.translate(-(cx - boxW / 2f), -(cy - boxH / 2f))
wv.draw(canvas)
val baos = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos)
val bytes = baos.toByteArray()
bitmap.recycle()
val captureMs = (SystemClock.elapsedRealtimeNanos() - t0) / 1_000_000.0
val reply =
Message.obtain(null, NappletEmbedContract.MSG_MAGNIFIER_FRAME).apply {
this.data =
Bundle().apply {
putByteArray(NappletEmbedContract.KEY_MAG_BYTES, bytes)
putInt(NappletEmbedContract.KEY_MAG_W, outW)
putInt(NappletEmbedContract.KEY_MAG_H, outH)
putDouble(NappletEmbedContract.KEY_MAG_CAPTURE_MS, captureMs)
putLong(NappletEmbedContract.KEY_MAG_REQ_T, reqT)
}
}
runCatching { tab.clientMessenger?.send(reply) }
MagnifierCapture.capture(
webView = wv,
bgColor = tab.bgColor,
cx = data.getFloat(NappletEmbedContract.KEY_MAG_X),
cy = data.getFloat(NappletEmbedContract.KEY_MAG_Y),
boxW = data.getInt(NappletEmbedContract.KEY_MAG_BOX_W, 150).coerceIn(16, 1024),
boxH = data.getInt(NappletEmbedContract.KEY_MAG_BOX_H, 84).coerceIn(16, 1024),
zoom = data.getFloat(NappletEmbedContract.KEY_MAG_ZOOM, 1.6f).coerceIn(1f, 4f),
) { bytes, outW, outH, captureMs ->
val reply =
Message.obtain(null, NappletEmbedContract.MSG_MAGNIFIER_FRAME).apply {
this.data =
Bundle().apply {
putByteArray(NappletEmbedContract.KEY_MAG_BYTES, bytes)
putInt(NappletEmbedContract.KEY_MAG_W, outW)
putInt(NappletEmbedContract.KEY_MAG_H, outH)
putDouble(NappletEmbedContract.KEY_MAG_CAPTURE_MS, captureMs)
putLong(NappletEmbedContract.KEY_MAG_REQ_T, reqT)
}
}
tab.toClient(reply)
}
}
/** Builds the SandboxedUiAdapter for [tab] and ships its cross-process handle (coreLibInfo) to the client. */
@@ -332,7 +375,7 @@ class NappletHostService : Service() {
Message.obtain(null, NappletEmbedContract.MSG_SESSION_READY).apply {
data = Bundle().apply { putBundle(NappletEmbedContract.KEY_CORE_LIB_INFO, coreLibInfo) }
}
runCatching { tab.clientMessenger?.send(reply) }
tab.toClient(reply)
}
/**
@@ -348,9 +391,29 @@ class NappletHostService : Service() {
// The session may have been closed between MSG_CREATE_SESSION and this posted call — fail rather
// than build a WebView that no tab tracks (it would leak).
val tab = tabs[sessionId] ?: error("No napplet tab for session $sessionId")
// A session re-opened on this tab (the client's view detached and re-attached) before the old one's
// close landed: that session's WebView is still here. Destroy it now — its close will be ignored
// (see onSessionClosed), and overwriting it would leak it.
tab.webView?.let { stale ->
(stale.parent as? ViewGroup)?.removeView(stale)
stale.destroy()
tab.webView = null
tab.bridgeReplyProxy = null
}
tab.container = container
// A rebuild after a renderer crash: release the previous content server first.
tab.contentServer?.close()
tab.contentServer = null
// Fail closed: Tor is wanted but has no port yet. Nothing may be fetched — not the applet's blobs (the
// content server would fetch them directly) and not its own traffic — so leave a blank placeholder and
// tell the client, whose Retry sends the port once Tor is up.
if (tab.useTor && tab.proxyPort <= 0) {
val blank = WebView(nightThemedContext(context, tab.themeType)).apply { setBackgroundColor(tab.bgColor) }
tab.webView = blank
reportRouteBlocked(tab)
return blank
}
tab.routeBlocked = false
val wv = WebView(nightThemedContext(context, tab.themeType))
// FIRST touch after construction: setProfile throws once the WebView has loaded content (or its
// profile has otherwise been used), so the storage partition must be chosen before the
@@ -376,19 +439,48 @@ class NappletHostService : Service() {
// Theme the pre-load background so the shell/app loading shows Amethyst's background, not white.
wv.setBackgroundColor(tab.bgColor)
wv.dropSystemBarInsets()
if (tab.profile.exposesNetwork) applyWebViewProxy(effectiveProxy)
WebViewCompat.addWebMessageListener(wv, NappletWebContract.BRIDGE_NAME, setOf(NappletWebContract.ORIGIN), ::onShellMessage)
wv.setFindListener { active, total, _ -> pushFindResult(tab, active, total) }
if (tab.textZoom != BrowserChrome.DEFAULT_TEXT_ZOOM) BrowserWebTools.setTextZoom(wv, tab.textZoom)
tab.webView = wv
wv.loadUrl(NappletWebContract.SHELL_URL)
if (tab.paused) wv.onPause()
if (tab.profile.exposesNetwork) {
// The site's own off-origin traffic follows the process-wide route; load once it's in place so
// a Tor nSite's first request can't leave over the open web — and not at all if it can't be.
WebViewProxyPolicy.observeRoute(tab) { usesTor ->
if (tabs[tab.sessionId] === tab) {
tab.toClient(Message.obtain(null, NappletEmbedContract.MSG_ROUTE).apply { data = Bundle().apply { putBoolean(NappletEmbedContract.KEY_ROUTE_TOR, usesTor) } })
}
}
WebViewProxyPolicy.claim(
owner = tab,
torPort = effectiveProxy,
onFailed = { reportRouteBlocked(tab) },
) { if (tab.webView === wv) wv.loadUrl(NappletWebContract.SHELL_URL) }
} else {
wv.loadUrl(NappletWebContract.SHELL_URL)
}
return wv
}
/** A session closed: drop the tab, release its resources, and destroy its own WebView (never a sibling's). */
fun onSessionClosed(sessionId: String) {
val tab = tabs.remove(sessionId) ?: return
fun onSessionClosed(
sessionId: String,
container: FrameLayout,
) {
// Only the session that currently owns the tab may close it. A late close from a session that was
// already replaced by a re-open would otherwise reap the live one — its WebView destroyed under a
// client that had just been told the session opened, leaving the surface black for good.
tabs[sessionId]?.takeIf { it.container === container }?.let(::closeTab)
}
/** Drops [tab] and everything it holds (its WebView, content server, broker state, proxy claim). */
private fun closeTab(tab: NappletTab) {
// By identity: a replaced tab closing late must not take its replacement (same id) with it.
tabs.remove(tab.sessionId, tab)
tab.bridgeReplyProxy = null
WebViewProxyPolicy.release(tab)
releaseFromBroker(tab)
// Release a picker still waiting on this surface before its WebView goes away.
tab.fileChooser.cancel()
tab.contentServer?.close()
@@ -515,23 +607,10 @@ class NappletHostService : Service() {
putString(NappletEmbedContract.KEY_FILE_CHOOSER_TITLE, params.title?.toString())
}
}
if (runCatching { client.send(msg) }.isFailure) tab.fileChooser.cancel()
if (!tab.toClient(msg)) tab.fileChooser.cancel()
return true
}
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(
private val tab: NappletTab,
@@ -544,7 +623,7 @@ class NappletHostService : Service() {
override fun onPageStarted(
view: WebView,
url: String,
favicon: android.graphics.Bitmap?,
favicon: Bitmap?,
) {
// A new main-frame navigation cleared any prior error.
tab.loadFailed = false
@@ -588,8 +667,8 @@ class NappletHostService : Service() {
/**
* The renderer died. It is shared by every WebView in `:napplet`, and an unhandled crash kills the
* whole process — every other tab included. Drop just this tab's WebView and report the load as
* failed; the tab's retry (MSG_RELOAD) rebuilds it in the same surface.
* whole process — every other tab included. Drop just this tab's WebView and report it gone
* ([NappletEmbedContract.KEY_RENDERER_GONE]); the client's MSG_RELOAD rebuilds it in the same surface.
*/
override fun onRenderProcessGone(
view: WebView,
@@ -602,7 +681,7 @@ class NappletHostService : Service() {
tab.webView = null
tab.bridgeReplyProxy = null
tab.loadFailed = true
pushLoadState(tab, isLoading = false)
pushLoadState(tab, isLoading = false, rendererGone = true)
}
return true
}
@@ -628,7 +707,7 @@ class NappletHostService : Service() {
Message.obtain(null, NappletEmbedContract.MSG_STATE).apply {
data = Bundle().apply { putBoolean(NappletEmbedContract.KEY_CAN_GO_BACK, view.canGoBack()) }
}
runCatching { tab.clientMessenger?.send(message) }
tab.toClient(message)
}
private fun pushFindResult(
@@ -644,7 +723,7 @@ class NappletHostService : Service() {
putInt(NappletEmbedContract.KEY_FIND_TOTAL, total)
}
}
runCatching { tab.clientMessenger?.send(message) }
tab.toClient(message)
}
private fun pushConsoleLog(
@@ -664,13 +743,15 @@ class NappletHostService : Service() {
putInt(NappletEmbedContract.KEY_CONSOLE_LINE, line)
}
}
runCatching { tab.clientMessenger?.send(message) }
tab.toClient(message)
}
/** Tells the client whether a main-frame load is in flight and whether it failed, so it can overlay a spinner/retry. */
private fun pushLoadState(
tab: NappletTab,
isLoading: Boolean,
rendererGone: Boolean = false,
routeBlocked: Boolean = false,
) {
val message =
Message.obtain(null, NappletEmbedContract.MSG_LOAD_STATE).apply {
@@ -678,9 +759,20 @@ class NappletHostService : Service() {
Bundle().apply {
putBoolean(NappletEmbedContract.KEY_IS_LOADING, isLoading)
putBoolean(NappletEmbedContract.KEY_LOAD_FAILED, tab.loadFailed)
putBoolean(NappletEmbedContract.KEY_RENDERER_GONE, rendererGone)
putBoolean(NappletEmbedContract.KEY_ROUTE_BLOCKED, routeBlocked)
}
}
runCatching { tab.clientMessenger?.send(message) }
tab.toClient(message)
}
/** The applet wasn't loaded because its route can't be honored: tell the client, which shows the error. */
private fun reportRouteBlocked(tab: NappletTab) {
if (tabs[tab.sessionId] !== tab) return
// Whatever WebView the tab has never loaded the applet: the retry must rebuild it, not reload it.
tab.routeBlocked = true
tab.loadFailed = true
pushLoadState(tab, isLoading = false, routeBlocked = true)
}
// ---- bridge: shell <-> native (mirror of NappletHostActivity.onShellMessage) ----
@@ -705,7 +797,7 @@ class NappletHostService : Service() {
Message.obtain(null, NappletEmbedContract.MSG_IME_EVENT).apply {
data = Bundle().apply { putString(NappletEmbedContract.KEY_IME_PAYLOAD, raw) }
}
runCatching { tab.clientMessenger?.send(reply) }
tab.toClient(reply)
return
}
@@ -720,9 +812,59 @@ class NappletHostService : Service() {
putString(NappletIpc.KEY_LAUNCH_TOKEN, tab.launchToken)
}
}
// Nobody is looking (parked off-screen, or the app is in the background): an act on the user's behalf
// waits until they're looking at this napplet again.
if (!tab.mayAct && NappletActingRequests.actsForUser(runCatching { NappletProtocolJson.readType(raw) }.getOrNull())) {
val refused = tab.heldWhilePaused.hold(msg)
if (refused != null) {
tab.bridgeReplyProxy?.failRequest(refused, NappletHeldRequests.TOO_MANY)
} else {
// Settle it with an error if nobody comes back for it, so the applet isn't left waiting forever.
heldExpiry.postDelayed({ expireHeld(tab) }, NappletHeldRequests.MAX_AGE_MS)
}
return
}
if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg)
}
/** Sends [tab]'s held acting requests once it may act again (attended and not paused). */
private fun releaseHeld(tab: NappletTab) {
if (!tab.mayAct) return
val held = tab.heldWhilePaused.drain()
held.fail.forEach { tab.bridgeReplyProxy?.failRequest(it, NappletHeldRequests.EXPIRED) }
held.send.forEach { request -> if (brokerMessenger == null) pendingBrokerRequests.add(request) else sendToBroker(request) }
}
private val heldExpiry = Handler(Looper.getMainLooper())
/** Tells the broker whether [tab] is being looked at, which gates decrypting its relay reads. */
private fun reportAttended(tab: NappletTab) {
val msg =
Message.obtain(null, NappletIpc.MSG_SET_ATTENDED).apply {
replyTo = tab.replyMessenger
data = Bundle().apply { putBoolean(NappletIpc.KEY_ATTENDED, tab.attended) }
}
if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg)
}
private fun expireHeld(tab: NappletTab) {
if (tabs[tab.sessionId] !== tab) return
tab.heldWhilePaused.expire().forEach { tab.bridgeReplyProxy?.failRequest(it, NappletHeldRequests.EXPIRED) }
}
/**
* [tab] is gone: have the broker close the live relay / inc subscriptions it opened and drop its reply
* Messenger (a binder the main process would otherwise hold, keeping the tab alive). The launch token is
* NOT given back: the main-process controller re-creates sessions with the same token, and gives it
* back itself when it is torn down.
*/
private fun releaseFromBroker(tab: NappletTab) {
tab.heldWhilePaused.clear()
pendingBrokerRequests.removeAll { it.replyTo == tab.replyMessenger }
if (brokerMessenger == null) return
sendToBroker(Message.obtain(null, NappletIpc.MSG_RELEASE_CLIENT).apply { replyTo = tab.replyMessenger })
}
private fun sendToBroker(msg: Message) {
try {
brokerMessenger?.send(msg)
@@ -774,7 +916,7 @@ class NappletHostService : Service() {
Message.obtain(null, NappletEmbedContract.MSG_NOTICE).apply {
data = Bundle().apply { putString(NappletEmbedContract.KEY_NOTICE, notice) }
}
runCatching { tab.clientMessenger?.send(message) }
tab.toClient(message)
}
private fun readContractAsset(path: String): ByteArray = assets.open(NappletWebContract.RESOURCE_ASSET_ROOT + path).use { it.readBytes() }
@@ -112,8 +112,13 @@ private class HostSession(
}
override fun close() {
// The library may call close() off the main thread; WebView.destroy() (and the tabs mutation)
// must run on the main thread.
Handler(Looper.getMainLooper()).post { service.onSessionClosed(sessionId) }
// WebView.destroy() (and the tabs mutation) must run on the main thread. The library usually calls
// this there already; post only when it doesn't. The container identifies THIS session, so a close
// that lands after the tab was re-opened can't tear down its successor.
if (Looper.myLooper() == Looper.getMainLooper()) {
service.onSessionClosed(sessionId, container)
} else {
Handler(Looper.getMainLooper()).post { service.onSessionClosed(sessionId, container) }
}
}
}
@@ -57,6 +57,9 @@ object NappletIpc {
/** Broker → host: the [KEY_LAUNCH_TOKEN] minted for [KEY_BROWSER_ORIGIN]. */
const val MSG_BROWSER_TOKEN = 6
/** How long a host waits for [MSG_BROWSER_TOKEN] before failing the origin's queued calls. */
const val MINT_TIMEOUT_MS = 20_000L
/**
* Host → broker: this sandbox surface entered ([KEY_FOREGROUND] true) or left ([KEY_FOREGROUND]
* false) the foreground. The `:napplet` host runs in its own process and so can't touch the main
@@ -109,8 +112,10 @@ object NappletIpc {
/**
* Host → broker: this surface is being destroyed — drop every reference the broker holds to its
* `replyTo` [android.os.Messenger] (inc-bus topic subscriptions, and its foreground lease when
* [KEY_LAUNCH_TOKEN] is supplied).
* `replyTo` [android.os.Messenger] (inc-bus topic subscriptions, live relay subscriptions, and its
* foreground lease when [KEY_LAUNCH_TOKEN] is supplied). [KEY_RELEASED_TOKENS], when present, lists
* launch tokens the surface minted and will never use again (a closed browser tab's per-origin
* tokens); the broker unregisters them so dead sessions stop crowding live ones out of the registry.
*
* A `Messenger` handed to the broker is a **binder**, so the main process holding it keeps a JNI
* global reference alive in `:napplet`. Because the sandbox's reply handler is a bound method
@@ -157,6 +162,24 @@ object NappletIpc {
*/
const val MSG_ADD_TO_HOME_SCREEN = 19
/**
* Broker → host: a request carried [KEY_LAUNCH_TOKEN] the registry no longer knows (it was evicted, or
* released). The request itself was answered with a failure; a browser host drops that token so the
* origin's next call mints a fresh one instead of failing forever.
*/
const val MSG_TOKEN_UNKNOWN = 20
/**
* Host → broker: whether the user is looking at the surface behind [android.os.Message.replyTo]
* ([KEY_ATTENDED]). The broker decrypts relay reads for a page — events pushed to its subscriptions, and
* `relay.query` results — only while it is; until then encrypted events wait. A surface is unattended
* until it says otherwise, and after each [MSG_RELEASE_CLIENT].
*/
const val MSG_SET_ATTENDED = 21
/** Boolean for [MSG_SET_ATTENDED]. */
const val KEY_ATTENDED = "attended"
const val KEY_REQUEST_ID = "requestId"
const val KEY_PAYLOAD = "payload"
@@ -199,6 +222,9 @@ object NappletIpc {
/** The visited web origin (e.g. `https://example.com`) a browser-mode request belongs to. */
const val KEY_BROWSER_ORIGIN = "browserOrigin"
/** [MSG_MINT_BROWSER_TOKEN]: the opaque storage profile the asking surface runs in (its account's jar). */
const val KEY_WEBVIEW_PROFILE = "webViewProfile"
/** Boolean: route this site through Tor (true) or over the open web (false). */
const val KEY_NETWORK_USE_TOR = "networkUseTor"
@@ -208,4 +234,7 @@ object NappletIpc {
* the trusted identity + declared capability set the launch was registered with.
*/
const val KEY_LAUNCH_TOKEN = "launchToken"
/** Launch tokens a [MSG_RELEASE_CLIENT] gives back (a string array). */
const val KEY_RELEASED_TOKENS = "releasedTokens"
}
@@ -0,0 +1,159 @@
/*
* 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.os.Handler
import android.os.Looper
import androidx.webkit.ProxyConfig
import androidx.webkit.ProxyController
import androidx.webkit.WebViewFeature
import com.vitorpamplona.amethyst.commons.napplet.NappletProxyClaims
import com.vitorpamplona.amethyst.commons.napplet.NappletProxyClaims.Route
import com.vitorpamplona.quartz.utils.Log
import java.util.concurrent.Executor
/**
* The single owner of the `:napplet` process's WebView proxy override. Every surface — embedded browser
* tab, embedded nSite, full-screen browser or host — files a claim here instead of setting the override
* itself; [NappletProxyClaims] derives the one route they all share (Tor always wins, see there).
*
* It fails CLOSED. A surface's page load waits for [claim]'s `onReady`, which only runs once the route is
* actually in effect: if applying it fails, or this WebView can't take a proxy override at all while Tor is
* wanted, the waiting loads get `onFailed` instead of going out directly, and the next claim tries again.
* (A surface that wants Tor but has no Tor port yet must not claim at all — it blocks its own load.)
*
* Main thread only; the WebKit callback is delivered back to the main thread too.
*/
object WebViewProxyPolicy {
private const val TAG = "WebViewProxyPolicy"
/** Why a surface's load can't go ahead. */
enum class Failure {
/** This device's WebView can't route through a proxy, so Tor can't be honored. */
TOR_UNSUPPORTED,
/** Setting the proxy override failed. */
APPLY_FAILED,
}
private class Waiter(
val wantsTor: Boolean,
val onReady: () -> Unit,
val onFailed: (Failure) -> Unit,
)
private val claims = NappletProxyClaims()
// What the WebView currently runs with (a fresh process has no override), and what is being applied.
private var applied: Route = NappletProxyClaims.DIRECT
private var applying: Route? = null
private val waiting = mutableListOf<Waiter>()
// Surfaces told which route is really in effect (an open-web page can be on Tor because another needs it).
private val routeListeners = LinkedHashMap<Any, (Boolean) -> Unit>()
private val main = Handler(Looper.getMainLooper())
private val mainExecutor = Executor { if (Looper.myLooper() == Looper.getMainLooper()) it.run() else main.post(it) }
private val supported by lazy { WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE) }
/**
* Files [owner]'s route: through Tor on [torPort] (> 0), or the open web ([NappletProxyClaims.NO_PROXY]).
* [onReady] runs on the main thread once the resulting process route is in effect — immediately when
* nothing had to change; [onFailed] instead when it can't be.
*/
fun claim(
owner: Any,
torPort: Int,
onFailed: (Failure) -> Unit = {},
onReady: () -> Unit = {},
) {
claims.claim(owner, torPort)
sync(Waiter(torPort > 0, onReady, onFailed))
}
/** Withdraws [owner]'s claim and route listener; the route relaxes once no remaining surface needs it. */
fun release(owner: Any) {
claims.release(owner)
routeListeners.remove(owner)
sync(null)
}
/** Tells [listener] (now, and on every change) whether the process currently routes through Tor. */
fun observeRoute(
owner: Any,
listener: (usesTor: Boolean) -> Unit,
) {
routeListeners[owner] = listener
listener(applied.usesTor)
}
private fun sync(waiter: Waiter?) {
val target = claims.route()
if (!supported) {
// Nothing can be proxied. The open web still works; a surface that wants Tor fails closed.
if (waiter?.wantsTor == true) waiter.onFailed(Failure.TOR_UNSUPPORTED) else waiter?.onReady?.invoke()
return
}
if (target == applied && applying == null) {
waiter?.onReady?.invoke()
return
}
waiter?.let { waiting += it }
if (target == applying) return
applying = target
apply(target) { ok ->
// A newer route superseded this one while it applied: its own callback settles the waiters.
if (applying != target) {
if (ok) applied = target
return@apply
}
applying = null
val settled = waiting.toList()
waiting.clear()
if (ok) {
applied = target
routeListeners.values.toList().forEach { it(target.usesTor) }
settled.forEach { it.onReady() }
} else {
// Keep `applied` as it was, so the next claim tries again; nothing waiting goes out unrouted.
settled.forEach { it.onFailed(Failure.APPLY_FAILED) }
}
}
}
private fun apply(
route: Route,
done: (ok: Boolean) -> Unit,
) {
runCatching {
if (route.usesTor) {
val config = ProxyConfig.Builder().addProxyRule("socks5://127.0.0.1:${route.torPort}").build()
ProxyController.getInstance().setProxyOverride(config, mainExecutor) { done(true) }
} else {
ProxyController.getInstance().clearProxyOverride(mainExecutor) { done(true) }
}
}.onFailure {
Log.w(TAG, "Failed to apply WebView proxy override", it)
done(false)
}
}
}
@@ -17,5 +17,8 @@
<!-- Developer console: page-load failures surfaced as console errors -->
<string name="napplet_console_load_error">Failed to load (%1$d): %2$s</string>
<string name="napplet_console_http_error">HTTP %1$d %2$s</string>
<!-- Shown by the full-screen browser / napplet (in the sandbox process, where compose resources can't be
read outside composition) when a page is not loaded because its Tor route can't be honored. -->
<string name="napplet_route_blocked">Not loaded: Tor isn\'t available for this page yet. Try again in a moment.</string>
</resources>