mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
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:
+23
-469
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+119
@@ -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()
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.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()
|
||||
}
|
||||
}
|
||||
+180
@@ -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
|
||||
}
|
||||
}
|
||||
+160
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user