mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge remote-tracking branch 'origin/main' into claude/magical-dirac-bgqwil
This commit is contained in:
@@ -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()) }
|
||||
|
||||
+75
-22
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+339
-31
@@ -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()}"
|
||||
}
|
||||
}
|
||||
|
||||
+34
-18
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
@@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.commons.resources.cordn_groups_none
|
||||
import com.vitorpamplona.amethyst.commons.resources.cordn_groups_none_detail
|
||||
import com.vitorpamplona.amethyst.commons.resources.cordn_groups_start
|
||||
import com.vitorpamplona.amethyst.commons.resources.cordn_groups_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_title
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
|
||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
|
||||
@@ -110,6 +111,16 @@ fun CordnGroupListScreen(
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBarWithBackButton(stringRes(Res.string.cordn_groups_title), nav) {
|
||||
// Invitations are fetched only when someone opens that screen
|
||||
// (every call to a coordinator is metadata, spec/00.md §8), so
|
||||
// this is a plain way in, never a badge.
|
||||
IconButton(onClick = { nav.nav(Route.CordnInvitations) }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.MoveToInbox,
|
||||
contentDescription = stringRes(Res.string.cordn_invitations_title),
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { nav.nav(Route.CordnCoordinators) }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Dns,
|
||||
|
||||
+33
@@ -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) {
|
||||
|
||||
+96
@@ -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
|
||||
}
|
||||
}
|
||||
+5
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
@@ -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()
|
||||
|
||||
|
||||
+15
-3
@@ -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
|
||||
|
||||
+170
-16
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+149
-52
@@ -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))
|
||||
}
|
||||
|
||||
+7
-1
@@ -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.
|
||||
|
||||
+2
-9
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+284
-42
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+26
-49
@@ -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
|
||||
}
|
||||
|
||||
+67
@@ -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)
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.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())
|
||||
}
|
||||
}
|
||||
+5
@@ -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. */
|
||||
|
||||
+47
@@ -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
|
||||
}
|
||||
+56
@@ -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)
|
||||
}
|
||||
+126
@@ -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"
|
||||
}
|
||||
}
|
||||
+93
@@ -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."
|
||||
}
|
||||
}
|
||||
+8
@@ -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()
|
||||
|
||||
+77
@@ -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)
|
||||
}
|
||||
}
|
||||
+48
@@ -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()) }
|
||||
}
|
||||
}
|
||||
+57
@@ -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))
|
||||
}
|
||||
}
|
||||
+155
@@ -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))
|
||||
}
|
||||
}
|
||||
+82
@@ -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)
|
||||
}
|
||||
}
|
||||
+73
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -3075,4 +3075,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -2950,4 +2950,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -5591,7 +5591,6 @@
|
||||
<string name="new_conversation_cordn_pro_1">End-to-end šifrování pomocí MLS — koordinátor nikdy nepřečte žádnou zprávu.</string>
|
||||
<string name="new_conversation_cordn_pro_2">Jedno dohodnuté pořadí pro všechny, takže historii nemohou přeházet špatně nastavené hodiny.</string>
|
||||
<string name="new_conversation_cordn_con_1">Koordinátor zjistí, kdo je ve skupině a kdy spolu mluvíte, i když nemůže číst, co píšete.</string>
|
||||
<string name="cordn_invitations_entry">Máte pozvánku do skupiny cordn?</string>
|
||||
<string name="cordn_invitations_entry_action">Zobrazit pozvánky</string>
|
||||
<!-- cordn device migration (spec/applications/multi-device.md §9/§11), as a
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
@@ -6488,4 +6487,5 @@
|
||||
<string name="cordn_voice_stop">Zastavit a odeslat</string>
|
||||
<string name="browser_address_hint">Hledat nebo zadat adresu</string>
|
||||
<string name="napplet_untitled">Nepojmenovaný nApplet</string>
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -5361,7 +5361,6 @@
|
||||
<string name="new_conversation_cordn_pro_1">Ende-zu-Ende-verschlüsselt mit MLS — der Koordinator kann nie eine Nachricht lesen.</string>
|
||||
<string name="new_conversation_cordn_pro_2">Eine gemeinsame Reihenfolge für alle, sodass der Verlauf nicht durch eine falsch gehende Uhr durcheinandergeraten kann.</string>
|
||||
<string name="new_conversation_cordn_con_1">Der Koordinator erfährt, wer in der Gruppe ist und wann ihr schreibt, auch wenn er nicht lesen kann, was ihr sagt.</string>
|
||||
<string name="cordn_invitations_entry">In eine cordn-Gruppe eingeladen?</string>
|
||||
<string name="cordn_invitations_entry_action">Einladungen ansehen</string>
|
||||
<!-- cordn device migration (spec/applications/multi-device.md §9/§11), as a
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
@@ -6152,4 +6151,5 @@
|
||||
<string name="cordn_voice_play">Sprachnachricht abspielen</string>
|
||||
<string name="cordn_voice_record">Sprachnachricht aufnehmen</string>
|
||||
<string name="cordn_voice_stop">Stoppen und senden</string>
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -2898,4 +2898,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -35,4 +35,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -2954,4 +2954,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -2970,4 +2970,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -2963,4 +2963,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -2963,4 +2963,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -2964,4 +2964,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -2930,4 +2930,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -2854,4 +2854,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -3147,4 +3147,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -461,14 +461,14 @@
|
||||
<string name="send_payment_source_external">अन्य धनकोष क्रमक</string>
|
||||
<string name="send_payment_source_onchain_wallet">खण्डश्रृंखलाबद्ध धनकोष</string>
|
||||
<string name="send_payment_source_cashu_wallet">काशयू॰ धनकोष</string>
|
||||
<string name="send_payment_method_lightning">लैटनिंग॰</string>
|
||||
<string name="send_payment_method_lightning">लैटनिंग</string>
|
||||
<string name="send_payment_method_clink">क्लिंक॰ प्रस्ताव</string>
|
||||
<string name="send_payment_method_onchain">खण्डश्रृंखलाबद्ध</string>
|
||||
<string name="send_payment_method_cashu">काशयू॰</string>
|
||||
<string name="send_payment_amount">मात्रा</string>
|
||||
<string name="send_payment_fixed_price">प्रस्ताव द्वारा स्थापित मूल्य</string>
|
||||
<string name="send_payment_receipt_section">ज्साप प्राप्तिप्रमाण</string>
|
||||
<string name="send_payment_receipt_clink">सीधा लैटनिंग॰ भुगतान। ज्साप प्राप्तिप्रमाण नोस्टर पर प्राकाशित नहीं होगा।</string>
|
||||
<string name="send_payment_receipt_clink">सीधा लैटनिंग भुगतान। ज्साप प्राप्तिप्रमाण नोस्टर पर प्राकाशित नहीं होगा।</string>
|
||||
<string name="send_payment_receipt_onchain">एक खण्डश्रृंखलाबद्ध ज्साप प्राप्तिप्रमाण नोस्टर पर प्रकाशित होगा जिससे प्राप्तकर्ता भुगतान खोज सके।</string>
|
||||
<string name="send_payment_receipt_onchain_address">लेखा परिचय घोषित द्व्यंकरूप्य पता (%1$s) को भुगतान सीधे आपके खण्डश्रृंखलाबद्ध धनकोष से। कोई ज्साप प्राप्तिप्रमाण प्रकाशित नहीं।</string>
|
||||
<string name="send_payment_receipt_cashu">नटज्साप॰ घटना ईकाश॰ स्वयम को भेजता है। अतः वह सर्वदा प्रकाशित।</string>
|
||||
@@ -577,6 +577,14 @@
|
||||
<string name="concord_invite_revoke_title">क्या इस योजक का निराकरण करें।</string>
|
||||
<string name="concord_invite_revoke_explainer">इस योजक के जो भी धारक अब भी हैं आगे से जुड नहीं सकेंगे। जो लोग पहले से इसके द्वारा जुड चुके हैं समुदाय में रहेंगे। इसको पूर्ववत नही किया जा सकता।</string>
|
||||
<string name="concord_invite_revoke_confirm">निराकरण</string>
|
||||
<string name="concord_invite_revoke_privatize_warning">यह इस समुदाय का अन्तिम जीवित आमन्त्रण योजक है। इसके निराकरण से समुदाय निजी बन जाएगा तथा इसके कुंचिकाओं का वर्तन किया जाएगा। जिससे केवल वर्तमान सदस्य अभिगमन अधिकार रखेंगे।</string>
|
||||
<string name="concord_invite_revoked_privatized">योजक का निराकरण हो गया। समुदाय अब निजी है। तथा इसके कुंचिकाओं का वर्तन किया गया।</string>
|
||||
<string name="concord_invite_revoked_privatize_pending">योजक का निराकरण हो गया। समुदाय अब निजी है। पर इसके कुंचिकाओं का वर्तन नहीं किया गया। सदस्यों को प्रतिबन्धित करने का अधिकार रखनेवाला एक प्रशासक को इनका वर्तन करना चाहिए।</string>
|
||||
<string name="concord_mode_private">निजी</string>
|
||||
<plurals name="concord_mode_public">
|
||||
<item quantity="one">सार्वजनिक। %1$d जीवित आमन्त्रण योजक</item>
|
||||
<item quantity="other">सार्वजनिक। %1$d जीवित आमन्त्रण योजक</item>
|
||||
</plurals>
|
||||
<string name="concord_invite_preview_unknown_name">समुदाय नाम आप जुडने के पश्चात ही बताया जाएगा</string>
|
||||
<string name="concord_invite_preview_explainer">जुडने से इस आमन्त्रण के पुनःप्रसारकों से संयोजन किया जाएगा। तथा आपके लेखा से हस्ताक्षरित एक जोड घोषणा प्रकाशित किया जाएगा। तथा समुदाय को आपकी सूची में जोडा जाएगा। कुछ नहीं भेजा जाएगा जुडें दबाने तक।</string>
|
||||
<string name="concord_invite_preview_relays">पुनःप्रसारक जिनसे सम्पर्क किया जाएगा इस आमन्त्रण द्वारा : %1$s</string>
|
||||
@@ -585,7 +593,28 @@
|
||||
<string name="concord_open_channel">प्रणाली खोलें</string>
|
||||
<string name="concord_edit_banner_hint">ध्वजचित्र जोडें</string>
|
||||
<string name="concord_edit_relays_desc">जहाँ इस समुदाय के रहस्यीकृत समतलों का प्रकाशन तथा पठन किया जाता है।</string>
|
||||
<string name="concord_timer_title">नश्वर सन्देश</string>
|
||||
<string name="concord_timer_desc">नए सन्देश सभी प्रणालियों में मिटाए जाएँगे इस अवधि पश्चात सदस्यों के यन्त्रें से तथा पुनःप्रसारकों से। इसके परिवर्तन से पूर्व प्रेषित सन्देशों पर कोई प्रभाव नहीं। जो भी एक सन्देश को पढ सकता है उसकी अनुकृति करने में सक्षम रहेगा।</string>
|
||||
<string name="concord_timer_off">निष्क्रिय</string>
|
||||
<string name="concord_timer_notice_set">%1$s ने नश्वर सन्देशों पर स्थापित किया %2$s</string>
|
||||
<string name="concord_timer_notice_off">%1$s ने सन्देश अदृश्यकरण अक्षम किया</string>
|
||||
<string name="concord_timer_active">सन्देश अदृश्य हो जाते हैं %1$s पश्चात</string>
|
||||
<string name="concord_dissolved_read_only">इस समुदाय को विघटित किया गया है तथा अब पठनेवशक्य है। आप इसका इतिहास पढ सकते हैं परन्तु कोई नए सन्देश नहीं भेज सकते।</string>
|
||||
<string name="concord_private_channel_no_key">यह एक निजी प्रणाली है। तथा आप इसकी कुंचिका नहीं रखते। इसलिए आप इसे पढ नहीं सकते। तथा यहाँ पत्र प्रकाशित नहीं कर सकते।</string>
|
||||
<string name="concord_pinned_title">टाँके गए सन्देश</string>
|
||||
<string name="concord_pinned_empty">कोई टाँके गए सन्देश नहीं इस प्रणाली में अब तक।</string>
|
||||
<string name="concord_pinned_unavailable">इस प्रणाली के टाँके एक कुंचिका के साथ तालाबद्ध हैं जो आपके पास नहीं है। इसलिए वे यहाँ दिखाए नहीं जा सकते। तथा टाँकना स्थगित है जब तक उनको पढा नहीं जा सकता।</string>
|
||||
<string name="concord_pinned_budget">%2$d में से %1$d टाँके। उपलब्ध आकार सीमा का %3$d प्रतिशत प्रयुक्त</string>
|
||||
<string name="concord_pinned_open_hint">टाँका दबाएँ वहाँ तक जाने के लिए</string>
|
||||
<string name="concord_pin_expiring_title">क्या नश्वर सन्देश टाँकें।</string>
|
||||
<string name="concord_pin_expiring_body">यह सन्देश अदृश्य होने जा रहा है। टाँकने से इसके शब्द रखे जाएँगे प्रणाली की टाँका सूची में। घडी द्वारा सन्देश को मिटाने के पश्चात भी।</string>
|
||||
<string name="concord_pin_expiring_confirm">तो भी टाँकें</string>
|
||||
<string name="concord_pin_failed_title">टाँके</string>
|
||||
<string name="concord_pin_failed_unavailable">टाँके गए सन्देश सूची एक कुंचिका से तालाबद्ध है जो आपके पास नहीं है। इसका परिवर्तन अब करने से टाँके छोड दिए जाएँगे जो आप देख नहीं सकते।</string>
|
||||
<string name="concord_pin_failed_too_many">इस प्रणाली में पहले से ही पच्चीस टाँके हैं। पहले एक टाँका हटाएँ।</string>
|
||||
<string name="concord_pin_failed_too_large">टाँके गए सन्देश सूची में स्थान नहीं है। पहले एक सन्देश से टाँका हटाएँ।</string>
|
||||
<string name="concord_pin_failed_message">इस सन्देश का मूल हस्ताक्षर यहाँ नहीं रखा जाता। इसलिए इसको टाँकने के लिए इसका प्रमाणीकरण असाध्य।</string>
|
||||
<string name="concord_pin_failed_generic">इस प्रणाली के टाँकों का परिवर्तन आप अब नहीं कर सकते।</string>
|
||||
<string name="concord_create_name">नाम</string>
|
||||
<string name="concord_create_about">जानकारी (विकल्पात्मक)</string>
|
||||
<string name="concord_ban_user">प्रतिबन्ध</string>
|
||||
@@ -606,6 +635,15 @@
|
||||
<string name="concord_role_owner">स्वत्वधारी</string>
|
||||
<string name="concord_role_admin">प्रशासक</string>
|
||||
<string name="concord_role_banned">प्रतिबन्धित</string>
|
||||
<string name="concord_role_kicked">पदप्रहार किया गया</string>
|
||||
<string name="concord_role_left">चले गए</string>
|
||||
<string name="concord_members_kick">पदप्रहार</string>
|
||||
<string name="concord_members_kick_title">क्या इस सदस्य पर पदप्रहार करें।</string>
|
||||
<string name="concord_members_kick_message">उनके पात्र हटाए गए तथा उनके क्रमक को समुदाय छोडके जाने का अनुरोध किया गया। पदप्रहार सहकारी है। इससे कुंचिकाओं का वर्तन नहीं होता। तथा वे आमन्त्रण से पुनःजुड सकते हैं। अभिगमन काटने के लिए उनको प्रतिबन्धित करें अथवा हटाएँ।</string>
|
||||
<string name="concord_members_kick_failed">इस सदस्य पर पदप्रहार असफल।</string>
|
||||
<string name="concord_kicked_title">समुदाय से हटाए गए</string>
|
||||
<string name="concord_kicked_message">एक नियामक ने आपको %1$s से हटाया। आप एक नए आमन्त्रण से पुनःजुड सकते हैं।</string>
|
||||
<string name="concord_kicked_message_unnamed">एक नियामक ने आपको एक समुदाय से हटाया। आप एक नए आमन्त्रण से पुनःजुड सकते हैं।</string>
|
||||
<string name="concord_invite_card_join">समुदाय से जुडें</string>
|
||||
<string name="concord_invite_card_subtitle">कोंकोर्ड समुदाय आमन्त्रण</string>
|
||||
<string name="concord_invite_naddr_label">कोंकोर्ड आमन्त्रण (आमन्त्रण योजक खोलें जुडने के लिए)</string>
|
||||
@@ -1223,9 +1261,9 @@
|
||||
<string name="podcast_value_search_user_hint">नाम अथवा @लेखासूचक द्वारा ढूँढें</string>
|
||||
<string name="podcast_value_remove_recipient">प्राप्तकर्ता हटाएँ</string>
|
||||
<string name="podcast_value_recipient_name">नाम (विकल्पात्मक)</string>
|
||||
<string name="podcast_value_type_lnaddress">लैटनिंग॰ पता</string>
|
||||
<string name="podcast_value_type_lnaddress">लैटनिंग पता</string>
|
||||
<string name="podcast_value_type_node">जालबिन्दु (कुंचिकाप्रेषण)</string>
|
||||
<string name="podcast_value_lnaddress">लैटनिंग॰ पता</string>
|
||||
<string name="podcast_value_lnaddress">लैटनिंग पता</string>
|
||||
<string name="podcast_value_lnaddress_hint">name@example.com</string>
|
||||
<string name="podcast_value_node_pubkey">जालबिन्दु ख्याप्यकुंचिका</string>
|
||||
<string name="podcast_value_node_pubkey_hint">02abc… (३३ अष्टक षोडशांक)</string>
|
||||
@@ -1433,7 +1471,7 @@
|
||||
<string name="payment_target_type">जाल प्रकार (उ॰ द्व्यंकरूप्य)</string>
|
||||
<string name="payment_target_authority">पता</string>
|
||||
<string name="delete_payment_target">भुगतान लक्ष्य मिटाएँ</string>
|
||||
<string name="bolt12_offers_explainer">पुनःउपयोज्य बोल्ट१२ प्रस्ताव प्रकाशित करें जिससे कि अन्य लोग आपको ज्साप कर सकें लैटनिंग॰ पर प्रत्येक बार पृथक चालान बिना।</string>
|
||||
<string name="bolt12_offers_explainer">पुनःउपयोज्य बोल्ट॰बारह प्रस्ताव प्रकाशित करें जिससे कि अन्य लोग आपको ज्साप कर सकें लैटनिंग पर प्रत्येक बार पृथक चालान बिना।</string>
|
||||
<string name="no_bolt12_offers_message">कोई बोल्ट१२ प्रस्ताव स्थापित नही। नीचे एक जोडें।</string>
|
||||
<string name="bolt12_offer">बोल्ट१२ प्रस्ताव (lno1…)</string>
|
||||
<string name="invalid_bolt12_offer">मान्य बोल्ट१२ प्रस्ताव नहीं</string>
|
||||
@@ -1932,8 +1970,8 @@
|
||||
<string name="wallet_no_wallets_description">एक अथवा अधिक नोस्टर धनकोष संयोजन जोडें भुगतान भेजने तथा प्राप्त करने के लिए।</string>
|
||||
<string name="wallet_add_connection">नोस्टर धनकोष संयोजन जोडें</string>
|
||||
<string name="wallet_add_choose_type">धनकोष प्रकार का चयन</string>
|
||||
<string name="wallet_add_nwc_title">लैटनिंग॰ धनकोष का संयोजन (एनडबल्यूसी॰)</string>
|
||||
<string name="wallet_add_nwc_description">बाहरी लैटनिंग॰ धनकोष से भुगतान नोस्टर धनकोष संयोजन के प्रयोग से।</string>
|
||||
<string name="wallet_add_nwc_title">लैटनिंग धनकोष का संयोजन (एनडबल्यूसी॰)</string>
|
||||
<string name="wallet_add_nwc_description">बाहरी लैटनिंग धनकोष से भुगतान नोस्टर धनकोष संयोजन के प्रयोग से।</string>
|
||||
<string name="wallet_add_cashu_description">एक क्रमकाभ्यन्तर ईकाश॰ धनकोष जो एक काशयू॰ टकसाल द्वारा समर्थित है। अक्षरराशि रहस्यकृत रूप में पुनःप्रसारकों में रखे जाते हैं।</string>
|
||||
<string name="cashu_wallet_title">काशयू॰ धनकोष</string>
|
||||
<string name="cashu_no_wallet">कोई काशयू॰ धनकोष नहीं अब तक</string>
|
||||
@@ -1982,13 +2020,13 @@
|
||||
<string name="cashu_mint_no_recs">कोई अनुशंसाएँ नहीं अब तक</string>
|
||||
<string name="cashu_mint_recommended_by">इनके द्वारा अनुशंसित</string>
|
||||
<string name="cashu_action_receive">प्राप्त करें</string>
|
||||
<string name="cashu_action_send_ln">भेजें लैटनिंग॰</string>
|
||||
<string name="cashu_action_send_ln">भेजें लैटनिंग</string>
|
||||
<string name="cashu_action_send_token">अक्षरराशि भेजें</string>
|
||||
<string name="cashu_action_redeem">चुकाएँ</string>
|
||||
<string name="cashu_amount_sats">मात्रा (साट्स)</string>
|
||||
<string name="cashu_pick_mint">टकसाल का चयन करें</string>
|
||||
<string name="cashu_memo_optional">ज्ञापक (विकल्पात्मक)</string>
|
||||
<string name="cashu_invoice_bolt11">लैटनिंग॰ चालान (बोल्ट११)</string>
|
||||
<string name="cashu_invoice_bolt11">लैटनिंग चालान (बोल्ट॰ग्यारह)</string>
|
||||
<string name="cashu_token_label">काशयू अक्षरराशि</string>
|
||||
<string name="cashu_copy_invoice">चालान अनुकृति</string>
|
||||
<string name="cashu_request_invoice">चालान अनुरोध</string>
|
||||
@@ -2068,7 +2106,7 @@
|
||||
<string name="onchain_send_change_recipient">प्राप्तकर्ता परिवर्तन</string>
|
||||
<string name="onchain_send_amount">मात्रा</string>
|
||||
<string name="onchain_send_sats_suffix">साट्स</string>
|
||||
<string name="onchain_send_min_warning">न्यूनतम खण्डश्रृंखलाबद्ध ज्साप %1$s साट्स है। न्यूनतर मात्राएँ खनिक शुल्क द्वारा खाए जाते हैं। इसके स्थान पर लैटनिंग॰ ज्साप का प्रयोग करें।</string>
|
||||
<string name="onchain_send_min_warning">न्यूनतम खण्डश्रृंखलाबद्ध ज्साप %1$s साट्स है। न्यूनतर मात्राएँ खनिक शुल्क द्वारा खाए जाते हैं। इसके स्थान पर लैटनिंग ज्साप का प्रयोग करें।</string>
|
||||
<string name="onchain_send_comment_label">टिप्पणी (विकल्पात्मक)</string>
|
||||
<string name="onchain_send_priority">प्राथमिकता</string>
|
||||
<string name="onchain_send_fee_rate_eta">%1$s साट्स प्रति अष्टकाभास। %2$s</string>
|
||||
@@ -2299,6 +2337,11 @@
|
||||
<string name="buzz_canvas_edit">चित्रपट सम्पादन</string>
|
||||
<string name="buzz_canvas_save">चित्रपट अभिलेखन</string>
|
||||
<string name="buzz_canvas_body_label">चित्रपट (मार्कडौन॰)</string>
|
||||
<string name="buzz_canvas_conflict">किसी अन्य ने इस चित्रपट का परिवर्तन किया जब से आपने सम्पादन आरम्भ किया। अपने परिवर्तनों की अनुकृति करें। चित्रपट पुनःखोलें तथा उनको पुनःलागू करें।</string>
|
||||
<string name="buzz_canvas_head_in_future">वर्तमान चित्रपट समयांकित है बहुत दूर के भविष्य में। इसलिए सुरक्षित रूप से सम्पादन करना असाध्य।</string>
|
||||
<string name="buzz_canvas_save_failed">चित्रपट का अभिलेखन असफल। %1$s</string>
|
||||
<string name="buzz_system_admin_kick">%1$s को एक प्रशासक द्वारा हटाया गया</string>
|
||||
<string name="buzz_system_channel_auto_archived">इस प्रणाली को संगृहीत किया गया स्वचालित रूप से</string>
|
||||
<string name="buzz_editing_banner">सन्देश सम्पादन</string>
|
||||
<string name="message_edited">(सम्पादित)</string>
|
||||
<string name="buzz_typing_one">%1$s टंकण मध्य…</string>
|
||||
@@ -3093,6 +3136,12 @@
|
||||
<string name="buzz_attest_hold_title">साक्ष्यांकन रखें</string>
|
||||
<string name="buzz_attest_holding">एक साक्ष्यांकन रखा जा रहा है इस लेखा के लिए। यह स्वचालित रूप से संलग्न किया जाएगा जब आप एक बज्स्स पुनःप्रसारक प्रति प्रमाणीकरण करेंगे।</string>
|
||||
<string name="buzz_attest_grants_prefix">देता है : %1$s</string>
|
||||
<string name="buzz_huddle_live">गोष्ठी वर्तमान</string>
|
||||
<string name="buzz_agent_label">घटक</string>
|
||||
<string name="buzz_agent_managed_by">%1$s द्वारा प्रबन्धित</string>
|
||||
<string name="buzz_agent_managed_by_you">आपके द्वारा प्रबन्धित</string>
|
||||
<string name="buzz_attest_expired">यह साक्ष्यांकन पुराना हो चुका। बज्स्स पुनःप्रसारक अब इसे नहीं स्वीकारते। स्वत्वधारी को एक नए के लिए पूछें।</string>
|
||||
<string name="buzz_attest_not_yet_valid">यह साक्ष्यांकन मान्य नहीं अब तक। बज्स्स पुनःप्रसारक इसे स्वीकारेंगे जब इसका आरम्भ समय बीतेगा।</string>
|
||||
<string name="buzz_attest_grants_unrestricted">किसी भी प्रकार तथा कभी भी (सीमित नहीं)</string>
|
||||
<string name="buzz_attest_remove">हटाएँ</string>
|
||||
<string name="buzz_attest_hold_desc">एक स्वत्वधारी हस्ताक्षरित प्रमाणीकरण विषयसूचक चिपकाएँ जो इस लेखा को दिया गया था अपने बज्स्स कार्यशाला प्रति प्रमाणीकरण के लिए अभासी सदस्य के रूप में।</string>
|
||||
@@ -3122,6 +3171,12 @@
|
||||
<string name="buzz_persona_provider">प्रदाता (विकल्पात्मक)</string>
|
||||
<string name="buzz_persona_runtime">चालनपर्यावरण (विकल्पात्मक)</string>
|
||||
<string name="buzz_persona_avatar">परिचायकचित्र जालपता (वैकल्पिक)</string>
|
||||
<string name="buzz_persona_description">विवरण (विकल्पात्मक)</string>
|
||||
<string name="buzz_persona_description_help">सार्वजनिक। २८० अक्षर तक।</string>
|
||||
<string name="buzz_persona_shared">कार्यशाला के साथ बाँटें</string>
|
||||
<string name="buzz_persona_shared_help">अन्य सदस्य इस व्यक्तिशैली को देख सकते हैं तथा इसका प्रयोग कर सकते हैं। निष्क्रिय करने से यह आपके यन्त्रों में ही रहेगा।</string>
|
||||
<string name="buzz_persona_thread_sessions">एक वार्तालाप प्रति सूत्र</string>
|
||||
<string name="buzz_persona_thread_sessions_help">घटक एक पृथक वार्तालाप रखता है प्रति सूत्र के लिए। प्रति प्रणाली के स्थान पर।</string>
|
||||
<string name="buzz_persona_publishing">प्रकाशन…</string>
|
||||
<string name="buzz_persona_publish">व्यक्तिशैली प्रकाशन</string>
|
||||
<string name="profile_card_follows_you">आपका अनुचर</string>
|
||||
@@ -3562,20 +3617,45 @@
|
||||
<item quantity="one">%1$d प्रणाली</item>
|
||||
<item quantity="other">%1$d प्रणालियाँ</item>
|
||||
</plurals>
|
||||
<string name="concord_channel_access_role_label">अभिगमन पात्र नाम</string>
|
||||
<string name="concord_channel_create">नयी प्रणाली</string>
|
||||
<string name="concord_channel_delete">प्रणाली मिटाएँ</string>
|
||||
<string name="concord_channel_delete_confirm">मिटाएँ</string>
|
||||
<string name="concord_channel_delete_message">क्या #%1$s को मिटा दें। इसको पूर्ववत नहीं किया जा सकता। तथा उसी सूचक के साथ प्रणाली का पुनःउत्पादन नहीं किया जा सकता।</string>
|
||||
<string name="concord_channel_delete_title">क्या प्रणाली मिटा दें।</string>
|
||||
<string name="concord_channel_make_private">निजी बनाएँ</string>
|
||||
<string name="concord_channel_make_private_message">#%1$s को अपनी कुंचिका प्राप्त होगी आगे से। तथा %2$s पात्र निर्धारित करेगा कि कौन इसे पढ सकता है। किसी के पास वह पात्र नहीं अब तक। उन सदस्यों को दें जिनको अभिगमन अधिकार होना चाहिए। सन्देश जो पहले से प्रकाशित हैं पठनशक्य रहेंगे समुदाय में सब के लिए।</string>
|
||||
<string name="concord_channel_make_public">सार्वजनिक बनाएँ</string>
|
||||
<string name="concord_channel_make_public_message">समुदाय में सभी सदस्य #%1$s को पढ सकेंगे आगे से। सन्देश जो प्रकाशित थे जब वह निजी था उन सदस्यों के लिए ही पठनशक्य रहेंगे जिनके पास उसकी कुंचिका थी।</string>
|
||||
<string name="concord_channel_name_label">प्रणाली नाम</string>
|
||||
<string name="concord_channel_no_messages">कोई सन्देश नहीं अब तक</string>
|
||||
<string name="concord_channel_private_hint">केवल इसके अभिगमन पात्र रखनेवाले सदस्य इसे पढ सकते हैं। पात्र प्रदान करें उनको भीतर लाने के लिए। इसके निराकरण से प्रणाली कुंचिका का वर्तन होगा।</string>
|
||||
<string name="concord_channel_private_toggle">निजी प्रणाली</string>
|
||||
<string name="concord_channel_rename">प्रणाली पुनःनामकरण</string>
|
||||
<string name="concord_channel_rename_save">पुनःनामकरण</string>
|
||||
<string name="concord_channel_rotate_key">कुंचिका वर्तन</string>
|
||||
<string name="concord_channels_empty">कोई प्रणाली नहीं अब तक।</string>
|
||||
<string name="concord_create_action">बनाएँ</string>
|
||||
<string name="concord_create_failed">समुदाय बनाया नहीं जा सका। कोई पुनःप्रसारक ने उसे स्वीकारा नहीं। अथवा आपकी समुदाय सूची का अद्यतन असफल। पुनःप्रसारकों की जाँच करें तथा पुनःप्रयास करें।</string>
|
||||
<string name="concord_create_relays">पुनःप्रसारक</string>
|
||||
<string name="concord_create_relays_desc">पुनःप्रसारक जो इस समुदाय के रहस्यीकृत सन्देशों को रखते हैं। रिक्त छोड दें अपने ही पुनःप्रसारकों का उपयोग करने के लिए।</string>
|
||||
<string name="concord_create_title">नयी कोंकोर्ड प्रणाली</string>
|
||||
<string name="concord_direct_invite_accept">स्वीकार</string>
|
||||
<string name="concord_direct_invite_accept_failed">यह समुदाय अभिगम्य नहीं। कुछ क्षण पश्चात पुनःप्रयास करें।</string>
|
||||
<string name="concord_direct_invite_action">आमन्त्रणकर्ता npub…</string>
|
||||
<string name="concord_direct_invite_catch_up">नए प्रणालियाँ एक समुदाय के लिए जिसमें आप हैं। %1$s</string>
|
||||
<string name="concord_direct_invite_decline">अस्वीकार</string>
|
||||
<string name="concord_direct_invite_explainer">आमन्त्रण इस व्यक्ति के लिए रहस्यीकृत है तथा उनके आगतपेटिका पुनःप्रसारकों तक भेजा गया। यह वहन करता है केवल उन निजी प्रणालियों को जो उनके पात्र उनको देते हैं। तथा इसका निराकरण नहीं किया जा सकता भेजने के पश्चात।</string>
|
||||
<string name="concord_direct_invite_expired">यह आमन्त्रण पुराना हो चुका</string>
|
||||
<string name="concord_direct_invite_failed">आमन्त्रण को इस व्यक्ति के आगतपेटिका पुनःप्रसारकों तक भेजने में असफल।</string>
|
||||
<string name="concord_direct_invite_failed_banned">यह व्यक्ति इस समुदाय में से प्रतिबन्धित है।</string>
|
||||
<string name="concord_direct_invite_failed_loading">समुदाय आवहन अब भी चालू। कुछ क्षण पश्चात पुनःप्रयास करें।</string>
|
||||
<string name="concord_direct_invite_failed_member">आप इस समुदाय में लोगों को आमन्त्रित नहीं कर सकते।</string>
|
||||
<string name="concord_direct_invite_from">%1$s द्वारा आमन्त्रित</string>
|
||||
<string name="concord_direct_invite_hint">नाम एनपुब॰ अथवा निप॰पाँच</string>
|
||||
<string name="concord_direct_invite_send">%1$s को आमन्त्रण भेजें</string>
|
||||
<string name="concord_direct_invite_title">किसी को सीधे आमन्त्रित करें</string>
|
||||
<string name="concord_direct_invites_title">समुदाय आमन्त्रण</string>
|
||||
<string name="concord_edit_title">समुदाय सम्पादन</string>
|
||||
<string name="concord_editing_banner">सन्देश सम्पादन</string>
|
||||
<string name="concord_home_title">कोंकोर्ड प्रणाली</string>
|
||||
@@ -3585,6 +3665,7 @@
|
||||
<string name="concord_invite_failed_expired">यह आमन्त्रण योजक पुराना हो चुका है तथा अनुपयुक्तव्य। नए योजक के लिए पूछें।</string>
|
||||
<string name="concord_invite_failed_incompatible">इस आमन्त्रण को खोला नहीं जा सकता। सम्भावना है कि यह पुराना हो चुका है अथवा नवीनतर द्वारा प्रतिस्थापित अथवा एक नवीनतर क्रमक संस्करण द्वारा बनाया गया। एक नए आमन्त्रण योजक के लिए पूछें।</string>
|
||||
<string name="concord_invite_failed_invalid">यह आमन्त्रण योजक अमान्य है अथवा इस लेखा द्वारा खोला नहीं जा सकता।</string>
|
||||
<string name="concord_invite_failed_not_saved">आमन्त्रण कार्य करता है। पर आपकी समुदाय सूची का अद्यतन असफल। इसलिए आप जुडे नहीं। कुछ क्षण पश्चात पुनःप्रयास करें।</string>
|
||||
<string name="concord_invite_failed_revoked">यह आमन्त्रण योजक निराकृत है तथा अनुपयुक्तव्य। नए के लिए पूछें।</string>
|
||||
<string name="concord_invite_links_action">आमन्त्रण योजक…</string>
|
||||
<string name="concord_invite_links_title">आमन्त्रण योजक</string>
|
||||
@@ -3601,6 +3682,8 @@
|
||||
<item quantity="other">%1$d सदस्य</item>
|
||||
</plurals>
|
||||
<string name="concord_members_ban">प्रतिबन्ध</string>
|
||||
<string name="concord_members_ban_title">क्या सदस्य को प्रतिबन्धित करें।</string>
|
||||
<string name="concord_members_ban_message">वे आगे से पत्र प्रकाशित नहीं कर सकते। तथा उनके सन्देश सब से छिपाए गए। अन्य क्रमक समुदाय को उनकी सूची से हटा सकते हैं। वे नए सन्देश पढ सकते हैं जब तक कि आप उनको भी समुदाय से नहीं हटाते। आप उनको अप्रतिबन्धित कर सकते हैं भविष्य में।</string>
|
||||
<string name="concord_members_make_admin">प्रशासक बनाएँ</string>
|
||||
<string name="concord_members_remove_admin">प्रशासक हटाएँ</string>
|
||||
<string name="concord_members_roles_failed">इस सदस्य के भूमिकाओं का अद्यतन करने में असफल।</string>
|
||||
@@ -4059,6 +4142,9 @@
|
||||
<string name="kind_profile_gallery">परिचय चित्रालय</string>
|
||||
<string name="kind_proxy_relays">प्रतिनिधि पुनःप्रसारक</string>
|
||||
<string name="kind_public_message">सार्वजनिक संदेश</string>
|
||||
<string name="kind_push_registration">प्रेषण पंजीकरण</string>
|
||||
<string name="kind_push_deregistration">प्रेषण पंजीकरण हटाएँ</string>
|
||||
<string name="kind_push_preferences">प्रेषण आद्यताएँ</string>
|
||||
<string name="kind_reactions">प्रतिक्रियाएँ</string>
|
||||
<string name="kind_relay_auth">पुनःप्रसारक प्रमाणीकरण</string>
|
||||
<string name="kind_relay_discovery">पुनःप्रसारक खोज</string>
|
||||
@@ -4083,6 +4169,7 @@
|
||||
<string name="kind_video">चलचित्र</string>
|
||||
<string name="kind_video_collaboration">चलचित्र सहयोग</string>
|
||||
<string name="kind_video_list">चलचित्र सूची</string>
|
||||
<string name="kind_video_views">चलचित्र दृष्टसंख्या</string>
|
||||
<string name="kind_video_repl">चलचित्र (अनु)</string>
|
||||
<string name="kind_video_subtitles">चलचित्र उपशीर्षक</string>
|
||||
<string name="kind_voice_msg">ध्वनि सन्देश</string>
|
||||
@@ -4522,7 +4609,7 @@
|
||||
<string name="nudity">नग्नता</string>
|
||||
<string name="nutzap_failed_no_event">घटना उद्धरण बनाने में अक्षम</string>
|
||||
<string name="nutzap_failed_no_recipient">कोई प्राप्तकर्ता ख्याप्यकुंचिका नहीं टीका में</string>
|
||||
<string name="nutzap_failed_private_note">नटज्साप॰ सार्वजनिक हैं तथा इस निजी टीका को प्रकाशित कर देंगे। लैटनिंग॰ ज्साप का प्रयोग करें इसके स्थान पर।</string>
|
||||
<string name="nutzap_failed_private_note">नटज्साप सार्वजनिक हैं तथा इस निजी टीका को प्रकाशित कर देंगे। लैटनिंग ज्साप का प्रयोग करें इसके स्थान पर।</string>
|
||||
<string name="nutzap_failed_title">नटज्साप॰ असफल</string>
|
||||
<plurals name="onchain_send_button_amount_split">
|
||||
<item quantity="one">%1$s साट्स भेजें %2$d धा विभक्त</item>
|
||||
@@ -4551,8 +4638,8 @@
|
||||
<item quantity="other">%1$d प्राप्तकर्ताओं में विभाजन</item>
|
||||
</plurals>
|
||||
<plurals name="onchain_send_splits_skipped">
|
||||
<item quantity="one">%1$d केवल लैटनिंग॰ पता प्राप्तकर्ता छोड दिया गया। खण्डश्रृंखलाबद्ध के लिए नोस्टर ख्याप्यकुंचिका आवश्यक है पता की व्युत्पत्ति के लिए।</item>
|
||||
<item quantity="other">%1$d केवल लैटनिंग॰ पता प्राप्तकर्ताओं को छोड दिया गया। खण्डश्रृंखलाबद्ध के लिए नोस्टर ख्याप्यकुंचिका आवश्यक है पता की व्युत्पत्ति के लिए।</item>
|
||||
<item quantity="one">%1$d केवल लैटनिंग पता प्राप्तकर्ता छोड दिया गया। खण्डश्रृंखलाबद्ध के लिए नोस्टर ख्याप्यकुंचिका आवश्यक है पता की व्युत्पत्ति के लिए।</item>
|
||||
<item quantity="other">%1$d केवल लैटनिंग पता प्राप्तकर्ताओं को छोड दिया गया। खण्डश्रृंखलाबद्ध के लिए नोस्टर ख्याप्यकुंचिका आवश्यक है पता की व्युत्पत्ति के लिए।</item>
|
||||
</plurals>
|
||||
<plurals name="onchain_send_use_note_split">
|
||||
<item quantity="one">इस टीका का %1$d धा ज्साप विभाजन का प्रयोग करें</item>
|
||||
@@ -4631,7 +4718,7 @@
|
||||
<string name="podcast_value_keysend_not_supported">आपका धनकोष कुंचिकाप्रेषण का अवलम्बन नहीं करता। इसलिए जालबिन्दु प्राप्तकर्ताओं को छोड दिया गया।</string>
|
||||
<string name="podcast_value_no_recipients">इस पुटप्रसार का कोई भुगतान सम्भाव्य मूल्य प्राप्तकर्ता नहीं।</string>
|
||||
<string name="podcast_value_stream_requires_wallet">एक नोस्टर धनकोष संयोजन अथवा ऋणांकन धनकोष जोडें सुनते समय साट्स प्रवाह चलाने के लिए।</string>
|
||||
<string name="podcast_value_user_no_lnaddress">इस प्रयोक्ता का कोई लैटनिंग॰ पता नहीं</string>
|
||||
<string name="podcast_value_user_no_lnaddress">इस प्रयोक्ता का कोई लैटनिंग पता नहीं</string>
|
||||
<string name="poll_author_no_vote">मतदानकर्ता अपने ही मतदान में निर्वाचन नहीं कर सकते।</string>
|
||||
<string name="poll_consensus_threshold_percent">(० से १००) प्रतिशत</string>
|
||||
<string name="poll_is_closed_explainer">मतदान आवृत है नये निर्वाचनों के लिए</string>
|
||||
@@ -5368,7 +5455,6 @@
|
||||
<string name="new_conversation_cordn_pro_1">आरम्भ से अन्त तक रहस्यीकृत एमएलएस॰ के साथ। समायोजक सन्देश कभी पढ नहीं सकता।</string>
|
||||
<string name="new_conversation_cordn_pro_2">एक स्वीकृत अनुक्रमण सभी के लिए। जिससे इतिहास को अस्तव्यस्त नहीं किया जा सकता एक भ्रष्ट घडी से।</string>
|
||||
<string name="new_conversation_cordn_con_1">समायोजक जान लेता है समूह में कौन हैं तथा आप कब बात करते है। यद्यपि वह पढ नहीं सकता कि आप ने क्या कहा।</string>
|
||||
<string name="cordn_invitations_entry">क्या आप एक कोर्डन समूह में आमन्त्रित हैं।</string>
|
||||
<string name="cordn_invitations_entry_action">आमन्त्रण पुनरीक्षण</string>
|
||||
<!-- cordn device migration (spec/applications/multi-device.md §9/§11), as a
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
@@ -6159,4 +6245,14 @@
|
||||
<string name="cordn_voice_play">ध्वनि टीका चलाएँ</string>
|
||||
<string name="cordn_voice_record">ध्वनि टीका का अभिलेखन करें</string>
|
||||
<string name="cordn_voice_stop">रोकें तथा भेजें</string>
|
||||
<string name="browser_address_hint">ढूँढें अथवा पता प्रविष्ट करें</string>
|
||||
<string name="napplet_untitled">शीर्षकरहित नोस्टर संलग्नक्रमक</string>
|
||||
<string name="browser_permission_section">जालस्थान अनुमतियाँ</string>
|
||||
<string name="browser_permission_camera">चित्रग्राहक का प्रयोग</string>
|
||||
<string name="browser_permission_microphone">ध्वनिग्राहक का प्रयोग</string>
|
||||
<string name="browser_permission_location">स्थूलतः स्थान की जानकारी</string>
|
||||
<string name="browser_permission_ask">पूछें</string>
|
||||
<string name="browser_permission_allowed">अनुमत</string>
|
||||
<string name="browser_permission_blocked">बाधित</string>
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -577,6 +577,14 @@
|
||||
<string name="concord_invite_revoke_title">Biztosan visszavonja ezt a hivatkozást?</string>
|
||||
<string name="concord_invite_revoke_explainer">Aki még mindig rendelkezik ezzel a hivatkozással, az már nem tud csatlakozni. Azok, akik már csatlakoztak vele, továbbra is a közösség tagjai maradnak. Ez a művelet nem vonható vissza.</string>
|
||||
<string name="concord_invite_revoke_confirm">Visszavonás</string>
|
||||
<string name="concord_invite_revoke_privatize_warning">Ez a közösség utolsó élő meghívási hivatkozása. A visszavonása priváttá teszi a közösséget, és lecseréli a kulcsait, így csak a jelenlegi tagok tartják meg a hozzáférést.</string>
|
||||
<string name="concord_invite_revoked_privatized">A hivatkozás visszavonva. A közösség most privát, és a kulcsai le lettek cserélve.</string>
|
||||
<string name="concord_invite_revoked_privatize_pending">A hivatkozás visszavonva. A közösség most privát, de a kulcsai nem lettek lecserélve: egy olyan adminisztrátornak kell lecserélnie őket, aki kitilthat tagokat.</string>
|
||||
<string name="concord_mode_private">Privát</string>
|
||||
<plurals name="concord_mode_public">
|
||||
<item quantity="one">Nyilvános · %1$d élő meghívási hivatkozás</item>
|
||||
<item quantity="other">Nyilvános · %1$d élő meghívóhivatkozás</item>
|
||||
</plurals>
|
||||
<string name="concord_invite_preview_unknown_name">A közösség neve csak a csatlakozás után válik láthatóvá</string>
|
||||
<string name="concord_invite_preview_explainer">A csatlakozás kapcsolódik a meghívóhoz tartozó átjátszókhoz, közzéteszi a fiókja által aláírt csatlakozási értesítést, és hozzáadja a közösséget az Ön listájához. Semmi sem kerül elküldésre, amíg a Csatlakozás gombra nem koppint.</string>
|
||||
<string name="concord_invite_preview_relays">Átjátszók, amelyekkel ez a meghívó felveszi a kapcsolatot: %1$s</string>
|
||||
@@ -585,7 +593,28 @@
|
||||
<string name="concord_open_channel">Csatorna megnyitása</string>
|
||||
<string name="concord_edit_banner_hint">Banner hozzáadása</string>
|
||||
<string name="concord_edit_relays_desc">Ahol a közösség titkosított síkjait közzéteszik és olvassák.</string>
|
||||
<string name="concord_timer_title">Eltűnő üzenetek</string>
|
||||
<string name="concord_timer_desc">Az új üzenetek minden csatornában ennyi idő után törlődnek a tagok eszközeiről és az átjátszókról. A módosítása nem érinti a már elküldött üzeneteket. Aki el tud olvasni egy üzenetet, az továbbra is lemásolhatja.</string>
|
||||
<string name="concord_timer_off">Ki</string>
|
||||
<string name="concord_timer_notice_set">%1$s %2$s értékre állította az eltűnő üzeneteket</string>
|
||||
<string name="concord_timer_notice_off">%1$s kikapcsolta az eltűnő üzeneteket</string>
|
||||
<string name="concord_timer_active">Az üzenetek %1$s után eltűnnek</string>
|
||||
<string name="concord_dissolved_read_only">Ezt a közösséget feloszlatták, és most már csak olvasható. Az előzményeket továbbra is olvashatja, de új üzeneteket már nem lehet közzétenni.</string>
|
||||
<string name="concord_private_channel_no_key">Ez egy privát csatorna, és Ön nem rendelkezik a kulcsával, így nem tudja elolvasni vagy megjegyzést írni.</string>
|
||||
<string name="concord_pinned_title">Rögzített üzenetek</string>
|
||||
<string name="concord_pinned_empty">Ebben a csatornában még nincsenek rögzített üzenetek.</string>
|
||||
<string name="concord_pinned_unavailable">Ennek a csatornának a kitűzött üzenetei olyan kulcs alá vannak zárva, amellyel Ön nem rendelkezik, így itt nem jeleníthetők meg, és a kitűzés szünetel, amíg el nem olvassa azokat.</string>
|
||||
<string name="concord_pinned_budget">%2$d rögzítésből %1$d · a méretkeret %3$d%-a használatban van</string>
|
||||
<string name="concord_pinned_open_hint">Koppintson egy kitűzött üzenetre az odajutáshoz</string>
|
||||
<string name="concord_pin_expiring_title">Kitűz egy eltűnő üzenetet?</string>
|
||||
<string name="concord_pin_expiring_body">Ez az üzenet eltűnésre van beállítva. A kitűzése megtartja a szövegét a csatorna kitűzési listájában, miután az időzítő törli az üzenetet.</string>
|
||||
<string name="concord_pin_expiring_confirm">Kitűzés mindenképp</string>
|
||||
<string name="concord_pin_failed_title">Kitűzések</string>
|
||||
<string name="concord_pin_failed_unavailable">A kitűzési lista olyan kulcs alá van zárva, amellyel Ön nem rendelkezik. A mostani módosítása olyan kitűzéseket dobna el, amelyeket most nem lát.</string>
|
||||
<string name="concord_pin_failed_too_many">Ennek a csatornának már 25 kitűzése van. Először oldja fel az egyik kitűzést.</string>
|
||||
<string name="concord_pin_failed_too_large">A kitűzési lista megtelt. Először szüntesse meg az egyik üzenet kitűzését.</string>
|
||||
<string name="concord_pin_failed_message">Ennek az üzenetnek az eredeti aláírása nincs itt, így nem bizonyítható és nem tűzhető ki.</string>
|
||||
<string name="concord_pin_failed_generic">Most nem tudja módosítani ennek a csatornának a kitűzéseit.</string>
|
||||
<string name="concord_create_name">Név</string>
|
||||
<string name="concord_create_about">Leírás (nem kötelező)</string>
|
||||
<string name="concord_ban_user">Kitiltás</string>
|
||||
@@ -606,6 +635,15 @@
|
||||
<string name="concord_role_owner">Tulajdonos</string>
|
||||
<string name="concord_role_admin">Adminisztrátor</string>
|
||||
<string name="concord_role_banned">Kitiltva</string>
|
||||
<string name="concord_role_kicked">Kirúgva</string>
|
||||
<string name="concord_role_left">Elhagyta a csoportot</string>
|
||||
<string name="concord_members_kick">Kirúgás</string>
|
||||
<string name="concord_members_kick_title">Kirúgja ezt a tagot?</string>
|
||||
<string name="concord_members_kick_message">A szerepköreik el lesznek távolítva, és az alkalmazásukat felkérik, hogy hagyja el a közösséget. A kirúgás együttműködésen alapul: nem változtatja meg a kulcsokat, és újra csatlakozhatnak egy meghívással. A hozzáférésük megszüntetéséhez inkább tiltsa ki vagy távolítsa el őket.</string>
|
||||
<string name="concord_members_kick_failed">Nem sikerült kirúgni ezt a tagot.</string>
|
||||
<string name="concord_kicked_title">Eltávolítva a közösségből</string>
|
||||
<string name="concord_kicked_message">Egy moderátor eltávolította Önt a(z) %1$s közösségből. Új meghívással újra csatlakozhat.</string>
|
||||
<string name="concord_kicked_message_unnamed">Egy moderátor eltávolította Önt egy közösségből. Új meghívással újra csatlakozhat.</string>
|
||||
<string name="concord_invite_card_join">Csatlakozás a közösséghez</string>
|
||||
<string name="concord_invite_card_subtitle">Concord közösségi meghívó</string>
|
||||
<string name="concord_invite_naddr_label">Concord meghívó (a csatlakozáshoz nyissa meg a teljes meghívóhivatkozást)</string>
|
||||
@@ -2299,6 +2337,11 @@
|
||||
<string name="buzz_canvas_edit">Vászon szerkesztése</string>
|
||||
<string name="buzz_canvas_save">Vászon mentése</string>
|
||||
<string name="buzz_canvas_body_label">Vászon (Markdown)</string>
|
||||
<string name="buzz_canvas_conflict">Valaki más megváltoztatta ezt a vásznat, mióta szerkeszteni kezdte. Másolja ki a módosításait, nyissa meg újra a vásznat, és alkalmazza őket újra.</string>
|
||||
<string name="buzz_canvas_head_in_future">A jelenlegi vászon időbélyege túl messze van a jövőben ahhoz, hogy biztonságosan szerkeszthető legyen.</string>
|
||||
<string name="buzz_canvas_save_failed">Nem sikerült menteni a vásznat: %1$s</string>
|
||||
<string name="buzz_system_admin_kick">%1$s el lett távolítva egy adminisztrátor által</string>
|
||||
<string name="buzz_system_channel_auto_archived">Ez a csatorna automatikusan archiválva lett</string>
|
||||
<string name="buzz_editing_banner">Üzenet szerkesztése</string>
|
||||
<string name="message_edited">(szerkesztve)</string>
|
||||
<string name="buzz_typing_one">%1$s ír…</string>
|
||||
@@ -3093,6 +3136,12 @@
|
||||
<string name="buzz_attest_hold_title">Egy igazolás megtartása</string>
|
||||
<string name="buzz_attest_holding">Egy igazolás megtartása ehhez a fiókhoz. Ez automatikusan csatolásra kerül, amikor hitelesít egy Buzz-átjátszón.</string>
|
||||
<string name="buzz_attest_grants_prefix">Engedélyek: %1$s</string>
|
||||
<string name="buzz_huddle_live">Élő megbeszélés</string>
|
||||
<string name="buzz_agent_label">Ügynök</string>
|
||||
<string name="buzz_agent_managed_by">kezeli: %1$s</string>
|
||||
<string name="buzz_agent_managed_by_you">Ön kezeli</string>
|
||||
<string name="buzz_attest_expired">Ez a tanúsítvány lejárt. A Buzz átjátszók már nem fogadják el; kérjen újat a tulajdonostól.</string>
|
||||
<string name="buzz_attest_not_yet_valid">Ez a tanúsítvány még nem érvényes. A Buzz átjátszók elfogadják, amint a kezdési időpontja elérkezik.</string>
|
||||
<string name="buzz_attest_grants_unrestricted">bármilyen típus, bármikor (korlátozás nélkül)</string>
|
||||
<string name="buzz_attest_remove">Eltávolítás</string>
|
||||
<string name="buzz_attest_hold_desc">Illesszen be egy tulajdonos által aláírt hitelesítési címkét, amelyet ennek a fióknak állítottak ki, hogy virtuális tagként hitelesítse magát a Buzz-munkaterületükön.</string>
|
||||
@@ -3122,6 +3171,12 @@
|
||||
<string name="buzz_persona_provider">Szolgáltató (nem kötelező)</string>
|
||||
<string name="buzz_persona_runtime">Futtatókörnyezet (nem kötelező)</string>
|
||||
<string name="buzz_persona_avatar">Avatar webcíme (nem kötelező)</string>
|
||||
<string name="buzz_persona_description">Leírás (nem kötelező)</string>
|
||||
<string name="buzz_persona_description_help">Nyilvános, legfeljebb 280 karakter.</string>
|
||||
<string name="buzz_persona_shared">Megosztás a munkaterülettel</string>
|
||||
<string name="buzz_persona_shared_help">Más tagok láthatják és használhatják ezt a személyazonosságot. Kikapcsolva csak a saját eszközein marad.</string>
|
||||
<string name="buzz_persona_thread_sessions">Egy beszélgetés beszélgetésenként</string>
|
||||
<string name="buzz_persona_thread_sessions_help">Az ügynök minden beszélgetéshez külön beszélgetést vezet a csatornánkénti egy helyett.</string>
|
||||
<string name="buzz_persona_publishing">Közzététel…</string>
|
||||
<string name="buzz_persona_publish">Személyiség közzététele</string>
|
||||
<string name="profile_card_follows_you">Követi Önt</string>
|
||||
@@ -3562,20 +3617,45 @@
|
||||
<item quantity="one">%1$d csatorna</item>
|
||||
<item quantity="other">%1$d csatorna</item>
|
||||
</plurals>
|
||||
<string name="concord_channel_access_role_label">Hozzáférési szerepkör neve</string>
|
||||
<string name="concord_channel_create">Új csatorna</string>
|
||||
<string name="concord_channel_delete">Csatorna törlése</string>
|
||||
<string name="concord_channel_delete_confirm">Törlés</string>
|
||||
<string name="concord_channel_delete_message">Biztosan törli a(z) #%1$s csatornát? Ez a művelet nem vonható vissza, és a csatorna nem hozható létre újra ugyanezzel az azonosítóval.</string>
|
||||
<string name="concord_channel_delete_title">Biztosan törli a csatornát?</string>
|
||||
<string name="concord_channel_make_private">Priváttá tétel</string>
|
||||
<string name="concord_channel_make_private_message">#%1$s mostantól saját kulcsot kap, és a(z) „%2$s” szerepkör dönti el, ki olvashatja. Ezt a szerepkört még senki sem birtokolja: adja meg azoknak a tagoknak, akiknek hozzáféréssel kell rendelkezniük. A már közzétett üzenetek továbbra is olvashatók maradnak a közösség minden tagja számára.</string>
|
||||
<string name="concord_channel_make_public">Nyilvánossá tétel</string>
|
||||
<string name="concord_channel_make_public_message">A közösség minden tagja olvashatja mostantól a(z) #%1$s csatornát. A privát állapotban közzétett üzenetek csak azoknak a tagoknak maradnak olvashatók, akik birtokolták a kulcsát.</string>
|
||||
<string name="concord_channel_name_label">Csatorna neve</string>
|
||||
<string name="concord_channel_no_messages">Még nincsenek üzenetek</string>
|
||||
<string name="concord_channel_private_hint">Csak azok a tagok olvashatják, akik birtokolják a hozzáférési szerepkörét. Adja meg a szerepkört, hogy beengedje őket; a visszavonása lecseréli a csatornakulcsot.</string>
|
||||
<string name="concord_channel_private_toggle">Privát csatorna</string>
|
||||
<string name="concord_channel_rename">Csatorna átnevezése</string>
|
||||
<string name="concord_channel_rename_save">Átnevezés</string>
|
||||
<string name="concord_channel_rotate_key">Kulcs cseréje</string>
|
||||
<string name="concord_channels_empty">Még nincsenek csatornák.</string>
|
||||
<string name="concord_create_action">Létrehozás</string>
|
||||
<string name="concord_create_failed">Nem sikerült létrehozni a közösséget: egyetlen átjátszó sem fogadta el, vagy a közösséglistáját nem sikerült frissíteni. Ellenőrizze az átjátszókat, és próbálja újra.</string>
|
||||
<string name="concord_create_relays">Átjátszók</string>
|
||||
<string name="concord_create_relays_desc">A közösség titkosított üzeneteit tároló átjátszók. Hagyja üresen a sajátjai használatához.</string>
|
||||
<string name="concord_create_title">Új Concord-csatorna</string>
|
||||
<string name="concord_direct_invite_accept">Elfogadás</string>
|
||||
<string name="concord_direct_invite_accept_failed">Nem sikerült elérni ezt a közösséget. Próbálja újra egy pillanat múlva.</string>
|
||||
<string name="concord_direct_invite_action">Meghívás npub alapján…</string>
|
||||
<string name="concord_direct_invite_catch_up">Új csatornák egy közösségben, amelynek Ön is tagja: %1$s</string>
|
||||
<string name="concord_direct_invite_decline">Elutasítás</string>
|
||||
<string name="concord_direct_invite_explainer">A meghívó ehhez a személyhez van titkosítva, és a bejövő átjátszóira kézbesítődik. Csak azokat a privát csatornákat tartalmazza, amelyeket a szerepkörei biztosítanak számára, és elküldés után nem vonható vissza.</string>
|
||||
<string name="concord_direct_invite_expired">Ez a meghívó lejárt</string>
|
||||
<string name="concord_direct_invite_failed">A meghívót nem sikerült kézbesíteni ennek a személynek a bejövő átjátszóira.</string>
|
||||
<string name="concord_direct_invite_failed_banned">Ez a személy ki van tiltva ebből a közösségből.</string>
|
||||
<string name="concord_direct_invite_failed_loading">Ez a közösség még töltődik. Próbálja újra egy pillanat múlva.</string>
|
||||
<string name="concord_direct_invite_failed_member">Nem hívhat meg embereket ebbe a közösségbe.</string>
|
||||
<string name="concord_direct_invite_from">Meghívta: %1$s</string>
|
||||
<string name="concord_direct_invite_hint">Név, npub vagy NIP-05</string>
|
||||
<string name="concord_direct_invite_send">Meghívó küldése %1$s részére</string>
|
||||
<string name="concord_direct_invite_title">Valaki közvetlen meghívása</string>
|
||||
<string name="concord_direct_invites_title">Közösségi meghívók</string>
|
||||
<string name="concord_edit_title">Közösség szerkesztése</string>
|
||||
<string name="concord_editing_banner">Üzenet szerkesztése</string>
|
||||
<string name="concord_home_title">Concord-csatornák</string>
|
||||
@@ -3585,6 +3665,7 @@
|
||||
<string name="concord_invite_failed_expired">Ez a meghívó hivatkozás lejárt és már nem használható. Kérjen egy új hivatkozást.</string>
|
||||
<string name="concord_invite_failed_incompatible">Ez a meghívó az alkalmazás egy újabb verziójával készült, és itt még nem nyitható meg. Kérjen egy frissített linket, vagy a frissítés után próbálja újra.</string>
|
||||
<string name="concord_invite_failed_invalid">Ez a meghívási hivatkozás érvénytelen, vagy nem nyitható meg ezzel a fiókkal.</string>
|
||||
<string name="concord_invite_failed_not_saved">A meghívó működik, de a közösséglistáját nem sikerült frissíteni, így nem csatlakozott. Próbálja újra egy pillanat múlva.</string>
|
||||
<string name="concord_invite_failed_revoked">Ezt a meghívási hivatkozást visszavonták, és már nem használható. Kérjen egy újat.</string>
|
||||
<string name="concord_invite_links_action">Meghívási hivatkozások…</string>
|
||||
<string name="concord_invite_links_title">Meghívási hivatkozások</string>
|
||||
@@ -3601,6 +3682,8 @@
|
||||
<item quantity="other">%1$d tag</item>
|
||||
</plurals>
|
||||
<string name="concord_members_ban">Kitiltás</string>
|
||||
<string name="concord_members_ban_title">Kitiltja a tagot?</string>
|
||||
<string name="concord_members_ban_message">Többé nem tehet közzé bejegyzéseket, és az üzenetei mindenki elől elrejtésre kerülnek. Más alkalmazások eltávolíthatják a közösséget a listájukról. Továbbra is olvashatják az új üzeneteket, amíg Ön el nem távolítja őket a közösségből. Később feloldhatja a kitiltásukat.</string>
|
||||
<string name="concord_members_make_admin">Kinevezés adminisztrátornak</string>
|
||||
<string name="concord_members_remove_admin">Adminisztrátori jog megvonása</string>
|
||||
<string name="concord_members_roles_failed">Nem sikerült frissíteni a tag szerepköreit.</string>
|
||||
@@ -4059,6 +4142,9 @@
|
||||
<string name="kind_profile_gallery">Profilgaléria</string>
|
||||
<string name="kind_proxy_relays">Proxyzott átjátszók</string>
|
||||
<string name="kind_public_message">Nyílvános üzenet</string>
|
||||
<string name="kind_push_registration">Push-regisztráció</string>
|
||||
<string name="kind_push_deregistration">Push-regisztráció törlése</string>
|
||||
<string name="kind_push_preferences">Push-beállítások</string>
|
||||
<string name="kind_reactions">Reakciók</string>
|
||||
<string name="kind_relay_auth">Átjátszóhitelesítés</string>
|
||||
<string name="kind_relay_discovery">Átjátszófelfedezés</string>
|
||||
@@ -4083,6 +4169,7 @@
|
||||
<string name="kind_video">Videó</string>
|
||||
<string name="kind_video_collaboration">Videó-együttműködés</string>
|
||||
<string name="kind_video_list">Videólista</string>
|
||||
<string name="kind_video_views">Videómegtekintések</string>
|
||||
<string name="kind_video_repl">Videó (válasz)</string>
|
||||
<string name="kind_video_subtitles">Videófeliratok</string>
|
||||
<string name="kind_voice_msg">Hangüzenet</string>
|
||||
@@ -5368,7 +5455,6 @@
|
||||
<string name="new_conversation_cordn_pro_1">Végponttól végpontig titkosított MLS-sel – a koordinátor soha nem olvashat el egyetlen üzenetet sem.</string>
|
||||
<string name="new_conversation_cordn_pro_2">Egy mindenki által elfogadott sorrend, így az előzményeket egy hibás óra sem keverheti össze.</string>
|
||||
<string name="new_conversation_cordn_con_1">A koordinátor tudja, ki van a csoportban és mikor beszélnek, még ha nem is tudja elolvasni, mit mondanak.</string>
|
||||
<string name="cordn_invitations_entry">Meghívták egy cordn csoportba?</string>
|
||||
<string name="cordn_invitations_entry_action">Meghívók áttekintése</string>
|
||||
<!-- cordn device migration (spec/applications/multi-device.md §9/§11), as a
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
@@ -6159,4 +6245,14 @@
|
||||
<string name="cordn_voice_play">Hangfelvétel lejátszása</string>
|
||||
<string name="cordn_voice_record">Hangfelvétel készítése</string>
|
||||
<string name="cordn_voice_stop">Leállítás és küldés</string>
|
||||
<string name="browser_address_hint">Keresés vagy cím megadása</string>
|
||||
<string name="napplet_untitled">Névtelen nKisalkalmazás</string>
|
||||
<string name="browser_permission_section">Oldalengedélyek</string>
|
||||
<string name="browser_permission_camera">Használja a kamerát</string>
|
||||
<string name="browser_permission_microphone">Használja a mikrofont</string>
|
||||
<string name="browser_permission_location">A hozzávetőleges helyzet megismerése</string>
|
||||
<string name="browser_permission_ask">Rákérdezés</string>
|
||||
<string name="browser_permission_allowed">Engedélyezve</string>
|
||||
<string name="browser_permission_blocked">Letiltva</string>
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -2856,4 +2856,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -2894,4 +2894,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -2914,4 +2914,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -2898,4 +2898,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -2982,4 +2982,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -4626,4 +4626,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -597,6 +597,12 @@
|
||||
<string name="concord_invite_revoked_privatized">Link został unieważniony. Społeczność ma teraz status prywatny, a jej klucze zostały odnowione.</string>
|
||||
<string name="concord_invite_revoked_privatize_pending">Link został unieważniony. Społeczność ma teraz status prywatny, ale jej klucze nie zostały odświeżone: administrator posiadający uprawnienia do blokowania członków musi je odświeżyć.</string>
|
||||
<string name="concord_mode_private">Prywatne</string>
|
||||
<plurals name="concord_mode_public">
|
||||
<item quantity="one">Tryb publiczny · %1$d – aktywny link zaproszeniowy</item>
|
||||
<item quantity="few">Tryb publiczny · %1$d – aktywnych linków zaproszeniowych</item>
|
||||
<item quantity="many">Tryb publiczny · %1$d – aktywnych linków zaproszeniowych</item>
|
||||
<item quantity="other">Tryb publiczny · %1$d – aktywne linki zaproszeniowe</item>
|
||||
</plurals>
|
||||
<string name="concord_invite_preview_unknown_name">Nazwa społeczności zostanie ujawniona dopiero po dołączeniu</string>
|
||||
<string name="concord_invite_preview_explainer">Dołączenie do transmiterów tego zaproszenia, publikuje komunikaty o dołączeniu podpisane przez twoje konto i dodaje społeczność do listy. Nic nie zostanie wysłane, dopóki nie klikniesz Dołącz.</string>
|
||||
<string name="concord_invite_preview_relays">Transmiter tego zaproszenia skontaktuje się: %1$s</string>
|
||||
@@ -616,8 +622,17 @@
|
||||
<string name="concord_pinned_title">Przypięte wiadomości</string>
|
||||
<string name="concord_pinned_empty">Brak przypiętych wiadomości na tym kanale.</string>
|
||||
<string name="concord_pinned_unavailable">Piny tego kanału są zabezpieczone kluczem, którego nie posiadasz, więc nie można ich tutaj wyświetlić, a przypinanie jest wstrzymane do czasu, aż będzie można je odczytać.</string>
|
||||
<string name="concord_pinned_budget">%1$d z %2$d pinów · wykorzystano %3$d% dostępnego budżetu</string>
|
||||
<string name="concord_pinned_open_hint">Kliknij pin, aby przejść do niego</string>
|
||||
<string name="concord_pin_expiring_title">Przypiąć znikającą wiadomość?</string>
|
||||
<string name="concord_pin_expiring_body">Ta wiadomość zostanie usunięta po upływie określonego czasu. Przypięcie jej sprawia, że jej treść pozostaje na liście przypiętych wiadomości w kanale nawet po tym, jak licznik czasu spowoduje usunięcie samej wiadomości.</string>
|
||||
<string name="concord_pin_expiring_confirm">Przypnij mimo wszystko</string>
|
||||
<string name="concord_pin_failed_title">Pineski</string>
|
||||
<string name="concord_pin_failed_unavailable">Przypięta lista jest zabezpieczona kluczem, którego nie posiadasz. Jej zmiana w tej chwili spowodowałaby usunięcie przypiętych elementów, których nie widzisz.</string>
|
||||
<string name="concord_pin_failed_too_many">Ten kanał ma już 25 pinów. Najpierw usuń jeden z nich.</string>
|
||||
<string name="concord_pin_failed_too_large">Na liście przypiętych nie ma już miejsca. Najpierw usuń przypinkę wiadomości.</string>
|
||||
<string name="concord_pin_failed_message">Oryginalny podpis tej wiadomości nie jest tutaj przechowywany, więc nie można go zweryfikować ani przypiąć.</string>
|
||||
<string name="concord_pin_failed_generic">W tej chwili nie można zmienić pinów tego kanału.</string>
|
||||
<string name="concord_create_name">Nazwa</string>
|
||||
<string name="concord_create_about">Informacje (opcjonalne)</string>
|
||||
<string name="concord_ban_user">Banuj</string>
|
||||
@@ -638,6 +653,15 @@
|
||||
<string name="concord_role_owner">Właściciel</string>
|
||||
<string name="concord_role_admin">Admin</string>
|
||||
<string name="concord_role_banned">Zbanowany</string>
|
||||
<string name="concord_role_kicked">Wyrzucony</string>
|
||||
<string name="concord_role_left">Zostawiony</string>
|
||||
<string name="concord_members_kick">Wyrzuć</string>
|
||||
<string name="concord_members_kick_title">Wyrzucić tego użytkownika?</string>
|
||||
<string name="concord_members_kick_message">Ich role zostają usunięte, a ich aplikacja otrzymuje prośbę o opuszczenie społeczności. Wykluczenie ma charakter kooperacyjny: nie powoduje zmiany żadnych kluczy, a użytkownik może ponownie dołączyć po otrzymaniu zaproszenia. Aby całkowicie zablokować mu dostęp, należy zamiast tego nałożyć na niego ban lub usunąć go.</string>
|
||||
<string name="concord_members_kick_failed">Nie udało się wykluczyć tego użytkownika.</string>
|
||||
<string name="concord_kicked_title">Usunięto ze społeczności</string>
|
||||
<string name="concord_kicked_message">Moderator usunął Cię z %1$s. Możesz dołączyć ponownie z nowym zaproszeniem.</string>
|
||||
<string name="concord_kicked_message_unnamed">Moderator usunął Cię ze społeczności. Możesz dołączyć ponownie z nowym zaproszeniem.</string>
|
||||
<string name="concord_invite_card_join">Dołącz do społeczności</string>
|
||||
<string name="concord_invite_card_subtitle">Zaproszenie dla społeczności Concord</string>
|
||||
<string name="concord_invite_naddr_label">Zaproszenie do Concord (kliknij link, aby wyświetlić pełną treść zaproszenia i dołączyć)</string>
|
||||
@@ -2337,6 +2361,11 @@
|
||||
<string name="buzz_canvas_edit">Edytuj płótno</string>
|
||||
<string name="buzz_canvas_save">Zapisz płótno</string>
|
||||
<string name="buzz_canvas_body_label">Płótno (Markdown)</string>
|
||||
<string name="buzz_canvas_conflict">Ktoś inny wprowadził zmiany w tym obszarze roboczym od momentu, gdy zacząłeś go edytować. Skopiuj swoje zmiany, otwórz ponownie obszar roboczy i zastosuj je ponownie.</string>
|
||||
<string name="buzz_canvas_head_in_future">Obecny obszar roboczy ma znacznik czasu wskazujący datę zbyt odległą w przyszłości, by można go było bezpiecznie edytować.</string>
|
||||
<string name="buzz_canvas_save_failed">Nie udało się zapisać obszaru roboczego: %1$s</string>
|
||||
<string name="buzz_system_admin_kick">%1$s został usunięty przez administratora</string>
|
||||
<string name="buzz_system_channel_auto_archived">Ten kanał został zarchiwizowany automatycznie</string>
|
||||
<string name="buzz_editing_banner">Edytuj wiadomość</string>
|
||||
<string name="message_edited">(edytowana)</string>
|
||||
<string name="buzz_typing_one">%1$s pisze…</string>
|
||||
@@ -5631,7 +5660,6 @@
|
||||
<string name="new_conversation_cordn_pro_1">Szyfrowanie typu „end-to-end” z wykorzystaniem MLS — koordynator nigdy nie ma możliwości odczytania wiadomości.</string>
|
||||
<string name="new_conversation_cordn_pro_2">Jedno uzgodnione postanowienie dla wszystkich, aby historia nie mogła zostać zmieniona przez błędny zegar.</string>
|
||||
<string name="new_conversation_cordn_con_1">Koordynator wie, kto należy do grupy i kiedy ktoś zabiera głos, mimo że nie potrafi odczytać treści wypowiedzi.</string>
|
||||
<string name="cordn_invitations_entry">Zostałeś zaproszony do grupy „cordn”?</string>
|
||||
<string name="cordn_invitations_entry_action">Przejrzyj zaproszenia</string>
|
||||
<!-- cordn device migration (spec/applications/multi-device.md §9/§11), as a
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
@@ -6526,4 +6554,5 @@
|
||||
<string name="cordn_voice_play">Odtwórz wiadomość głosową</string>
|
||||
<string name="cordn_voice_record">Nagraj wiadomość głosową</string>
|
||||
<string name="cordn_voice_stop">Zatrzymaj i wyślij</string>
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -5359,7 +5359,6 @@
|
||||
<string name="new_conversation_cordn_pro_1">Criptografia de ponta a ponta com MLS — o coordenador nunca consegue ler uma mensagem.</string>
|
||||
<string name="new_conversation_cordn_pro_2">Uma única ordem acordada para todos, então o histórico não pode ser embaralhado por um relógio errado.</string>
|
||||
<string name="new_conversation_cordn_con_1">O coordenador fica sabendo quem está no grupo e quando vocês conversam, mesmo sem conseguir ler o que é dito.</string>
|
||||
<string name="cordn_invitations_entry">Convidado para um grupo cordn?</string>
|
||||
<string name="cordn_invitations_entry_action">Ver convites</string>
|
||||
<!-- cordn device migration (spec/applications/multi-device.md §9/§11), as a
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
@@ -6150,4 +6149,5 @@
|
||||
<string name="cordn_voice_play">Reproduzir mensagem de voz</string>
|
||||
<string name="cordn_voice_record">Gravar uma mensagem de voz</string>
|
||||
<string name="cordn_voice_stop">Parar e enviar</string>
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -2914,4 +2914,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -3001,4 +3001,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -2982,4 +2982,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -5001,4 +5001,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -2959,4 +2959,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -5359,7 +5359,6 @@
|
||||
<string name="new_conversation_cordn_pro_1">End-to-end-krypterad med MLS — koordinatorn kan aldrig läsa ett meddelande.</string>
|
||||
<string name="new_conversation_cordn_pro_2">En gemensam ordning för alla, så att historiken inte kan kastas om av en felaktig klocka.</string>
|
||||
<string name="new_conversation_cordn_con_1">Koordinatorn får veta vilka som är med i gruppen och när ni pratar, även om den inte kan läsa vad ni säger.</string>
|
||||
<string name="cordn_invitations_entry">Inbjuden till en cordn-grupp?</string>
|
||||
<string name="cordn_invitations_entry_action">Granska inbjudningar</string>
|
||||
<!-- cordn device migration (spec/applications/multi-device.md §9/§11), as a
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
@@ -6150,4 +6149,5 @@
|
||||
<string name="cordn_voice_play">Spela upp röstmeddelande</string>
|
||||
<string name="cordn_voice_record">Spela in ett röstmeddelande</string>
|
||||
<string name="cordn_voice_stop">Stoppa och skicka</string>
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -2934,4 +2934,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -2923,4 +2923,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -2764,4 +2764,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -2930,4 +2930,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -2988,4 +2988,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -2939,4 +2939,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -2884,4 +2884,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -3900,4 +3900,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -2949,4 +2949,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -2949,4 +2949,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -2920,4 +2920,5 @@
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) -->
|
||||
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
|
||||
</resources>
|
||||
|
||||
@@ -5543,15 +5543,14 @@
|
||||
<string name="cordn_preview_photo">Photo</string>
|
||||
<string name="cordn_preview_video">Video</string>
|
||||
<string name="cordn_preview_file">File</string>
|
||||
<string name="new_conversation_cordn_title">Cordn group</string>
|
||||
<string name="new_conversation_cordn_tagline">Encrypted group chat ordered by a coordinator you pick.</string>
|
||||
<string name="new_conversation_cordn_title">Cordn Group</string>
|
||||
<string name="new_conversation_cordn_tagline">Encrypted groups on a coordinator you pick</string>
|
||||
<string name="new_conversation_cordn_chip">Coordinated</string>
|
||||
<string name="new_conversation_cordn_best">Best for teams that want one reliable ordering of the conversation.</string>
|
||||
<string name="new_conversation_cordn_best">Teams that want one reliable ordering of the conversation.</string>
|
||||
<string name="new_conversation_cordn_cta">Create Cordn group</string>
|
||||
<string name="new_conversation_cordn_pro_1">End-to-end encrypted with MLS — the coordinator can never read a message.</string>
|
||||
<string name="new_conversation_cordn_pro_2">One agreed order for everyone, so history cannot be reshuffled by a bad clock.</string>
|
||||
<string name="new_conversation_cordn_con_1">The coordinator learns who is in the group and when you talk, even though it cannot read what you say.</string>
|
||||
<string name="cordn_invitations_entry">Invited to a cordn group?</string>
|
||||
<string name="new_conversation_cordn_pro_1">Coordinator can't read messages</string>
|
||||
<string name="new_conversation_cordn_pro_2">One agreed message order</string>
|
||||
<string name="new_conversation_cordn_con_1">Coordinator sees who talks, and when</string>
|
||||
<string name="cordn_invitations_entry_action">Review invitations</string>
|
||||
|
||||
<!-- cordn device migration (spec/applications/multi-device.md §9/§11), as a
|
||||
@@ -5686,6 +5685,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>
|
||||
|
||||
+9
-1
@@ -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) }) }
|
||||
}
|
||||
|
||||
+9
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+35
-43
@@ -21,7 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.commons.chats.ui
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -42,6 +42,7 @@ import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -64,7 +65,6 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.navigation.Route
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_entry
|
||||
import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_entry_action
|
||||
import com.vitorpamplona.amethyst.commons.resources.new_conversation_best_for
|
||||
import com.vitorpamplona.amethyst.commons.resources.new_conversation_concord_best
|
||||
@@ -164,7 +164,9 @@ private val ColorLocation = Color(0xFF0891B2)
|
||||
* One selectable conversation type. Collapsed, a row shows only the icon, name, a short tagline, and
|
||||
* a one-word [chip] naming its deciding axis (scale / device-bound / moderation / live). Tapping the
|
||||
* row expands it to reveal [bestFor] and the [pros]/[cons] before the [cta] button routes to that
|
||||
* type's existing creation (or browse) flow.
|
||||
* type's existing creation (or browse) flow. A type whose joining is its own flow (rather than
|
||||
* something that just shows up in the inbox) adds it as [secondary], an outlined button under
|
||||
* the main one, so "join" sits on the same card as "create" instead of floating elsewhere.
|
||||
*/
|
||||
@Immutable
|
||||
private class ConversationType(
|
||||
@@ -178,6 +180,13 @@ private class ConversationType(
|
||||
val pros: List<StringResource>,
|
||||
val cons: List<StringResource>,
|
||||
val route: Route,
|
||||
val secondary: SecondaryAction? = null,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
private class SecondaryAction(
|
||||
val label: StringResource,
|
||||
val route: Route,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
@@ -253,6 +262,13 @@ private val conversationSections =
|
||||
// to it, and this is the first place it can be said.
|
||||
cons = listOf(Res.string.new_conversation_cordn_con_1),
|
||||
route = Route.CordnCreateGroup,
|
||||
// Being invited is the other half of "start a
|
||||
// conversation", so it sits on the same card as
|
||||
// creating one. It is a button you press, never a
|
||||
// count: a count would take a call to every
|
||||
// coordinator, and every call to a coordinator is
|
||||
// metadata (spec/00.md §8).
|
||||
secondary = SecondaryAction(Res.string.cordn_invitations_entry_action, Route.CordnInvitations),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -351,50 +367,11 @@ fun NewConversationScreen(nav: INav) {
|
||||
expanded = expandedId == type.title.key,
|
||||
onToggle = { expandedId = if (expandedId == type.title.key) "" else type.title.key },
|
||||
onCreate = { nav.nav(type.route) },
|
||||
onSecondary = { type.secondary?.let { nav.nav(it.route) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Being invited is the other half of "start a conversation", so it
|
||||
// belongs on the screen people reach for when they want one --
|
||||
// not buried in settings. The count is deliberately absent: it
|
||||
// would take a call to every coordinator, and every call to a
|
||||
// coordinator is metadata (spec/00.md §8).
|
||||
item(key = "cordn-invitations") {
|
||||
CordnInvitationsEntry(onClick = { nav.nav(Route.CordnInvitations) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CordnInvitationsEntry(onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 6.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Dns,
|
||||
contentDescription = null,
|
||||
tint = ColorCordn,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringRes(Res.string.cordn_invitations_entry),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(Res.string.cordn_invitations_entry_action),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -405,6 +382,7 @@ private fun ConversationRow(
|
||||
expanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
onCreate: () -> Unit,
|
||||
onSecondary: () -> Unit,
|
||||
) {
|
||||
// Lighten the accent in dark mode so chip/label/checkmark text stays legible on the dark ground;
|
||||
// the solid icon tile keeps the saturated base color in both themes.
|
||||
@@ -493,6 +471,20 @@ private fun ConversationRow(
|
||||
) {
|
||||
Text(stringRes(type.cta), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
|
||||
// Stacked rather than side by side: at phone width each half
|
||||
// would be ~150dp and the bold primary label would wrap.
|
||||
type.secondary?.let { secondary ->
|
||||
OutlinedButton(
|
||||
onClick = onSecondary,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = BorderStroke(1.dp, accent),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = accent),
|
||||
) {
|
||||
Text(stringRes(secondary.label), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-10
@@ -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)
|
||||
|
||||
@@ -123,18 +123,18 @@
|
||||
"Vietnamese"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "maxblake2015",
|
||||
"languages": [
|
||||
"Polish"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "rajs19420616",
|
||||
"languages": [
|
||||
"Hindi"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "maxblake2015",
|
||||
"languages": [
|
||||
"Polish"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "summoner001",
|
||||
"languages": [
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
+90
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+206
-43
@@ -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). */
|
||||
|
||||
+43
-1
@@ -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"
|
||||
|
||||
+338
-114
@@ -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. */
|
||||
|
||||
+8
-3
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
@@ -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"
|
||||
|
||||
+101
-34
@@ -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,
|
||||
),
|
||||
|
||||
+15
@@ -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"
|
||||
|
||||
+221
-79
@@ -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() }
|
||||
|
||||
+8
-3
@@ -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"
|
||||
}
|
||||
|
||||
+159
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user