feat(privacy): plug remaining HTTP paths into the route-aware stack

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<Int, MediaSessionPool> 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.
This commit is contained in:
Claude
2026-05-19 00:16:13 +00:00
parent 19c750dede
commit 4e796c8e8b
3 changed files with 88 additions and 44 deletions
@@ -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,
)
@@ -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)
}
@@ -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<Int, MediaSessionPool>()
@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)
}
}