mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
@@ -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,
|
||||
)
|
||||
|
||||
+33
@@ -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)
|
||||
}
|
||||
|
||||
+23
-40
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user