From 4e796c8e8b79126982a9271f5486d1e8b1950dce Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 00:16:13 +0000 Subject: [PATCH] feat(privacy): plug remaining HTTP paths into the route-aware stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three callers were still bypassing PrivacyRouter and would route Tor-only or direct-only regardless of the picker: - Coil ImageLoader (AppModules): called okHttpClients.getHttpClient(shouldUseTorForImageDownload(it)), which collapses to a boolean and can't pick I2P. Now uses roleBasedHttpClientBuilder.okHttpClientForImage(it) so images route through the user's preferred clearnet transport and hidden-service hostnames hard-pin. - ExoPlayer pool (PlaybackService): had separate poolNoProxy / poolWithProxy named fields and bound them to getDynamicCallFactory(useProxy: Boolean) — i.e. with-proxy was always the Tor-proxied client. Reshaped to a poolsByPort: Map keyed by the SOCKS port returned by proxyPortForVideo(url). DualHttpClientManager gains getHttpClientForPort and PortBasedCallFactory to resolve the right OkHttpClient (direct / Tor / I2P) live based on port. - Nostr relay websocket builder (AppModules): only consulted torEvaluator, which has no notion of .i2p. Now hard-pins .onion to Tor and .i2p to I2P (throwing BlockedRouteException via the fail-closed contract when the matching daemon isn't ready) and falls through to torEvaluator for clearnet relays — the existing TorRelayEvaluation per-relay-class booleans stay as-is for clearnet routing. --- .../com/vitorpamplona/amethyst/AppModules.kt | 36 +++++++++-- .../service/okhttp/DualHttpClientManager.kt | 33 ++++++++++ .../playback/service/PlaybackService.kt | 63 +++++++------------ 3 files changed, 88 insertions(+), 44 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 221f6858ce..e34ebcf34e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -26,6 +26,8 @@ import coil3.disk.DiskCache import coil3.memory.MemoryCache import com.vitorpamplona.amethyst.commons.i2p.I2pSettings import com.vitorpamplona.amethyst.commons.model.NoteState +import com.vitorpamplona.amethyst.commons.privacy.BlockReason +import com.vitorpamplona.amethyst.commons.privacy.PrivacyRoute import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash import com.vitorpamplona.amethyst.commons.tor.TorSettings import com.vitorpamplona.amethyst.model.Account @@ -58,6 +60,7 @@ import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.notifications.AlwaysOnNotificationServiceManager import com.vitorpamplona.amethyst.service.notifications.NotificationDispatcher import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver +import com.vitorpamplona.amethyst.service.okhttp.BlockedRouteException import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache @@ -95,6 +98,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.HiddenServiceKind +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.classifyHidden import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache import com.vitorpamplona.quartz.nip03Timestamp.ots.OtsBlockHeightCache import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client @@ -365,11 +370,34 @@ class AppModules( dns = surgeDns, ) - // Connects the INostrClient class with okHttp + // Connects the INostrClient class with okHttp. + // + // Hidden-service hostnames (.onion / .i2p) hard-pin to their matching + // network — same fail-closed contract PrivacyRouter applies to ad-hoc HTTP + // traffic. Clearnet relays still go through the existing TorRelayEvaluation + // (its per-relay-class booleans are richer than the global picker for the + // relay case, and don't conflict with the picker since the picker is about + // clearnet ad-hoc HTTP traffic, not the relay subscription pool). val websocketBuilder = OkHttpWebSocket.Builder { url -> - val useTor = torEvaluatorFlow.flow.value.useTor(url) - okHttpClientForRelays.getHttpClient(useTor) + when (url.classifyHidden()) { + HiddenServiceKind.ONION -> + if (torManager.isSocksReady()) { + okHttpClientForRelays.getHttpClient(true) + } else { + throw BlockedRouteException(BlockReason.ONION_REQUIRES_TOR) + } + HiddenServiceKind.I2P -> + if (i2pManager.isSocksReady()) { + okHttpClientForRelays.getHttpClient(PrivacyRoute.I2p) + } else { + throw BlockedRouteException(BlockReason.I2P_REQUIRES_I2P) + } + HiddenServiceKind.LOCALHOST, HiddenServiceKind.CLEARNET -> { + val useTor = torEvaluatorFlow.flow.value.useTor(url) + okHttpClientForRelays.getHttpClient(useTor) + } + } } // Caches all events in Memory @@ -588,7 +616,7 @@ class AppModules( diskCache = { diskCache }, memoryCache = { memoryCache }, blossomServerResolver = { blossomResolver }, - callFactory = { okHttpClients.getHttpClient(roleBasedHttpClientBuilder.shouldUseTorForImageDownload(it)) }, + callFactory = { roleBasedHttpClientBuilder.okHttpClientForImage(it) }, thumbnailCache = thumbnailDiskCache, backgroundScope = applicationIOScope, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt index 0b54490010..9576236d9c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt @@ -124,6 +124,26 @@ class DualHttpClientManager( fun getDynamicCallFactory(useProxy: Boolean) = DynamicCallFactory(useProxy, this) + /** + * Resolves to the OkHttpClient whose attached SOCKS proxy matches [port]. Used + * by ExoPlayer's per-port pool to pick the correct transport — `0` / `null` + * means direct, the live Tor port maps to the Tor-proxied client, the live I2P + * port maps to the I2P-proxied client. Unknown non-zero ports fall back to + * direct rather than guess. + */ + fun getHttpClientForPort(port: Int?): OkHttpClient { + if (port == null || port <= 0) return defaultHttpClientWithoutProxy.value + val torPort = (defaultHttpClient.value.proxy?.address() as? InetSocketAddress)?.port + val i2pPort = (i2pHttpClient.value.proxy?.address() as? InetSocketAddress)?.port + return when (port) { + torPort -> defaultHttpClient.value + i2pPort -> i2pHttpClient.value + else -> defaultHttpClientWithoutProxy.value + } + } + + fun getDynamicCallFactoryForPort(port: Int) = PortBasedCallFactory(port, this) + companion object { fun blockedException(reason: BlockReason): BlockedRouteException = BlockedRouteException(reason) } @@ -138,3 +158,16 @@ class DynamicCallFactory( ) : Call.Factory { override fun newCall(request: Request): Call = manager.getHttpClient(useProxy).newCall(request) } + +/** + * Port-keyed version of [DynamicCallFactory]. Lets ExoPlayer's per-port pool + * route through Tor or I2P (or direct) based on the SOCKS port the caller asked + * for — resolved live so a proxy-port change without a pool rebuild still picks + * the right OkHttpClient. + */ +class PortBasedCallFactory( + val port: Int, + val manager: DualHttpClientManager, +) : Call.Factory { + override fun newCall(request: Request): Call = manager.getHttpClientForPort(port).newCall(request) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt index 0c636f8a9a..b73e7afb8a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt @@ -34,7 +34,6 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.service.okhttp.DynamicCallFactory import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerBuilder @@ -44,15 +43,19 @@ import com.vitorpamplona.amethyst.service.playback.playerPool.SimultaneousPlayba import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.runBlocking +import okhttp3.Call class PlaybackService : MediaSessionService() { - private var poolNoProxy: MediaSessionPool? = null - private var poolWithProxy: MediaSessionPool? = null + // One pool per distinct SOCKS port. With three privacy transports we have at + // most three pools (direct/0, Tor port, I2P port). Each pool's call factory is + // bound to its port so a transport switch via the privacy picker just creates + // a new pool instead of misrouting traffic through an old one. + private val poolsByPort = mutableMapOf() @OptIn(UnstableApi::class) fun newPool( videoCache: VideoCache, - okHttpClient: DynamicCallFactory, + okHttpClient: Call.Factory, blossomServerResolver: BlossomServerResolver, ): MediaSessionPool { val dataSourceFactory = OkHttpDataSource.Factory(okHttpClient) @@ -96,39 +99,20 @@ class PlaybackService : MediaSessionService() { @OptIn(UnstableApi::class) fun lazyPool(proxyPort: Int): MediaSessionPool { - return if (proxyPort <= 0) { - // no proxy - poolNoProxy?.let { return it } + // Normalize all "no proxy" intents onto port 0 so direct callers share a pool. + val key = if (proxyPort < 0) 0 else proxyPort + poolsByPort[key]?.let { return it } - val okHttpClient = Amethyst.instance.okHttpClients.getDynamicCallFactory(false) - val videoCache = Amethyst.instance.videoCache - val blossomServerResolver = Amethyst.instance.blossomResolver + val okHttpClient = Amethyst.instance.okHttpClients.getDynamicCallFactoryForPort(key) + val videoCache = Amethyst.instance.videoCache + val blossomServerResolver = Amethyst.instance.blossomResolver - // creates new - newPool(videoCache, okHttpClient, blossomServerResolver) - .also { - poolNoProxy = it - // Kick off the player pool warmup as soon as we know this pool is being used. - // It runs async on the main looper, yielding between builds, so the very first - // session still acquires synchronously while subsequent ones can grab a warm - // ExoPlayer instead of paying the build cost on the main thread. - it.exoPlayerPool.create(applicationContext) - } - } else { - poolWithProxy?.let { return it } - - // creates brand new - // proxy port can change without affecting the pool because - // the choice of okhttp is resolved in newCall - val okHttpClient = Amethyst.instance.okHttpClients.getDynamicCallFactory(true) - val videoCache = Amethyst.instance.videoCache - val blossomServerResolver = Amethyst.instance.blossomResolver - - newPool(videoCache, okHttpClient, blossomServerResolver) - .also { - poolWithProxy = it - it.exoPlayerPool.create(applicationContext) - } + return newPool(videoCache, okHttpClient, blossomServerResolver).also { + poolsByPort[key] = it + // Warm up the ExoPlayer pool for the first use of this transport so the very + // first session acquires synchronously while subsequent ones grab a warm + // ExoPlayer instead of paying the build cost on the main thread. + it.exoPlayerPool.create(applicationContext) } } @@ -140,8 +124,8 @@ class PlaybackService : MediaSessionService() { override fun onDestroy() { Log.d("PlaybackService", "PlaybackService.onDestroy") - poolWithProxy?.destroy() - poolNoProxy?.destroy() + poolsByPort.values.forEach { it.destroy() } + poolsByPort.clear() super.onDestroy() } @@ -161,12 +145,11 @@ class PlaybackService : MediaSessionService() { // 2. b. On screen video with volume on // 2. c. On screen video with volume off. - val playing = (poolWithProxy?.playingContent() ?: emptyList()) + (poolNoProxy?.playingContent() ?: emptyList()) + val playing = poolsByPort.values.flatMap { it.playingContent() } - // if nothing is pl if (playing.isEmpty() && BackgroundMedia.hasInstance()) { BackgroundMedia.bgInstance?.id?.let { id -> - (poolNoProxy?.getSession(id) ?: poolWithProxy?.getSession(id))?.let { + poolsByPort.values.firstNotNullOfOrNull { it.getSession(id) }?.let { super.onUpdateNotification(it, startInForegroundRequired) } }