From 4453985f6826a3ceb286ee460dec4c30b02fa59d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 00:45:00 +0000 Subject: [PATCH] refactor(napplet): split the host Service and Activity by responsibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two largest Android napplet files mixed many concerns. Decompose each into focused collaborators, behavior-preserving (both napplet test suites stay green): NappletBrokerService (641 -> 195 lines) is now a thin IPC shell — Messenger transport, per-account broker cache, lifecycle — delegating to: - gateways/AccountNappletGateways: the account -> NappletBroker adapter that wires all six gateway impls (relay publish/query, consent, wallet/NWC, resource, identity, upload). - gateways/NappletResourceFetcher: data:/https:/blossom: resource fetching with the Tor-aware OkHttp client (sha256-verified blossom blobs). - gateways/AccountIdentityReader: identity.* reads -> JSON (public data only). - NappletConsentSummary: NappletRequest -> localized consent dialog text. - NappletLiveSubscriptions: the live relay subscription registry (open/close/ closeAll, single-EOSE latch, per-sub client for correct teardown). NappletHostActivity (483 -> 336 lines) keeps the Activity lifecycle, WebView hardening, and the shell<->broker bridge; the resource edge moves to: - NappletContentServer: serves the shell + verified blobs (CSP headers, SPA fallback, shim injection) and owns the disk-cached blob HTTP client. No wire or policy change; all decode/consent/exec still flows through the shared NappletRequestRouter + NappletBroker. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde --- .../amethyst/napplet/NappletBrokerService.kt | 492 +----------------- .../amethyst/napplet/NappletConsentSummary.kt | 83 +++ .../amethyst/napplet/NappletContentServer.kt | 207 ++++++++ .../amethyst/napplet/NappletHostActivity.kt | 159 +----- .../napplet/NappletLiveSubscriptions.kt | 119 +++++ .../napplet/gateways/AccountIdentityReader.kt | 93 ++++ .../gateways/AccountNappletGateways.kt | 180 +++++++ .../gateways/NappletResourceFetcher.kt | 160 ++++++ 8 files changed, 871 insertions(+), 622 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletContentServer.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountIdentityReader.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index ebfe0324e6..0c0418ef90 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -31,72 +31,32 @@ import android.os.Message import android.os.Messenger import android.os.Process import android.os.RemoteException -import android.util.Base64 import android.util.Log import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.napplet.NappletBroker import com.vitorpamplona.amethyst.commons.napplet.NappletCapability -import com.vitorpamplona.amethyst.commons.napplet.NappletConsentPrompt import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity -import com.vitorpamplona.amethyst.commons.napplet.NappletIdentityGateway -import com.vitorpamplona.amethyst.commons.napplet.NappletRelayGateway import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter -import com.vitorpamplona.amethyst.commons.napplet.NappletResource -import com.vitorpamplona.amethyst.commons.napplet.NappletResourceGateway -import com.vitorpamplona.amethyst.commons.napplet.NappletUploadGateway -import com.vitorpamplona.amethyst.commons.napplet.NappletUploadResult -import com.vitorpamplona.amethyst.commons.napplet.NappletWalletGateway import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson -import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader -import com.vitorpamplona.amethyst.ui.pluralStringRes -import com.vitorpamplona.quartz.lightning.LnInvoiceUtil -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse -import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag -import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver -import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.sniffContentType -import com.vitorpamplona.quartz.utils.sha256.sha256 -import kotlinx.coroutines.CompletableDeferred +import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout -import kotlinx.serialization.json.add -import kotlinx.serialization.json.buildJsonArray -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put -import kotlinx.serialization.json.putJsonObject -import okhttp3.OkHttpClient -import okhttp3.Request -import java.io.ByteArrayInputStream -import java.net.InetSocketAddress -import java.net.Proxy -import java.net.URLDecoder -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger /** * The trust boundary's main-process endpoint. The untrusted `:napplet` process binds this - * service and sends [NappletRequest]s as JSON over a [Messenger]; this is the only side that - * holds the signer, the relays, and the permission ledger. It runs each request through the - * shared [NappletBroker], which gates everything on consent and never returns key material. + * service and sends requests as JSON over a [Messenger]; this is the only side that holds the + * signer, the relays, and the permission ledger. It runs each request through the shared + * [NappletBroker] (built per account by [AccountNappletGateways]) via the host-agnostic + * [NappletRequestRouter], which gates everything on consent and never returns key material. + * + * This class is intentionally thin: it owns the Messenger transport, the per-account broker cache, + * and the live-subscription registry, and delegates all decode/policy/execution to the shared core. * * `exported=false` in the manifest restricts binding to this app's own UID, so no other * installed app can reach the broker. A renderer escape that reaches the `:napplet` process @@ -117,21 +77,8 @@ class NappletBrokerService : Service() { // The broker for the current account, rebuilt only on account switch (see broker()). private var cachedBroker: Pair? = null - // Reused blob HTTP client, keyed by the active Tor port (see blobHttpClient()). - private var cachedHttp: Pair? = null - - // Live relay subscriptions, keyed by the applet's subId. Holds the exact client that opened each - // one so teardown unsubscribes from the right account even after a switch, and an eose latch so - // a multi-relay subscription emits a single relay.eose. - private val liveSubs = ConcurrentHashMap() - private val liveSeq = AtomicInteger(0) - - private class LiveSub( - val clientSubId: String, - val client: INostrClient, - ) { - val eoseSent = AtomicBoolean(false) - } + // Live relay subscriptions, keyed by the applet's subId; reads the current account live. + private val liveSubscriptions = NappletLiveSubscriptions { Amethyst.instance.sessionManager.loggedInAccount() } override fun onBind(intent: Intent?): IBinder? { // Defense in depth on top of exported=false: only our own UID may bind. @@ -140,8 +87,7 @@ class NappletBrokerService : Service() { } override fun onDestroy() { - liveSubs.values.forEach { sub -> runCatching { sub.client.unsubscribe(sub.clientSubId) } } - liveSubs.clear() + liveSubscriptions.closeAll() scope.cancel() super.onDestroy() } @@ -175,8 +121,8 @@ class NappletBrokerService : Service() { when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) { is NappletRequestRouter.Outcome.Ignore -> {} is NappletRequestRouter.Outcome.Reply -> reply(replyTo, requestId, outcome.payload) - is NappletRequestRouter.Outcome.OpenSubscription -> openLiveSubscription(outcome.subId, outcome.filters, replyTo) - is NappletRequestRouter.Outcome.CloseSubscription -> closeLiveSubscription(outcome.subId) + is NappletRequestRouter.Outcome.OpenSubscription -> liveSubscriptions.open(outcome.subId, outcome.filters) { push(replyTo, it) } + is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(outcome.subId) is NappletRequestRouter.Outcome.Push -> outcome.payloads.forEach { push(replyTo, it) } } } @@ -199,405 +145,18 @@ class NappletBrokerService : Service() { private fun broker(): NappletBroker? { val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return null cachedBroker?.let { (acc, broker) -> if (acc === account) return broker } - return buildBroker(account).also { cachedBroker = account to it } + val broker = + AccountNappletGateways( + account = account, + context = applicationContext, + ledger = ledger, + storage = storage, + torPort = { Amethyst.instance.torManager.activePortOrNull.value ?: -1 }, + ).broker() + cachedBroker = account to broker + return broker } - private fun buildBroker(account: Account): NappletBroker { - val relay = - object : NappletRelayGateway { - override suspend fun publish(event: Event): List { - val relays = account.computeRelayListToBroadcast(event) - account.client.publish(event, relays) - return relays.map { it.url } - } - - override suspend fun query(filters: List): List = queryEvents(account, filters) - } - - val consent = - NappletConsentPrompt { id, capability, request -> - NappletConsentCoordinator.requestConsent( - context = applicationContext, - info = consentInfo(id, capability, request), - ) - } - - val wallet = NappletWalletGateway { invoice -> payInvoiceViaNwc(account, invoice) } - - val resource = NappletResourceGateway { url -> fetchResource(account, url) } - - val identityReads = NappletIdentityGateway { method, argument -> readIdentity(account, method, argument) } - - val upload = NappletUploadGateway { bytes, contentType, filename -> uploadBlob(account, bytes, contentType, filename) } - - return NappletBroker(account.signer, ledger, consent, relay, storage, wallet, resource, upload = upload, identityReads = identityReads) - } - - /** - * Uploads [bytes] to the user's first Blossom server (kind:10063) with a signed authorization - * event, via the app's existing [BlossomUploader]. Returns null when there's no server or the - * upload fails. Consent is enforced by the broker before this runs. - */ - private suspend fun uploadBlob( - account: Account, - bytes: ByteArray, - contentType: String, - filename: String?, - ): NappletUploadResult? { - val server = - account.blossomServers - .getBlossomServersList() - ?.servers() - ?.firstOrNull() ?: return null - val hash = sha256(bytes).toHexKey() - val result = - runCatching { - BlossomUploader().upload( - inputStream = ByteArrayInputStream(bytes), - hash = hash, - length = bytes.size.toLong(), - baseFileName = filename, - contentType = contentType, - alt = null, - sensitiveContent = null, - serverBaseUrl = server, - okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, - httpAuth = { h, size, alt -> account.createBlossomUploadAuth(h, size, alt) }, - context = applicationContext, - ) - }.getOrNull() ?: return null - val url = result.url ?: return null - return NappletUploadResult(url, result.sha256, result.size, result.type) - } - - /** - * Reads a non-key identity datum from the active account as a JSON value string. Returns the - * literal `"null"` for an absent value, or `null` for a method this shell does not implement - * (the broker then answers `Unsupported`). All reads are public data — never key material. - */ - private fun readIdentity( - account: Account, - method: String, - argument: String?, - ): String? = - when (method) { - "getProfile" -> profileJson(account) - "getFollows" -> jsonStringArray(account.kind3FollowList.flow.value.authors) - "getMutes" -> - jsonStringArray( - account.muteList.flow.value - .filterIsInstance() - .map { it.pubKey }, - ) - "getBlocked" -> - jsonStringArray( - account.blockPeopleList.flow.value - .filterIsInstance() - .map { it.pubKey }, - ) - "getRelays" -> relaysJson(account) - // getList/getZaps/getBadges and any other read are not implemented yet → Unsupported. - else -> null - } - - private fun jsonStringArray(items: Iterable): String = buildJsonArray { items.forEach { add(it) } }.toString() - - /** Builds a `@napplet/nap` `ProfileData` object (note `displayName`, not `display_name`) from kind-0. */ - private fun profileJson(account: Account): String { - val md = account.userMetadata.getUserMetadataEvent()?.contactMetaData() ?: return "null" - return buildJsonObject { - md.name?.let { put("name", it) } - md.displayName?.let { put("displayName", it) } - md.about?.let { put("about", it) } - md.picture?.let { put("picture", it) } - md.banner?.let { put("banner", it) } - md.nip05?.let { put("nip05", it) } - md.lud16?.let { put("lud16", it) } - md.website?.let { put("website", it) } - }.toString() - } - - /** Builds `{ "": { "read": bool, "write": bool }, ... }` from the user's NIP-65 list. */ - private fun relaysJson(account: Account): String { - val relays = account.nip65RelayList.getNIP65RelayList()?.relays() ?: return "null" - return buildJsonObject { - relays.forEach { info -> - putJsonObject(info.relayUrl.url) { - put("read", info.type.isRead()) - put("write", info.type.isWrite()) - } - } - }.toString() - } - - /** Fetches an https/data resource on the applet's behalf (it has no direct network). */ - private suspend fun fetchResource( - account: Account, - url: String, - ): NappletResource? = - withContext(Dispatchers.IO) { - when { - url.startsWith("data:") -> decodeDataUrl(url) - url.startsWith("https://") -> { - runCatching { - blobHttpClient() - .newCall( - Request - .Builder() - .url(url) - .get() - .build(), - ).execute() - .use { r -> - if (!r.isSuccessful) return@withContext null - val body = r.body.bytes() - val type = r.header("Content-Type") ?: "application/octet-stream" - NappletResource(body, type) - } - }.getOrNull() - } - url.startsWith("blossom:") -> fetchBlossom(account, url) - // nostr: resolution (event → bytes) is unspecified for resource.bytes; left as a follow-up. - else -> null - } - } - - /** - * Tor-routed OkHttp client for host-side blob fetches (the applet has no direct network). - * Cached and reused for connection pooling; rebuilt only when the Tor proxy port changes. - */ - @Synchronized - private fun blobHttpClient(): OkHttpClient { - val port = Amethyst.instance.torManager.activePortOrNull.value ?: -1 - cachedHttp?.let { (cachedPort, client) -> if (cachedPort == port) return client } - val client = - if (port > 0) { - OkHttpClient.Builder().proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port))).build() - } else { - OkHttpClient() - } - cachedHttp = port to client - return client - } - - /** - * Fetches a `blossom:` (or `blossom://`) blob from the user's Blossom servers - * (kind:10063), verifying the sha256 before returning — content-addressed, so a wrong server - * can never substitute the blob. Returns null for a malformed hash or if no server serves it. - */ - private fun fetchBlossom( - account: Account, - url: String, - ): NappletResource? { - val hash = - url - .removePrefix("blossom://") - .removePrefix("blossom:") - .substringBefore('/') - .substringBefore('?') - .trim() - .lowercase() - if (!hash.matches(Regex("^[0-9a-f]{64}$"))) return null - - val servers = - account.blossomServers - .getBlossomServersList() - ?.servers() - .orEmpty() - val client = blobHttpClient() - for (candidate in StaticSiteResolver.candidateUrls(servers, hash)) { - val bytes = - runCatching { - client - .newCall( - Request - .Builder() - .url(candidate) - .get() - .build(), - ).execute() - .use { r -> - if (r.isSuccessful) r.body.bytes() else null - } - }.getOrNull() ?: continue - if (StaticSiteResolver.verify(bytes, hash)) { - return NappletResource(bytes, sniffContentType(bytes) ?: "application/octet-stream") - } - } - return null - } - - /** Parses a `data:[][;base64],` URL into bytes + content type. */ - private fun decodeDataUrl(url: String): NappletResource? { - val comma = url.indexOf(',') - if (comma < 0) return null - val meta = url.substring("data:".length, comma) - val data = url.substring(comma + 1) - val isBase64 = meta.endsWith(";base64") - val contentType = meta.removeSuffix(";base64").ifEmpty { "text/plain" } - val bytes = - if (isBase64) { - runCatching { Base64.decode(data, Base64.DEFAULT) }.getOrNull() ?: return null - } else { - URLDecoder.decode(data, "UTF-8").encodeToByteArray() - } - return NappletResource(bytes, contentType) - } - - /** Bounded live relay fetch (EOSE/timeout) for all [filters], merged with the local cache, newest-first. */ - private suspend fun queryEvents( - account: Account, - filters: List, - ): List { - if (filters.isEmpty()) return emptyList() - val relays = account.homeRelays.flow.value - val fromRelays = - if (relays.isEmpty()) { - emptyList() - } else { - runCatching { - account.client.fetchAll(filters = relays.associateWith { filters }, timeoutMs = QUERY_TIMEOUT_MS) - }.getOrDefault(emptyList()) - } - val fromCache = filters.flatMap { filter -> account.cache.filter(filter).mapNotNull { it.event } } - - val merged = - (fromRelays + fromCache) - .distinctBy { it.id } - .sortedByDescending { it.createdAt } - val limit = filters.mapNotNull { it.limit }.maxOrNull() - return limit?.let { merged.take(it) } ?: merged - } - - /** - * Opens a live relay subscription for [nappletSubId], streaming `relay.event`/`relay.eose`/ - * `relay.closed` pushes to the applet as events arrive. Replaces any existing subscription for - * the same id. Reached only after the broker authorized the subscription (RELAY consent). - */ - private fun openLiveSubscription( - nappletSubId: String, - filters: List, - replyTo: Messenger, - ) { - val account = Amethyst.instance.sessionManager.loggedInAccount() - val relays = account?.homeRelays?.flow?.value ?: emptySet() - if (account == null || filters.isEmpty() || relays.isEmpty()) { - push(replyTo, NappletProtocolJson.encodeRelayEose(nappletSubId)) - return - } - - closeLiveSubscription(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 - - val listener = - object : SubscriptionListener { - override fun onEvent( - event: Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) = push(replyTo, NappletProtocolJson.encodeRelayEvent(nappletSubId, event)) - - // A subscription fans out to several relays; collapse their EOSEs into the single - // relay.eose the SDK expects (fired when the first relay finishes its stored events). - override fun onEose( - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - if (sub.eoseSent.compareAndSet(false, true)) push(replyTo, NappletProtocolJson.encodeRelayEose(nappletSubId)) - } - - override fun onClosed( - message: String, - relay: NormalizedRelayUrl, - forFilters: List?, - ) = push(replyTo, NappletProtocolJson.encodeRelayClosed(nappletSubId, message)) - } - - runCatching { sub.client.subscribe(sub.clientSubId, relays.associateWith { filters }, listener) } - } - - /** Stops the live subscription for [nappletSubId], unsubscribing from the client that opened it. */ - private fun closeLiveSubscription(nappletSubId: String) { - val sub = liveSubs.remove(nappletSubId) ?: return - runCatching { - sub.client.unsubscribe(sub.clientSubId) - } - } - - /** - * Pays [invoice] via the user's connected NWC wallet, returning the preimage on success. - * Throws (→ `Failed`) when no wallet is connected, the wallet reports an error, or it does not - * respond in time — so the applet never silently believes a payment succeeded. - */ - private suspend fun payInvoiceViaNwc( - account: Account, - invoice: String, - ): String? { - if (account.nip47SignerState.defaultWalletUri.value == null) { - throw IllegalStateException("No Lightning wallet is connected.") - } - - val result = CompletableDeferred() - account.sendZapPaymentRequestFor(invoice, null) { response -> - when (response) { - is PayInvoiceSuccessResponse -> result.complete(response.result?.preimage) - is PayInvoiceErrorResponse -> result.completeExceptionally(RuntimeException(response.error?.message ?: "Payment failed.")) - is NwcErrorResponse -> result.completeExceptionally(RuntimeException(response.error?.message ?: "Wallet error.")) - else -> result.completeExceptionally(RuntimeException("Unexpected wallet response.")) - } - } - return withTimeout(WALLET_TIMEOUT_MS) { result.await() } - } - - private fun consentInfo( - identity: NappletIdentity, - capability: NappletCapability, - request: NappletRequest, - ): NappletConsentInfo { - val title = identity.identifier.ifBlank { getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8)) } - return NappletConsentInfo( - appletTitle = title, - coordinate = identity.coordinate, - capabilityLabel = getString(capability.labelRes()), - operationSummary = summaryFor(request), - allowAlways = capability.canGrantAlways, - ) - } - - private fun summaryFor(request: NappletRequest): String = - when (request) { - is NappletRequest.GetPublicKey -> getString(R.string.napplet_consent_get_pubkey) - is NappletRequest.IdentityRead -> getString(R.string.napplet_consent_identity_read) - is NappletRequest.Publish -> { - val preview = request.content.take(160).trim() - if (preview.isEmpty()) { - getString(R.string.napplet_consent_publish, request.kind) - } else { - getString(R.string.napplet_consent_publish_preview, request.kind) + "\n“$preview”" - } - } - is NappletRequest.PublishEncrypted -> getString(R.string.napplet_consent_publish_encrypted) - is NappletRequest.QueryEvents, is NappletRequest.Subscribe -> getString(R.string.napplet_consent_query) - is NappletRequest.StorageGet, is NappletRequest.StorageSet, is NappletRequest.StorageRemove, is NappletRequest.StorageKeys -> - getString(R.string.napplet_consent_storage) - is NappletRequest.PayInvoice -> { - val sats = runCatching { LnInvoiceUtil.getAmountInSats(request.invoice).toLong() }.getOrNull() - if (sats != null) { - pluralStringRes(this, R.plurals.napplet_consent_pay_amount, sats.toInt(), sats) - } else { - getString(R.string.napplet_consent_pay) - } - } - is NappletRequest.ResourceBytes -> getString(R.string.napplet_consent_resource) - is NappletRequest.UploadBlob -> getString(R.string.napplet_consent_upload) - // Resolved in the broker before consent (negotiation / shell-mediated); never shown. - is NappletRequest.ShellSupports, is NappletRequest.RegisterAction, is NappletRequest.UnregisterAction -> "" - } - private fun reply( replyTo: Messenger, requestId: String, @@ -633,9 +192,4 @@ class NappletBrokerService : Service() { Log.w("NappletBrokerService", "Applet host went away before push could be delivered", e) } } - - companion object { - private const val QUERY_TIMEOUT_MS = 8_000L - private const val WALLET_TIMEOUT_MS = 60_000L - } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt new file mode 100644 index 0000000000..53f472cf7f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt @@ -0,0 +1,83 @@ +/* + * 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.napplet + +import android.content.Context +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.napplet.NappletCapability +import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest +import com.vitorpamplona.amethyst.ui.pluralStringRes +import com.vitorpamplona.quartz.lightning.LnInvoiceUtil + +/** + * Turns a pending [NappletRequest] into the human-readable [NappletConsentInfo] the consent dialog + * shows — the applet's title, the capability label, and a per-operation summary (e.g. a note preview + * or a sat amount). Localized via app resources; holds only a [Context], no account state. + */ +class NappletConsentSummary( + private val context: Context, +) { + fun info( + identity: NappletIdentity, + capability: NappletCapability, + request: NappletRequest, + ): NappletConsentInfo { + val title = identity.identifier.ifBlank { context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8)) } + return NappletConsentInfo( + appletTitle = title, + coordinate = identity.coordinate, + capabilityLabel = context.getString(capability.labelRes()), + operationSummary = summaryFor(request), + allowAlways = capability.canGrantAlways, + ) + } + + private fun summaryFor(request: NappletRequest): String = + when (request) { + is NappletRequest.GetPublicKey -> context.getString(R.string.napplet_consent_get_pubkey) + is NappletRequest.IdentityRead -> context.getString(R.string.napplet_consent_identity_read) + is NappletRequest.Publish -> { + val preview = request.content.take(160).trim() + if (preview.isEmpty()) { + context.getString(R.string.napplet_consent_publish, request.kind) + } else { + context.getString(R.string.napplet_consent_publish_preview, request.kind) + "\n“$preview”" + } + } + is NappletRequest.PublishEncrypted -> context.getString(R.string.napplet_consent_publish_encrypted) + is NappletRequest.QueryEvents, is NappletRequest.Subscribe -> context.getString(R.string.napplet_consent_query) + is NappletRequest.StorageGet, is NappletRequest.StorageSet, is NappletRequest.StorageRemove, is NappletRequest.StorageKeys -> + context.getString(R.string.napplet_consent_storage) + is NappletRequest.PayInvoice -> { + val sats = runCatching { LnInvoiceUtil.getAmountInSats(request.invoice).toLong() }.getOrNull() + if (sats != null) { + pluralStringRes(context, R.plurals.napplet_consent_pay_amount, sats.toInt(), sats) + } else { + context.getString(R.string.napplet_consent_pay) + } + } + is NappletRequest.ResourceBytes -> context.getString(R.string.napplet_consent_resource) + is NappletRequest.UploadBlob -> context.getString(R.string.napplet_consent_upload) + // Resolved in the broker before consent (negotiation / shell-mediated); never shown. + is NappletRequest.ShellSupports, is NappletRequest.RegisterAction, is NappletRequest.UnregisterAction -> "" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletContentServer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletContentServer.kt new file mode 100644 index 0000000000..3f4b7a4935 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletContentServer.kt @@ -0,0 +1,207 @@ +/* + * 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.napplet + +import android.util.Log +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract +import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.BlobFetcher +import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolution +import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag +import kotlinx.coroutines.runBlocking +import okhttp3.Cache +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.ByteArrayInputStream +import java.io.File +import java.net.InetSocketAddress +import java.net.Proxy + +/** + * Serves the napplet sandbox's content over the internal `https://napplet.local` origin: the trusted + * shell page, and the manifest's blobs — each **sha256-verified** by [StaticSiteResolver] before it + * leaves this class. Everything else 404s. Blobs are fetched through the user's Tor proxy (the applet + * has no direct network) and disk-cached; because they are content-addressed and re-verified on every + * serve, a stale or poisoned cache entry can never be served. + * + * This is the host's resource edge, kept separate from the Activity lifecycle and the broker bridge: + * given a [WebResourceRequest] it returns the [WebResourceResponse] (with the right CSP headers) or + * null to defer to the WebView. + */ +class NappletContentServer( + private val paths: List, + private val servers: List, + proxyPort: Int, + cacheDir: File, + private val shellHtmlBytes: ByteArray, + private val shimJs: String, +) { + private val http = buildHttpClient(proxyPort, cacheDir) + + private val fetch: BlobFetcher = { url -> + try { + http + .newCall( + Request + .Builder() + .url(url) + .get() + .build(), + ).execute() + .use { r -> + if (r.isSuccessful) r.body.bytes() else null + } + } catch (e: Exception) { + Log.w(TAG, "Blob fetch failed for $url", e) + null + } + } + + /** + * Serves the trusted shell or a verified app blob for a GET to our origin; 404s anything else on + * the origin, and returns null (defer to the WebView) for non-GET or off-origin requests. + */ + fun serve(request: WebResourceRequest): WebResourceResponse? { + val url = request.url.toString() + if (!request.method.equals("GET", ignoreCase = true)) return null + if (!url.startsWith(NappletWebContract.ORIGIN)) return notFound() + + if (url == NappletWebContract.SHELL_URL) return serveShell() + if (url == NappletWebContract.APP_BASE || url.startsWith(NappletWebContract.APP_BASE)) { + // A document navigation accepts text/html; a sub-resource (js/css/img) does not. + val acceptsHtml = request.requestHeaders["Accept"]?.contains("text/html", ignoreCase = true) == true + return serveAppResource(url, acceptsHtml) + } + return notFound() + } + + private fun serveShell(): WebResourceResponse = + WebResourceResponse( + "text/html", + "utf-8", + 200, + "OK", + mapOf("Content-Security-Policy" to NappletWebContract.SHELL_CSP), + ByteArrayInputStream(shellHtmlBytes), + ) + + private fun serveAppResource( + url: String, + acceptsHtml: Boolean, + ): WebResourceResponse { + val requestPath = + url + .removePrefix(NappletWebContract.APP_BASE) + .substringBefore('?') + .substringBefore('#') + .let { if (it.isEmpty()) "/" else "/$it" } + + var resolution = runBlocking { StaticSiteResolver.resolve(requestPath, paths, servers, fetch) } + + // SPA fallback: a document navigation (Accept: text/html) to a route that isn't in the + // manifest falls back to the verified index.html, so client-side-routed sites survive deep + // links and refreshes. Missing sub-resources (js/css/images) still 404 — they don't accept + // html — so a broken asset never silently returns the page. + if (resolution !is StaticSiteResolution.Resolved && acceptsHtml && requestPath != "/") { + resolution = runBlocking { StaticSiteResolver.resolve("/", paths, servers, fetch) } + } + + if (resolution !is StaticSiteResolution.Resolved) return notFound() + + val (mime, charset) = splitContentType(resolution.contentType) + val isHtml = mime.equals("text/html", ignoreCase = true) + val bytes = if (isHtml) injectShim(resolution.bytes) else resolution.bytes + + return WebResourceResponse( + mime, + charset, + 200, + "OK", + mapOf("Content-Security-Policy" to NappletWebContract.APP_CSP), + ByteArrayInputStream(bytes), + ) + } + + /** Inserts the `window.napplet` client shim into the applet's HTML document. */ + private fun injectShim(html: ByteArray): ByteArray { + val text = html.decodeToString() + val script = "" + val headIdx = text.indexOf("= 0 -> { + val close = text.indexOf('>', headIdx) + if (close >= 0) text.substring(0, close + 1) + script + text.substring(close + 1) else script + text + } + else -> script + text + } + return injected.encodeToByteArray() + } + + /** + * Routes blob fetches through the user's Tor SOCKS proxy when one is active (port > 0), and + * caches them on disk. Blobs are content-addressed (`/`) and therefore + * immutable, so a long-lived forced cache is safe — and the resolver re-verifies every blob's + * sha256 on the way out regardless, so a stale/poisoned cache entry can never be served. + */ + private fun buildHttpClient( + port: Int, + cacheDir: File, + ): OkHttpClient { + val builder = OkHttpClient.Builder() + if (port > 0) { + builder.proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port))) + } + runCatching { + builder.cache(Cache(File(cacheDir, "napplet-blobs"), BLOB_CACHE_BYTES)) + builder.addNetworkInterceptor { chain -> + val response = chain.proceed(chain.request()) + if (response.isSuccessful) { + response + .newBuilder() + .header("Cache-Control", "public, max-age=31536000, immutable") + .removeHeader("Pragma") + .build() + } else { + response + } + } + } + return builder.build() + } + + private fun notFound(): WebResourceResponse = WebResourceResponse("text/plain", "utf-8", 404, "Not Found", emptyMap(), ByteArrayInputStream(ByteArray(0))) + + private fun splitContentType(contentType: String): Pair { + val mime = contentType.substringBefore(';').trim().ifEmpty { "application/octet-stream" } + val charset = + contentType.substringAfter("charset=", "").trim().ifEmpty { null } + ?: if (mime.startsWith("text/") || mime.endsWith("javascript") || mime.endsWith("json")) "utf-8" else "" + return mime to charset + } + + companion object { + private const val TAG = "NappletContentServer" + private const val BLOB_CACHE_BYTES = 50L * 1024 * 1024 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt index 0aeca315d0..224c413e7c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt @@ -46,19 +46,9 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson import com.vitorpamplona.amethyst.commons.napplet.resolveRequiredCapabilities -import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.BlobFetcher -import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolution -import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag import kotlinx.coroutines.runBlocking -import okhttp3.Cache -import okhttp3.OkHttpClient -import okhttp3.Request import org.json.JSONObject -import java.io.ByteArrayInputStream -import java.io.File -import java.net.InetSocketAddress -import java.net.Proxy /** * Hosts a napplet/nsite WebView in the isolated `:napplet` process — a process that holds **no** @@ -93,25 +83,9 @@ class NappletHostActivity : ComponentActivity() { private var fireSeq = 0 private var proxyPort: Int = -1 - private val http by lazy { buildHttpClient(proxyPort) } - private val fetch: BlobFetcher = { url -> - try { - http - .newCall( - Request - .Builder() - .url(url) - .get() - .build(), - ).execute() - .use { r -> - if (r.isSuccessful) r.body.bytes() else null - } - } catch (e: Exception) { - Log.w(TAG, "Blob fetch failed for $url", e) - null - } - } + + // The resource edge (shell + verified blobs); built in onCreate once the manifest is parsed. + private lateinit var contentServer: NappletContentServer // Messenger to the main-process broker, bound lazily; requests queue until connected. private var brokerMessenger: Messenger? = null @@ -152,10 +126,8 @@ class NappletHostActivity : ComponentActivity() { // The shell page + shim are the shared web contract (commons composeResources); load them // once up front so the WebView worker threads that serve them never block on resource I/O. - runBlocking { - shellHtmlBytes = NappletWebContract.shellHtml() - shimJs = NappletWebContract.shimJs().decodeToString() - } + val (shellHtml, shim) = runBlocking { NappletWebContract.shellHtml() to NappletWebContract.shimJs().decodeToString() } + contentServer = NappletContentServer(paths, servers, proxyPort, cacheDir, shellHtml, shim) webView = WebView(this) setContentView(webView) @@ -174,10 +146,6 @@ class NappletHostActivity : ComponentActivity() { webView.loadUrl(NappletWebContract.SHELL_URL) } - // The shared web contract, preloaded in onCreate (see commons NappletWebContract). - private lateinit var shellHtmlBytes: ByteArray - private lateinit var shimJs: String - override fun onResume() { super.onResume() if (this::webView.isInitialized) { @@ -260,19 +228,7 @@ class NappletHostActivity : ComponentActivity() { override fun shouldInterceptRequest( view: WebView, request: WebResourceRequest, - ): WebResourceResponse? { - val url = request.url.toString() - if (!request.method.equals("GET", ignoreCase = true)) return null - if (!url.startsWith(NappletWebContract.ORIGIN)) return notFound() - - if (url == NappletWebContract.SHELL_URL) return serveShell() - if (url == NappletWebContract.APP_BASE || url.startsWith(NappletWebContract.APP_BASE)) { - // A document navigation accepts text/html; a sub-resource (js/css/img) does not. - val acceptsHtml = request.requestHeaders["Accept"]?.contains("text/html", ignoreCase = true) == true - return serveAppResource(url, acceptsHtml) - } - return notFound() - } + ): WebResourceResponse? = contentServer.serve(request) override fun shouldOverrideUrlLoading( view: WebView, @@ -294,69 +250,6 @@ class NappletHostActivity : ComponentActivity() { } } - private fun serveShell(): WebResourceResponse = - WebResourceResponse( - "text/html", - "utf-8", - 200, - "OK", - mapOf("Content-Security-Policy" to NappletWebContract.SHELL_CSP), - ByteArrayInputStream(shellHtmlBytes), - ) - - private fun serveAppResource( - url: String, - acceptsHtml: Boolean, - ): WebResourceResponse { - val requestPath = - url - .removePrefix(NappletWebContract.APP_BASE) - .substringBefore('?') - .substringBefore('#') - .let { if (it.isEmpty()) "/" else "/$it" } - - var resolution = runBlocking { StaticSiteResolver.resolve(requestPath, paths, servers, fetch) } - - // SPA fallback: a document navigation (Accept: text/html) to a route that isn't in the - // manifest falls back to the verified index.html, so client-side-routed sites survive deep - // links and refreshes. Missing sub-resources (js/css/images) still 404 — they don't accept - // html — so a broken asset never silently returns the page. - if (resolution !is StaticSiteResolution.Resolved && acceptsHtml && requestPath != "/") { - resolution = runBlocking { StaticSiteResolver.resolve("/", paths, servers, fetch) } - } - - if (resolution !is StaticSiteResolution.Resolved) return notFound() - - val (mime, charset) = splitContentType(resolution.contentType) - val isHtml = mime.equals("text/html", ignoreCase = true) - val bytes = if (isHtml) injectShim(resolution.bytes) else resolution.bytes - - return WebResourceResponse( - mime, - charset, - 200, - "OK", - mapOf("Content-Security-Policy" to NappletWebContract.APP_CSP), - ByteArrayInputStream(bytes), - ) - } - - /** Inserts the `window.napplet` client shim into the applet's HTML document. */ - private fun injectShim(html: ByteArray): ByteArray { - val text = html.decodeToString() - val script = "" - val headIdx = text.indexOf("= 0 -> { - val close = text.indexOf('>', headIdx) - if (close >= 0) text.substring(0, close + 1) + script + text.substring(close + 1) else script + text - } - else -> script + text - } - return injected.encodeToByteArray() - } - // ---- bridge: shell <-> native ---- private fun onShellMessage( @@ -437,47 +330,7 @@ class NappletHostActivity : ComponentActivity() { return true } - /** - * Routes blob fetches through the user's Tor SOCKS proxy when one is active (port > 0), and - * caches them on disk. Blobs are content-addressed (`/`) and therefore - * immutable, so a long-lived forced cache is safe — and the resolver re-verifies every blob's - * sha256 on the way out regardless, so a stale/poisoned cache entry can never be served. - */ - private fun buildHttpClient(port: Int): OkHttpClient { - val builder = OkHttpClient.Builder() - if (port > 0) { - builder.proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port))) - } - runCatching { - builder.cache(Cache(File(cacheDir, "napplet-blobs"), BLOB_CACHE_BYTES)) - builder.addNetworkInterceptor { chain -> - val response = chain.proceed(chain.request()) - if (response.isSuccessful) { - response - .newBuilder() - .header("Cache-Control", "public, max-age=31536000, immutable") - .removeHeader("Pragma") - .build() - } else { - response - } - } - } - return builder.build() - } - - private fun notFound(): WebResourceResponse = WebResourceResponse("text/plain", "utf-8", 404, "Not Found", emptyMap(), ByteArrayInputStream(ByteArray(0))) - - private fun splitContentType(contentType: String): Pair { - val mime = contentType.substringBefore(';').trim().ifEmpty { "application/octet-stream" } - val charset = - contentType.substringAfter("charset=", "").trim().ifEmpty { null } - ?: if (mime.startsWith("text/") || mime.endsWith("javascript") || mime.endsWith("json")) "utf-8" else "" - return mime to charset - } - companion object { private const val TAG = "NappletHostActivity" - private const val BLOB_CACHE_BYTES = 50L * 1024 * 1024 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt new file mode 100644 index 0000000000..1115bd026e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt @@ -0,0 +1,119 @@ +/* + * 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.napplet + +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import java.util.concurrent.ConcurrentHashMap +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 + * 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 + * caller-supplied sink — it never touches the transport itself. + * + * [account] is read live (so it always targets the currently signed-in account); [open] is reached + * only after the broker authorized the subscription (RELAY consent). + */ +class NappletLiveSubscriptions( + private val account: () -> Account?, +) { + private val liveSubs = ConcurrentHashMap() + private val liveSeq = AtomicInteger(0) + + private class LiveSub( + val clientSubId: String, + val client: INostrClient, + ) { + val eoseSent = AtomicBoolean(false) + } + + /** + * 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. + */ + fun open( + nappletSubId: String, + filters: List, + push: (String) -> Unit, + ) { + val account = account() + val relays = account?.homeRelays?.flow?.value ?: emptySet() + if (account == null || filters.isEmpty() || relays.isEmpty()) { + push(NappletProtocolJson.encodeRelayEose(nappletSubId)) + return + } + + close(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 + + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) = push(NappletProtocolJson.encodeRelayEvent(nappletSubId, event)) + + // A subscription fans out to several relays; collapse their EOSEs into the single + // relay.eose the SDK expects (fired when the first relay finishes its stored events). + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (sub.eoseSent.compareAndSet(false, true)) push(NappletProtocolJson.encodeRelayEose(nappletSubId)) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) = push(NappletProtocolJson.encodeRelayClosed(nappletSubId, message)) + } + + 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 + runCatching { sub.client.unsubscribe(sub.clientSubId) } + } + + /** Tears down every open subscription (service teardown). */ + fun closeAll() { + liveSubs.values.forEach { sub -> runCatching { sub.client.unsubscribe(sub.clientSubId) } } + liveSubs.clear() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountIdentityReader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountIdentityReader.kt new file mode 100644 index 0000000000..121a454227 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountIdentityReader.kt @@ -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.napplet.gateways + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject + +/** + * Reads non-key identity data from an account as JSON value strings, for the napplet `identity.*` + * domain. Everything here is **public** profile/list data — never key material. Returns the literal + * `"null"` for an absent value, or `null` for a method this shell does not implement yet (the broker + * then answers `Unsupported`). Shapes match `@napplet/nap` (e.g. `displayName`, not `display_name`). + */ +class AccountIdentityReader( + private val account: Account, +) { + fun read( + method: String, + argument: String?, + ): String? = + when (method) { + "getProfile" -> profileJson() + "getFollows" -> jsonStringArray(account.kind3FollowList.flow.value.authors) + "getMutes" -> + jsonStringArray( + account.muteList.flow.value + .filterIsInstance() + .map { it.pubKey }, + ) + "getBlocked" -> + jsonStringArray( + account.blockPeopleList.flow.value + .filterIsInstance() + .map { it.pubKey }, + ) + "getRelays" -> relaysJson() + // getList/getZaps/getBadges and any other read are not implemented yet → Unsupported. + else -> null + } + + private fun jsonStringArray(items: Iterable): String = buildJsonArray { items.forEach { add(it) } }.toString() + + /** Builds a `@napplet/nap` `ProfileData` object (note `displayName`, not `display_name`) from kind-0. */ + private fun profileJson(): String { + val md = account.userMetadata.getUserMetadataEvent()?.contactMetaData() ?: return "null" + return buildJsonObject { + md.name?.let { put("name", it) } + md.displayName?.let { put("displayName", it) } + md.about?.let { put("about", it) } + md.picture?.let { put("picture", it) } + md.banner?.let { put("banner", it) } + md.nip05?.let { put("nip05", it) } + md.lud16?.let { put("lud16", it) } + md.website?.let { put("website", it) } + }.toString() + } + + /** Builds `{ "": { "read": bool, "write": bool }, ... }` from the user's NIP-65 list. */ + private fun relaysJson(): String { + val relays = account.nip65RelayList.getNIP65RelayList()?.relays() ?: return "null" + return buildJsonObject { + relays.forEach { info -> + putJsonObject(info.relayUrl.url) { + put("read", info.type.isRead()) + put("write", info.type.isWrite()) + } + } + }.toString() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt new file mode 100644 index 0000000000..378d2f88e3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt @@ -0,0 +1,180 @@ +/* + * 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.napplet.gateways + +import android.content.Context +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.napplet.NappletBroker +import com.vitorpamplona.amethyst.commons.napplet.NappletConsentPrompt +import com.vitorpamplona.amethyst.commons.napplet.NappletIdentityGateway +import com.vitorpamplona.amethyst.commons.napplet.NappletRelayGateway +import com.vitorpamplona.amethyst.commons.napplet.NappletResourceGateway +import com.vitorpamplona.amethyst.commons.napplet.NappletStorage +import com.vitorpamplona.amethyst.commons.napplet.NappletUploadGateway +import com.vitorpamplona.amethyst.commons.napplet.NappletUploadResult +import com.vitorpamplona.amethyst.commons.napplet.NappletWalletGateway +import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.napplet.NappletConsentCoordinator +import com.vitorpamplona.amethyst.napplet.NappletConsentSummary +import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeout +import java.io.ByteArrayInputStream + +/** + * The account adapter: turns a signed-in [Account] into a configured [NappletBroker] by wiring the + * platform gateway implementations (relay publish/query, consent, wallet/NWC, resource fetch, + * identity reads, blob upload). The broker itself owns the trust boundary; this class only supplies + * the Android/account-backed plumbing. Built once per account and cached by the caller. + */ +class AccountNappletGateways( + private val account: Account, + private val context: Context, + private val ledger: NappletPermissionLedger, + private val storage: NappletStorage, + private val torPort: () -> Int, +) { + private val consentSummary = NappletConsentSummary(context) + private val resourceFetcher = NappletResourceFetcher(account, torPort) + private val identityReader = AccountIdentityReader(account) + + fun broker(): NappletBroker { + val relay = + object : NappletRelayGateway { + override suspend fun publish(event: Event): List { + val relays = account.computeRelayListToBroadcast(event) + account.client.publish(event, relays) + return relays.map { it.url } + } + + override suspend fun query(filters: List): List = queryEvents(filters) + } + + val consent = + NappletConsentPrompt { identity, capability, request -> + NappletConsentCoordinator.requestConsent( + context = context, + info = consentSummary.info(identity, capability, request), + ) + } + + val wallet = NappletWalletGateway { invoice -> payInvoiceViaNwc(invoice) } + val resource = NappletResourceGateway { url -> resourceFetcher.fetch(url) } + val identityReads = NappletIdentityGateway { method, argument -> identityReader.read(method, argument) } + val upload = NappletUploadGateway { bytes, contentType, filename -> uploadBlob(bytes, contentType, filename) } + + return NappletBroker(account.signer, ledger, consent, relay, storage, wallet, resource, upload = upload, identityReads = identityReads) + } + + /** + * Uploads [bytes] to the user's first Blossom server (kind:10063) with a signed authorization + * event, via the app's existing [BlossomUploader]. Returns null when there's no server or the + * upload fails. Consent is enforced by the broker before this runs. + */ + private suspend fun uploadBlob( + bytes: ByteArray, + contentType: String, + filename: String?, + ): NappletUploadResult? { + val server = + account.blossomServers + .getBlossomServersList() + ?.servers() + ?.firstOrNull() ?: return null + val hash = sha256(bytes).toHexKey() + val result = + runCatching { + BlossomUploader().upload( + inputStream = ByteArrayInputStream(bytes), + hash = hash, + length = bytes.size.toLong(), + baseFileName = filename, + contentType = contentType, + alt = null, + sensitiveContent = null, + serverBaseUrl = server, + okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, + httpAuth = { h, size, alt -> account.createBlossomUploadAuth(h, size, alt) }, + context = context, + ) + }.getOrNull() ?: return null + val url = result.url ?: return null + return NappletUploadResult(url, result.sha256, result.size, result.type) + } + + /** Bounded live relay fetch (EOSE/timeout) for all [filters], merged with the local cache, newest-first. */ + private suspend fun queryEvents(filters: List): List { + if (filters.isEmpty()) return emptyList() + val relays = account.homeRelays.flow.value + val fromRelays = + if (relays.isEmpty()) { + emptyList() + } else { + runCatching { + account.client.fetchAll(filters = relays.associateWith { filters }, timeoutMs = QUERY_TIMEOUT_MS) + }.getOrDefault(emptyList()) + } + val fromCache = filters.flatMap { filter -> account.cache.filter(filter).mapNotNull { it.event } } + + val merged = + (fromRelays + fromCache) + .distinctBy { it.id } + .sortedByDescending { it.createdAt } + val limit = filters.mapNotNull { it.limit }.maxOrNull() + return limit?.let { merged.take(it) } ?: merged + } + + /** + * Pays [invoice] via the user's connected NWC wallet, returning the preimage on success. + * Throws (→ `Failed`) when no wallet is connected, the wallet reports an error, or it does not + * respond in time — so the applet never silently believes a payment succeeded. + */ + private suspend fun payInvoiceViaNwc(invoice: String): String? { + if (account.nip47SignerState.defaultWalletUri.value == null) { + throw IllegalStateException("No Lightning wallet is connected.") + } + + val result = CompletableDeferred() + account.sendZapPaymentRequestFor(invoice, null) { response -> + when (response) { + is PayInvoiceSuccessResponse -> result.complete(response.result?.preimage) + is PayInvoiceErrorResponse -> result.completeExceptionally(RuntimeException(response.error?.message ?: "Payment failed.")) + is NwcErrorResponse -> result.completeExceptionally(RuntimeException(response.error?.message ?: "Wallet error.")) + else -> result.completeExceptionally(RuntimeException("Unexpected wallet response.")) + } + } + return withTimeout(WALLET_TIMEOUT_MS) { result.await() } + } + + companion object { + private const val QUERY_TIMEOUT_MS = 8_000L + private const val WALLET_TIMEOUT_MS = 60_000L + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt new file mode 100644 index 0000000000..3da003291d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt @@ -0,0 +1,160 @@ +/* + * 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.napplet.gateways + +import android.util.Base64 +import com.vitorpamplona.amethyst.commons.napplet.NappletResource +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver +import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.sniffContentType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.net.InetSocketAddress +import java.net.Proxy +import java.net.URLDecoder + +/** + * Fetches a resource URL on an applet's behalf — the applet has no direct network + * (`connect-src 'none'`), so every `resource.bytes` is brokered through here. Handles `data:`, + * `https:`, and `blossom:` URLs; blossom blobs are content-addressed and **sha256-verified** before + * returning, so a wrong server can never substitute the blob. + * + * Owns a Tor-routed [OkHttpClient], cached and rebuilt only when the active Tor port ([torPort]) + * changes. Built per account (so it reads the right Blossom server list); consent is enforced by the + * broker before [fetch] ever runs. + */ +class NappletResourceFetcher( + private val account: Account, + private val torPort: () -> Int, +) { + // Reused blob HTTP client, keyed by the active Tor port (see client()). + private var cachedHttp: Pair? = null + + /** Fetches an https/data/blossom resource, or null if unsupported/unavailable. */ + suspend fun fetch(url: String): NappletResource? = + withContext(Dispatchers.IO) { + when { + url.startsWith("data:") -> decodeDataUrl(url) + url.startsWith("https://") -> { + runCatching { + client() + .newCall( + Request + .Builder() + .url(url) + .get() + .build(), + ).execute() + .use { r -> + if (!r.isSuccessful) return@withContext null + val body = r.body.bytes() + val type = r.header("Content-Type") ?: "application/octet-stream" + NappletResource(body, type) + } + }.getOrNull() + } + url.startsWith("blossom:") -> fetchBlossom(url) + // nostr: resolution (event → bytes) is unspecified for resource.bytes; left as a follow-up. + else -> null + } + } + + /** + * Tor-routed OkHttp client for host-side blob fetches (the applet has no direct network). + * Cached and reused for connection pooling; rebuilt only when the Tor proxy port changes. + */ + @Synchronized + private fun client(): OkHttpClient { + val port = torPort() + cachedHttp?.let { (cachedPort, client) -> if (cachedPort == port) return client } + val client = + if (port > 0) { + OkHttpClient.Builder().proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port))).build() + } else { + OkHttpClient() + } + cachedHttp = port to client + return client + } + + /** + * Fetches a `blossom:` (or `blossom://`) blob from the user's Blossom servers + * (kind:10063), verifying the sha256 before returning — content-addressed, so a wrong server + * can never substitute the blob. Returns null for a malformed hash or if no server serves it. + */ + private fun fetchBlossom(url: String): NappletResource? { + val hash = + url + .removePrefix("blossom://") + .removePrefix("blossom:") + .substringBefore('/') + .substringBefore('?') + .trim() + .lowercase() + if (!hash.matches(Regex("^[0-9a-f]{64}$"))) return null + + val servers = + account.blossomServers + .getBlossomServersList() + ?.servers() + .orEmpty() + val client = client() + for (candidate in StaticSiteResolver.candidateUrls(servers, hash)) { + val bytes = + runCatching { + client + .newCall( + Request + .Builder() + .url(candidate) + .get() + .build(), + ).execute() + .use { r -> + if (r.isSuccessful) r.body.bytes() else null + } + }.getOrNull() ?: continue + if (StaticSiteResolver.verify(bytes, hash)) { + return NappletResource(bytes, sniffContentType(bytes) ?: "application/octet-stream") + } + } + return null + } + + /** Parses a `data:[][;base64],` URL into bytes + content type. */ + private fun decodeDataUrl(url: String): NappletResource? { + val comma = url.indexOf(',') + if (comma < 0) return null + val meta = url.substring("data:".length, comma) + val data = url.substring(comma + 1) + val isBase64 = meta.endsWith(";base64") + val contentType = meta.removeSuffix(";base64").ifEmpty { "text/plain" } + val bytes = + if (isBase64) { + runCatching { Base64.decode(data, Base64.DEFAULT) }.getOrNull() ?: return null + } else { + URLDecoder.decode(data, "UTF-8").encodeToByteArray() + } + return NappletResource(bytes, contentType) + } +}