refactor(napplet): split the host Service and Activity by responsibility

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
This commit is contained in:
Claude
2026-06-22 00:45:00 +00:00
parent b6f9630883
commit 4453985f68
8 changed files with 871 additions and 622 deletions
@@ -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<Account, NappletBroker>? = null
// Reused blob HTTP client, keyed by the active Tor port (see blobHttpClient()).
private var cachedHttp: Pair<Int, OkHttpClient>? = 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<String, LiveSub>()
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<String> {
val relays = account.computeRelayListToBroadcast(event)
account.client.publish(event, relays)
return relays.map { it.url }
}
override suspend fun query(filters: List<Filter>): List<Event> = 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<UserTag>()
.map { it.pubKey },
)
"getBlocked" ->
jsonStringArray(
account.blockPeopleList.flow.value
.filterIsInstance<UserTag>()
.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>): 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 `{ "<relay url>": { "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:<sha256>` (or `blossom://<sha256>`) 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:[<mediatype>][;base64],<data>` 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<Filter>,
): List<Event> {
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<Filter>,
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<Filter>?,
) = 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<Filter>?,
) {
if (sub.eoseSent.compareAndSet(false, true)) push(replyTo, NappletProtocolJson.encodeRelayEose(nappletSubId))
}
override fun onClosed(
message: String,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) = 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<String?>()
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
}
}
@@ -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 -> ""
}
}
@@ -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<PathTag>,
private val servers: List<String>,
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 = "<script>$shimJs</script>"
val headIdx = text.indexOf("<head", ignoreCase = true)
val injected =
when {
headIdx >= 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 (`<server>/<sha256>`) 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<String, String> {
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
}
}
@@ -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 = "<script>$shimJs</script>"
val headIdx = text.indexOf("<head", ignoreCase = true)
val injected =
when {
headIdx >= 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 (`<server>/<sha256>`) 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<String, String> {
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
}
}
@@ -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<String, LiveSub>()
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<Filter>,
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<Filter>?,
) = 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<Filter>?,
) {
if (sub.eoseSent.compareAndSet(false, true)) push(NappletProtocolJson.encodeRelayEose(nappletSubId))
}
override fun onClosed(
message: String,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) = 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()
}
}
@@ -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<UserTag>()
.map { it.pubKey },
)
"getBlocked" ->
jsonStringArray(
account.blockPeopleList.flow.value
.filterIsInstance<UserTag>()
.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>): 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 `{ "<relay url>": { "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()
}
}
@@ -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<String> {
val relays = account.computeRelayListToBroadcast(event)
account.client.publish(event, relays)
return relays.map { it.url }
}
override suspend fun query(filters: List<Filter>): List<Event> = 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<Filter>): List<Event> {
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<String?>()
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
}
}
@@ -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<Int, OkHttpClient>? = 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:<sha256>` (or `blossom://<sha256>`) 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:[<mediatype>][;base64],<data>` 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)
}
}