refactor(napplet): route blob fetches through the shared OkHttpClientFactory

The t4 onion-location proposal hand-wired OnionLocationInterceptor +
OnionUrlRewriteInterceptor into NappletResourceFetcher's private,
torPort-keyed OkHttpClient. That reached onion-routing parity but
duplicated the exact wiring OkHttpClientFactory already does, and the
private client still missed the local Blossom cache redirect, the shared
connection pool / HTTP-2 keepalive, and SurgeDns.

Inject the app-wide client instead: NappletResourceFetcher now takes a
() -> OkHttpClient and the broker supplies
`okHttpClients.getHttpClient(useProxy = true)` — the same DualHttpClientManager
path the image pipeline uses. Behavior-preserving for Tor (proxied when
Tor is active, clearnet when not) and, since these are sha256 blobs, the
shared Blossom-cache redirect is now a feature, not a loss. Drops the
private client + its cache and the hand-wired interceptors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-06-26 17:32:57 -04:00
co-authored by Claude Opus 4.8
parent 6209378b83
commit c797033ba5
3 changed files with 20 additions and 46 deletions
@@ -320,7 +320,8 @@ class NappletBrokerService : Service() {
context = applicationContext,
ledger = ledger,
storage = storage,
torPort = { Amethyst.instance.torManager.activePortOrNull.value ?: -1 },
// Prefer Tor when active; the shared manager falls back to clearnet when it isn't.
httpClient = { Amethyst.instance.okHttpClients.getHttpClient(useProxy = true) },
).broker()
cachedBroker = account to broker
return broker
@@ -56,6 +56,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.withTimeout
import okhttp3.OkHttpClient
import java.io.ByteArrayInputStream
import kotlin.time.Duration.Companion.seconds
@@ -70,14 +71,14 @@ class AccountNappletGateways(
private val context: Context,
private val ledger: NappletPermissionLedger,
private val storage: NappletStorage,
private val torPort: () -> Int,
private val httpClient: () -> OkHttpClient,
) {
private val consentSummary = NappletConsentSummary(context)
// Share the app-wide OnionLocationCache so any `Onion-Location` learned
// elsewhere (NIP-11 docs, relay handshakes, image hosts, money endpoints)
// also benefits napplet HTTP blob fetches over Tor — and vice versa.
private val resourceFetcher = NappletResourceFetcher(account, torPort, Amethyst.instance.onionLocationCache)
// Reuse the app-wide HTTP client so napplet blob fetches inherit the same Tor
// routing, Onion-Location discovery/rewriting, Blossom cache and pool as the
// rest of the app, instead of a private client that has to re-wire all of it.
private val resourceFetcher = NappletResourceFetcher(account, httpClient)
private val identityReader = AccountIdentityReader(account)
fun broker(): NappletBroker {
@@ -23,9 +23,6 @@ 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.amethyst.service.okhttp.OnionLocationCache
import com.vitorpamplona.amethyst.service.okhttp.OnionLocationInterceptor
import com.vitorpamplona.amethyst.service.okhttp.OnionUrlRewriteInterceptor
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
@@ -43,8 +40,6 @@ 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
/**
@@ -53,22 +48,17 @@ import java.net.URLDecoder
* `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.
* Network goes through the app-wide [OkHttpClient] supplied by [httpClient] (the shared
* [com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager]). Reusing it — rather than
* standing up a private client — means napplet blob fetches inherit the same Tor routing,
* passive `Onion-Location` discovery + `.onion` rewriting, local Blossom cache redirect,
* connection pool and DNS as every other HTTP role. 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,
// Shared with the rest of the app so an `Onion-Location` learned via any
// OkHttp client (e.g. an NIP-11 fetch on a relay socket) transparently
// applies to napplet blob fetches too — and vice versa. Optional so unit
// tests can construct this without standing up the cache.
private val onionCache: OnionLocationCache? = null,
private val httpClient: () -> OkHttpClient,
) {
// 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) {
@@ -150,30 +140,12 @@ class NappletResourceFetcher(
}
/**
* 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.
* The app-wide OkHttp client for host-side blob fetches (the applet has no direct network).
* The shared manager already routes through Tor when active, captures `Onion-Location` and
* rewrites `.onion`s, bridges the local Blossom cache, and pools connections — so there is no
* private client to build or cache here.
*/
@Synchronized
private fun client(): OkHttpClient {
val port = torPort()
cachedHttp?.let { (cachedPort, client) -> if (cachedPort == port) return client }
// Both variants passively capture `Onion-Location` headers into the
// shared cache. The Tor-routed variant additionally rewrites outbound
// URLs to known `.onion`s so applet blob fetches over Tor avoid exit
// nodes when the destination has advertised an onion. Clearnet variant
// never rewrites (DNS would fail on `.onion`).
val builder = OkHttpClient.Builder()
if (port > 0) {
builder.proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port)))
}
onionCache?.let { builder.addInterceptor(OnionLocationInterceptor(it)) }
if (port > 0) {
onionCache?.let { builder.addInterceptor(OnionUrlRewriteInterceptor(it)) }
}
val client = builder.build()
cachedHttp = port to client
return client
}
private fun client(): OkHttpClient = httpClient()
/**
* Fetches a `blossom:<sha256>` (or `blossom://<sha256>`) blob from the user's Blossom servers