diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index f2c5af1dc..f50cd12b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -24,7 +24,10 @@ import android.content.Context import androidx.security.crypto.EncryptedSharedPreferences 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.services.lnurl.OkHttpLnurlEndpointResolver import com.vitorpamplona.amethyst.commons.tor.TorSettings @@ -36,10 +39,13 @@ import com.vitorpamplona.amethyst.model.nip03Timestamp.BitcoinExplorerEndpoint import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier import com.vitorpamplona.amethyst.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever +import com.vitorpamplona.amethyst.model.preferences.I2pSharedPreferences import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences +import com.vitorpamplona.amethyst.model.preferences.PrivacySharedPreferences import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences +import com.vitorpamplona.amethyst.model.privacyOptions.PrivacyRoutingFlow import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector import com.vitorpamplona.amethyst.model.torState.TorRelayState @@ -56,6 +62,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 @@ -80,6 +87,7 @@ import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.LocalBlossomCacheProbe import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory +import com.vitorpamplona.amethyst.ui.i2p.I2pManager import com.vitorpamplona.amethyst.ui.resourceCacheInit import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.AccountState @@ -92,6 +100,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.okhttp.OkHttpBitcoinExplorer import com.vitorpamplona.quartz.nip03Timestamp.ots.OtsBlockHeightCache @@ -154,6 +164,18 @@ class AppModules( TorSharedPreferences(prefs, appContext, applicationIOScope) } + private val i2pPrefsDeferred = + applicationIOScope.async { + val prefs = I2pSharedPreferences.i2pPreferences(appContext) ?: I2pSettings() + I2pSharedPreferences(prefs, appContext, applicationIOScope) + } + + private val privacyPrefsDeferred = + applicationIOScope.async { + val initial = PrivacySharedPreferences.preferredClearnetTransport(appContext) + PrivacySharedPreferences(initial, appContext, applicationIOScope) + } + // Blocking load of UI Preferences to avoid theme/language blinking val uiPrefs by lazy { Log.d("AppModules", "UiSharedPreferences Init") @@ -166,6 +188,21 @@ class AppModules( runBlocking { torPrefsDeferred.await() } } + // Blocking load of I2P settings — paired with torPrefs since both feed the + // privacy transport routing decision and we don't want clearnet to leak before + // either is restored. + val i2pPrefs by lazy { + Log.d("AppModules", "I2pSharedPreferences Init") + runBlocking { i2pPrefsDeferred.await() } + } + + // Picks which transport (if any) carries clearnet traffic. Loaded blocking for + // the same reason as torPrefs/i2pPrefs. + val privacyPrefs by lazy { + Log.d("AppModules", "PrivacySharedPreferences Init") + runBlocking { privacyPrefsDeferred.await() } + } + // Namecoin ElectrumX server preferences (global, like Tor settings) val namecoinPrefs by lazy { Log.d("AppModules", "NamecoinSharedPreferences Init") @@ -192,6 +229,10 @@ class AppModules( val torManager = TorManager(torPrefs, appContext, applicationIOScope) + // I2P manager: EXTERNAL-only (i2pd / Java I2P at the configured SOCKS port). + // No embedded daemon in this branch; INTERNAL is reserved for a follow-up. + val i2pManager = I2pManager(i2pPrefs, applicationIOScope) + // Whenever the underlying network identity changes (wifi↔cellular, regained from // offline, etc.) we clear any active Tor session bypass so the manager re-attempts // bootstrap on the new network. The remembered-approval window is unaffected: if Tor @@ -228,6 +269,7 @@ class AppModules( DualHttpClientManager( userAgent = appAgent, proxyPortProvider = torManager.activePortOrNull, + i2pProxyPortProvider = i2pManager.activePortOrNull, isMobileDataProvider = connManager.isMobileOrNull, keyCache = keyCache, scope = applicationIOScope, @@ -243,8 +285,15 @@ class AppModules( }, ) - // Offers easy methods to know when connections are happening through Tor or not - val roleBasedHttpClientBuilder = RoleBasedHttpClientBuilder(okHttpClients, torPrefs.value) + // Read-side facade over PrivacyRouter. Routes hidden services strictly to their + // matching daemon (Blocked when off) and clearnet to whichever transport the + // user picked as preferred. + val privacyRouting = PrivacyRoutingFlow(torPrefs.value, i2pPrefs.value, privacyPrefs) + + // Resolves a transport per ad-hoc HTTP call (images, NIP-05, etc.) and hands + // back the matching OkHttpClient from okHttpClients. Hidden-service URLs fail + // closed via DualHttpClientManager.blockedException when their daemon is off. + val roleBasedHttpClientBuilder = RoleBasedHttpClientBuilder(okHttpClients, privacyRouting) val electrumXClient by lazy { Log.d("AppModules", "ElectrumXClient Init") @@ -320,16 +369,40 @@ class AppModules( DualHttpClientManagerForRelays( userAgent = appAgent, proxyPortProvider = torManager.activePortOrNull, + i2pProxyPortProvider = i2pManager.activePortOrNull, isMobileDataProvider = connManager.isMobileOrNull, scope = applicationIOScope, 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 @@ -582,7 +655,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/model/preferences/I2pSharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/I2pSharedPreferences.kt new file mode 100644 index 000000000..8d0a082f8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/I2pSharedPreferences.kt @@ -0,0 +1,138 @@ +/* + * 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.model.preferences + +import android.content.Context +import androidx.compose.runtime.Stable +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.commons.i2p.I2pSettings +import com.vitorpamplona.amethyst.commons.i2p.I2pType +import com.vitorpamplona.amethyst.ui.i2p.I2pSettingsFlow +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlin.coroutines.cancellation.CancellationException + +@Stable +class I2pSharedPreferences( + prefs: I2pSettings, + val context: Context, + val scope: CoroutineScope, +) { + val value = I2pSettingsFlow.build(prefs) + + @OptIn(FlowPreview::class) + val saving = + value.propertyWatchFlow + .debounce(1000) + .distinctUntilChanged() + .onEach { + save(it, context) + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + value.toSettings(), + ) + + companion object { + val I2P_TYPE_KEY = stringPreferencesKey("i2p.i2pType") + val EXTERNAL_SOCKS_PORT_KEY = intPreferencesKey("i2p.externalSocksPort") + val I2P_RELAYS_VIA_I2P_KEY = booleanPreferencesKey("i2p.i2pRelaysViaI2p") + val DM_RELAYS_VIA_I2P_KEY = booleanPreferencesKey("i2p.dmRelaysViaI2p") + val NEW_RELAYS_VIA_I2P_KEY = booleanPreferencesKey("i2p.newRelaysViaI2p") + val TRUSTED_RELAYS_VIA_I2P_KEY = booleanPreferencesKey("i2p.trustedRelaysViaI2p") + val URL_PREVIEWS_VIA_I2P_KEY = booleanPreferencesKey("i2p.urlPreviewsViaI2p") + val PROFILE_PICS_VIA_I2P_KEY = booleanPreferencesKey("i2p.profilePicsViaI2p") + val IMAGES_VIA_I2P_KEY = booleanPreferencesKey("i2p.imagesViaI2p") + val VIDEOS_VIA_I2P_KEY = booleanPreferencesKey("i2p.videosViaI2p") + val MONEY_OPERATIONS_VIA_I2P_KEY = booleanPreferencesKey("i2p.moneyOperationsViaI2p") + val NIP05_VERIFICATIONS_VIA_I2P_KEY = booleanPreferencesKey("i2p.nip05VerificationsViaI2p") + val MEDIA_UPLOADS_VIA_I2P_KEY = booleanPreferencesKey("i2p.mediaUploadsViaI2p") + + suspend fun i2pPreferences(context: Context): I2pSettings? = + try { + val preferences = context.sharedPreferencesDataStore.data.first() + I2pSettings( + // A stored "INTERNAL" from an earlier branch is no longer a valid I2pType — + // runCatching swallows the IllegalArgumentException so the user lands on OFF + // and can re-enable EXTERNAL from the settings screen. + i2pType = + preferences[I2P_TYPE_KEY] + ?.let { runCatching { I2pType.valueOf(it) }.getOrNull() } + ?: I2pType.OFF, + externalSocksPort = preferences[EXTERNAL_SOCKS_PORT_KEY] ?: 4447, + i2pRelaysViaI2p = preferences[I2P_RELAYS_VIA_I2P_KEY] ?: true, + dmRelaysViaI2p = preferences[DM_RELAYS_VIA_I2P_KEY] ?: false, + newRelaysViaI2p = preferences[NEW_RELAYS_VIA_I2P_KEY] ?: false, + trustedRelaysViaI2p = preferences[TRUSTED_RELAYS_VIA_I2P_KEY] ?: false, + urlPreviewsViaI2p = preferences[URL_PREVIEWS_VIA_I2P_KEY] ?: false, + profilePicsViaI2p = preferences[PROFILE_PICS_VIA_I2P_KEY] ?: false, + imagesViaI2p = preferences[IMAGES_VIA_I2P_KEY] ?: false, + videosViaI2p = preferences[VIDEOS_VIA_I2P_KEY] ?: false, + moneyOperationsViaI2p = preferences[MONEY_OPERATIONS_VIA_I2P_KEY] ?: false, + nip05VerificationsViaI2p = preferences[NIP05_VERIFICATIONS_VIA_I2P_KEY] ?: false, + mediaUploadsViaI2p = preferences[MEDIA_UPLOADS_VIA_I2P_KEY] ?: false, + ) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("SharedPreferences") { "Error reading I2P DataStore preferences: ${e.message}" } + null + } + + suspend fun save( + settings: I2pSettings, + context: Context, + ) { + try { + context.sharedPreferencesDataStore.edit { preferences -> + preferences[I2P_TYPE_KEY] = settings.i2pType.name + preferences[EXTERNAL_SOCKS_PORT_KEY] = settings.externalSocksPort + preferences[I2P_RELAYS_VIA_I2P_KEY] = settings.i2pRelaysViaI2p + preferences[DM_RELAYS_VIA_I2P_KEY] = settings.dmRelaysViaI2p + preferences[NEW_RELAYS_VIA_I2P_KEY] = settings.newRelaysViaI2p + preferences[TRUSTED_RELAYS_VIA_I2P_KEY] = settings.trustedRelaysViaI2p + preferences[URL_PREVIEWS_VIA_I2P_KEY] = settings.urlPreviewsViaI2p + preferences[PROFILE_PICS_VIA_I2P_KEY] = settings.profilePicsViaI2p + preferences[IMAGES_VIA_I2P_KEY] = settings.imagesViaI2p + preferences[VIDEOS_VIA_I2P_KEY] = settings.videosViaI2p + preferences[MONEY_OPERATIONS_VIA_I2P_KEY] = settings.moneyOperationsViaI2p + preferences[NIP05_VERIFICATIONS_VIA_I2P_KEY] = settings.nip05VerificationsViaI2p + preferences[MEDIA_UPLOADS_VIA_I2P_KEY] = settings.mediaUploadsViaI2p + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("SharedPreferences") { "Error saving I2P DataStore preferences: ${e.message}" } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/PrivacySharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/PrivacySharedPreferences.kt new file mode 100644 index 000000000..f98a3e9fe --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/PrivacySharedPreferences.kt @@ -0,0 +1,90 @@ +/* + * 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.model.preferences + +import android.content.Context +import androidx.compose.runtime.Stable +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.commons.privacy.PrivacyTransport +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlin.coroutines.cancellation.CancellationException + +// Global pick of which privacy transport carries clearnet traffic. Lives apart +// from TorSharedPreferences / I2pSharedPreferences because it spans both — each +// transport owns its own daemon-enable + per-feature toggles; this owns the +// "which one is in charge of clearnet" decision. +@Stable +class PrivacySharedPreferences( + initial: PrivacyTransport, + val context: Context, + val scope: CoroutineScope, +) { + val preferredClearnetTransport: MutableStateFlow = MutableStateFlow(initial) + + @OptIn(FlowPreview::class) + val saving = + preferredClearnetTransport + .debounce(1000) + .distinctUntilChanged() + .onEach { save(it, context) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, initial) + + companion object { + val PREFERRED_CLEARNET_TRANSPORT_KEY = stringPreferencesKey("privacy.preferredClearnetTransport") + + suspend fun preferredClearnetTransport(context: Context): PrivacyTransport = + try { + val name = context.sharedPreferencesDataStore.data.first()[PREFERRED_CLEARNET_TRANSPORT_KEY] + name?.let { runCatching { PrivacyTransport.valueOf(it) }.getOrNull() } ?: PrivacyTransport.DIRECT + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("SharedPreferences") { "Error reading preferredClearnetTransport: ${e.message}" } + PrivacyTransport.DIRECT + } + + suspend fun save( + transport: PrivacyTransport, + context: Context, + ) { + try { + context.sharedPreferencesDataStore.edit { prefs -> + prefs[PREFERRED_CLEARNET_TRANSPORT_KEY] = transport.name + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("SharedPreferences") { "Error saving preferredClearnetTransport: ${e.message}" } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/PrivacyRoutingFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/PrivacyRoutingFlow.kt new file mode 100644 index 000000000..7b385a506 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/PrivacyRoutingFlow.kt @@ -0,0 +1,51 @@ +/* + * 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.model.privacyOptions + +import com.vitorpamplona.amethyst.commons.privacy.FeatureRole +import com.vitorpamplona.amethyst.commons.privacy.PrivacyRoute +import com.vitorpamplona.amethyst.commons.privacy.PrivacyRouter +import com.vitorpamplona.amethyst.commons.privacy.PrivacySettings +import com.vitorpamplona.amethyst.model.preferences.PrivacySharedPreferences +import com.vitorpamplona.amethyst.ui.i2p.I2pSettingsFlow +import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow + +// Read-side facade over PrivacyRouter that snapshot-reads from the three live +// pref flows (Tor, I2P, preferred clearnet transport). Exists so callers don't +// have to know how to assemble a PrivacySettings every time — and so the daemon +// work and HTTP routing rewrite that come next have one stable seam to consult. +class PrivacyRoutingFlow( + val tor: TorSettingsFlow, + val i2p: I2pSettingsFlow, + val privacy: PrivacySharedPreferences, +) { + fun routeFor( + role: FeatureRole, + url: String, + ): PrivacyRoute = PrivacyRouter.route(url, role, snapshot()) + + fun snapshot(): PrivacySettings = + PrivacySettings( + tor = tor.toSettings(), + i2p = i2p.toSettings(), + preferredClearnetTransport = privacy.preferredClearnetTransport.value, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt index a332044be..70bd026bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt @@ -20,142 +20,81 @@ */ package com.vitorpamplona.amethyst.model.privacyOptions -import com.vitorpamplona.amethyst.commons.tor.TorType +import com.vitorpamplona.amethyst.commons.privacy.FeatureRole +import com.vitorpamplona.amethyst.commons.privacy.PrivacyRoute import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager -import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import okhttp3.OkHttpClient import java.net.InetSocketAddress import java.net.Proxy import javax.net.SocketFactory +/** + * Role-based router for ad-hoc HTTP traffic (images, videos, NIP-05, etc.). + * + * Delegates to [PrivacyRoutingFlow] for the per-call decision and to + * [DualHttpClientManager] for the actual OkHttpClient with the right SOCKS + * proxy attached. Hidden-service hostnames hard-pin to their matching + * daemon — [DualHttpClientManager.getHttpClient] throws on `Blocked` rather + * than silently leak to the clearnet. + * + * Legacy `shouldUseTorForX` methods remain, returning `true` iff the route + * would end up on Tor (call-site references in AppModules still depend on + * them as method-references); they no longer drive routing themselves. + */ class RoleBasedHttpClientBuilder( val okHttpClient: DualHttpClientManager, - val torSettings: TorSettingsFlow, + val routing: PrivacyRoutingFlow, ) : IRoleBasedHttpClientBuilder { - fun shouldUseTorForImageDownload(url: String) = - shouldUseTorFor( - url, - torSettings.torType.value, - torSettings.imagesViaTor.value, - ) - - fun shouldUseTorFor( + private fun routeFor( + role: FeatureRole, url: String, - torType: TorType, - imagesViaTor: Boolean, - ) = when (torType) { - TorType.OFF -> false - TorType.INTERNAL -> shouldUseTor(url, imagesViaTor) - TorType.EXTERNAL -> shouldUseTor(url, imagesViaTor) - } + ): PrivacyRoute = routing.routeFor(role, url) - private fun shouldUseTor( - normalizedUrl: String, - final: Boolean, - ): Boolean = - if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) { - false - } else if (RelayUrlNormalizer.isOnion(normalizedUrl)) { - true - } else { - final - } + override fun proxyPortForVideo(url: String): Int? = okHttpClient.getCurrentProxyPort(routeFor(FeatureRole.VIDEO, url)) - fun shouldUseTorForVideoDownload() = - when (torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> torSettings.videosViaTor.value - TorType.EXTERNAL -> torSettings.videosViaTor.value - } + override fun okHttpClientForNip05(url: String): OkHttpClient = okHttpClient.getHttpClient(routeFor(FeatureRole.NIP05, url)) - fun shouldUseTorForVideoDownload(url: String) = - when (torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> checkLocalHostOnionAndThen(url, torSettings.videosViaTor.value) - TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, torSettings.videosViaTor.value) - } + override fun okHttpClientForUploads(url: String): OkHttpClient = okHttpClient.getHttpClient(routeFor(FeatureRole.UPLOAD, url)) - fun shouldUseTorForPreviewUrl(url: String) = - when (torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> checkLocalHostOnionAndThen(url, torSettings.urlPreviewsViaTor.value) - TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, torSettings.urlPreviewsViaTor.value) - } + override fun okHttpClientForImage(url: String): OkHttpClient = okHttpClient.getHttpClient(routeFor(FeatureRole.IMAGE, url)) - fun shouldUseTorForTrustedRelays() = - when (torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> torSettings.trustedRelaysViaTor.value - TorType.EXTERNAL -> torSettings.trustedRelaysViaTor.value - } + override fun okHttpClientForVideo(url: String): OkHttpClient = okHttpClient.getHttpClient(routeFor(FeatureRole.VIDEO, url)) - private fun checkLocalHostOnionAndThen( - url: String, - final: Boolean, - ): Boolean = checkLocalHostOnionAndThen(url, torSettings.onionRelaysViaTor.value, final) + override fun okHttpClientForMoney(url: String): OkHttpClient = okHttpClient.getHttpClient(routeFor(FeatureRole.MONEY, url)) - private fun checkLocalHostOnionAndThen( - normalizedUrl: String, - isOnionRelaysActive: Boolean, - final: Boolean, - ): Boolean = - if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) { - false - } else if (RelayUrlNormalizer.isOnion(normalizedUrl)) { - isOnionRelaysActive - } else { - final - } + override fun okHttpClientForPreview(url: String): OkHttpClient = okHttpClient.getHttpClient(routeFor(FeatureRole.URL_PREVIEW, url)) - fun shouldUseTorForMoneyOperations(url: String) = - when (torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> checkLocalHostOnionAndThen(url, torSettings.moneyOperationsViaTor.value) - TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, torSettings.moneyOperationsViaTor.value) - } + // Push registration goes to a trusted relay. Reuse the URL_PREVIEW role for now — + // the legacy code used `shouldUseTorForTrustedRelays()` which was a no-URL + // global flag; under the new model we need a URL to compute a route, so the + // closest analogue is "treat it like any other clearnet ad-hoc HTTP call". + override fun okHttpClientForPushRegistration(url: String): OkHttpClient = okHttpClient.getHttpClient(routeFor(FeatureRole.URL_PREVIEW, url)) - fun shouldUseTorForNIP05(url: String) = - when (torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> checkLocalHostOnionAndThen(url, torSettings.nip05VerificationsViaTor.value) - TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, torSettings.nip05VerificationsViaTor.value) - } + fun shouldUseTorForImageDownload(url: String) = routeFor(FeatureRole.IMAGE, url) is PrivacyRoute.Tor - fun shouldUseTorForUploads(url: String) = - when (torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> checkLocalHostOnionAndThen(url, torSettings.mediaUploadsViaTor.value) - TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, torSettings.mediaUploadsViaTor.value) - } + fun shouldUseTorForVideoDownload(url: String) = routeFor(FeatureRole.VIDEO, url) is PrivacyRoute.Tor - override fun proxyPortForVideo(url: String): Int? = okHttpClient.getCurrentProxyPort(shouldUseTorForVideoDownload(url)) + fun shouldUseTorForPreviewUrl(url: String) = routeFor(FeatureRole.URL_PREVIEW, url) is PrivacyRoute.Tor - override fun okHttpClientForNip05(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForNIP05(url)) + fun shouldUseTorForMoneyOperations(url: String) = routeFor(FeatureRole.MONEY, url) is PrivacyRoute.Tor - override fun okHttpClientForUploads(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForUploads(url)) + fun shouldUseTorForNIP05(url: String) = routeFor(FeatureRole.NIP05, url) is PrivacyRoute.Tor - override fun okHttpClientForImage(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForImageDownload(url)) - - override fun okHttpClientForVideo(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForVideoDownload(url)) - - override fun okHttpClientForMoney(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForMoneyOperations(url)) - - override fun okHttpClientForPreview(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForPreviewUrl(url)) - - override fun okHttpClientForPushRegistration(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForTrustedRelays()) + fun shouldUseTorForUploads(url: String) = routeFor(FeatureRole.UPLOAD, url) is PrivacyRoute.Tor /** - * Returns a [SocketFactory] that routes through the user's Tor proxy - * when NIP-05 verification traffic should use Tor. + * Returns a [SocketFactory] that routes through the user's Tor proxy when + * NIP-05 verification traffic should use Tor. * - * Used by [ElectrumxClient] so that Namecoin lookups respect the - * same proxy/Tor settings as HTTP-based NIP-05 verification, - * preventing IP leaks through direct socket connections. + * Used by ElectrumXClient so that Namecoin lookups respect the same proxy + * settings as HTTP-based NIP-05 verification, preventing IP leaks through + * direct socket connections. + * + * I2P-routed NIP-05 lookups are NOT bridged here yet — ElectrumX over I2P + * needs a SAM/streaming endpoint, not a plain SOCKS hop, and Namecoin name + * servers aren't deployed on I2P in practice. Falls through to default. */ fun socketFactoryForNip05(): SocketFactory { - // ElectrumX servers are always external, so we use a dummy - // non-localhost, non-onion URL to query the Tor policy. val useTor = shouldUseTorForNIP05("https://electrumx.example.com") if (!useTor) return SocketFactory.getDefault() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/BlockedRouteException.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/BlockedRouteException.kt new file mode 100644 index 000000000..7c3fc9cb6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/BlockedRouteException.kt @@ -0,0 +1,48 @@ +/* + * 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.service.okhttp + +import com.vitorpamplona.amethyst.commons.privacy.BlockReason +import java.io.IOException + +/** + * Thrown when a request targets a hidden-service hostname whose matching + * privacy daemon is disabled. The fail-closed semantics chosen by the user — + * we never silently fall back to a clearnet route for `.onion` / `.i2p` URLs. + * + * Subclass of [IOException] so the existing HTTP error paths in Coil, OkHttp + * call factories, etc. propagate it without changes. Callers that want to + * present a clearer UI ("enable Tor/I2P to view this content") can + * pattern-match on the type / reason instead of inspecting the message. + */ +class BlockedRouteException( + val reason: BlockReason, +) : IOException(messageFor(reason)) { + companion object { + fun messageFor(reason: BlockReason): String = + when (reason) { + BlockReason.ONION_REQUIRES_TOR -> + "Cannot reach .onion address: Tor is disabled. Enable Tor in Privacy Options." + BlockReason.I2P_REQUIRES_I2P -> + "Cannot reach .i2p address: I2P is disabled. Enable I2P in Privacy Options." + } + } +} 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 7cb4d3362..9576236d9 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 @@ -20,7 +20,10 @@ */ package com.vitorpamplona.amethyst.service.okhttp +import com.vitorpamplona.amethyst.commons.privacy.BlockReason +import com.vitorpamplona.amethyst.commons.privacy.PrivacyRoute import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -39,6 +42,10 @@ class DualHttpClientManager( keyCache: EncryptionKeyCache, scope: CoroutineScope, dns: SurgeDns, + // EXTERNAL-only for now (the i2pd / Java I2P SOCKS port). Null while I2P is + // OFF or not configured; tri-state routing falls back to Direct rather than + // hold a request when the daemon is unavailable on a clearnet feature. + private val i2pProxyPortProvider: StateFlow = MutableStateFlow(null), shouldBridgeBlossomCache: (() -> Boolean)? = null, ) : IHttpClientManager { val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache) @@ -62,8 +69,19 @@ class DualHttpClientManager( factory.buildHttpClient(isMobileDataProvider.value), ) + val i2pHttpClient: StateFlow = + combine(i2pProxyPortProvider, isMobileDataProvider) { proxy, mobile -> + factory.buildHttpClient(proxy, mobile) + }.stateIn( + scope, + SharingStarted.WhileSubscribed(1000), + factory.buildHttpClient(i2pProxyPortProvider.value, isMobileDataProvider.value), + ) + fun getCurrentProxy(): Proxy? = defaultHttpClient.value.proxy + fun getCurrentI2pProxy(): Proxy? = i2pHttpClient.value.proxy + override fun getCurrentProxyPort(useProxy: Boolean): Int? = if (useProxy) { (getCurrentProxy()?.address() as? InetSocketAddress)?.port @@ -78,7 +96,57 @@ class DualHttpClientManager( defaultHttpClientWithoutProxy.value } + /** + * Route-aware client lookup. Direct → no proxy, Tor → Tor SOCKS, I2p → I2P + * SOCKS, Blocked → IOException so the request fails closed instead of + * silently leaking to the clearnet. + * + * If a daemon-required route lands here while its proxy port is null (daemon + * not yet started), the I2P / Tor client is still returned — the underlying + * OkHttp call will fail to connect rather than fall back to a direct route, + * which is consistent with the fail-closed contract. + */ + fun getHttpClient(route: PrivacyRoute): OkHttpClient = + when (route) { + PrivacyRoute.Direct -> defaultHttpClientWithoutProxy.value + PrivacyRoute.Tor -> defaultHttpClient.value + PrivacyRoute.I2p -> i2pHttpClient.value + is PrivacyRoute.Blocked -> throw blockedException(route.reason) + } + + fun getCurrentProxyPort(route: PrivacyRoute): Int? = + when (route) { + PrivacyRoute.Direct -> null + PrivacyRoute.Tor -> (getCurrentProxy()?.address() as? InetSocketAddress)?.port + PrivacyRoute.I2p -> (getCurrentI2pProxy()?.address() as? InetSocketAddress)?.port + is PrivacyRoute.Blocked -> throw blockedException(route.reason) + } + 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) + } } /** @@ -90,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/okhttp/DualHttpClientManagerForRelays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManagerForRelays.kt index d190c885d..c125ff496 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManagerForRelays.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManagerForRelays.kt @@ -20,7 +20,9 @@ */ package com.vitorpamplona.amethyst.service.okhttp +import com.vitorpamplona.amethyst.commons.privacy.PrivacyRoute import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -36,6 +38,7 @@ class DualHttpClientManagerForRelays( isMobileDataProvider: StateFlow, scope: CoroutineScope, dns: SurgeDns, + private val i2pProxyPortProvider: StateFlow = MutableStateFlow(null), ) : IHttpClientManager { val factory = OkHttpClientFactoryForRelays(userAgent, dns) @@ -58,8 +61,19 @@ class DualHttpClientManagerForRelays( factory.buildHttpClient(isMobileDataProvider.value), ) + val i2pHttpClient: StateFlow = + combine(i2pProxyPortProvider, isMobileDataProvider) { proxy, mobile -> + factory.buildHttpClient(proxy, mobile) + }.stateIn( + scope, + SharingStarted.WhileSubscribed(1000), + factory.buildHttpClient(i2pProxyPortProvider.value, isMobileDataProvider.value), + ) + fun getCurrentProxy(): Proxy? = defaultHttpClient.value.proxy + fun getCurrentI2pProxy(): Proxy? = i2pHttpClient.value.proxy + override fun getCurrentProxyPort(useProxy: Boolean): Int? = if (useProxy) { (getCurrentProxy()?.address() as? InetSocketAddress)?.port @@ -73,4 +87,20 @@ class DualHttpClientManagerForRelays( } else { defaultHttpClientWithoutProxy.value } + + fun getHttpClient(route: PrivacyRoute): OkHttpClient = + when (route) { + PrivacyRoute.Direct -> defaultHttpClientWithoutProxy.value + PrivacyRoute.Tor -> defaultHttpClient.value + PrivacyRoute.I2p -> i2pHttpClient.value + is PrivacyRoute.Blocked -> throw DualHttpClientManager.blockedException(route.reason) + } + + fun getCurrentProxyPort(route: PrivacyRoute): Int? = + when (route) { + PrivacyRoute.Direct -> null + PrivacyRoute.Tor -> (getCurrentProxy()?.address() as? InetSocketAddress)?.port + PrivacyRoute.I2p -> (getCurrentI2pProxy()?.address() as? InetSocketAddress)?.port + is PrivacyRoute.Blocked -> throw DualHttpClientManager.blockedException(route.reason) + } } 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 0c636f8a9..b73e7afb8 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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pDialogViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pDialogViewModel.kt new file mode 100644 index 000000000..03faa8c4b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pDialogViewModel.kt @@ -0,0 +1,78 @@ +/* + * 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.ui.i2p + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.commons.i2p.I2pSettings +import com.vitorpamplona.amethyst.commons.i2p.I2pType + +@Stable +class I2pDialogViewModel : ViewModel() { + val i2pType = mutableStateOf(I2pType.OFF) + val socksPortStr = mutableStateOf("4447") + + val i2pRelaysViaI2p = mutableStateOf(true) + val dmRelaysViaI2p = mutableStateOf(false) + val newRelaysViaI2p = mutableStateOf(false) + val trustedRelaysViaI2p = mutableStateOf(false) + val urlPreviewsViaI2p = mutableStateOf(false) + val profilePicsViaI2p = mutableStateOf(false) + val imagesViaI2p = mutableStateOf(false) + val videosViaI2p = mutableStateOf(false) + val moneyOperationsViaI2p = mutableStateOf(false) + val nip05VerificationsViaI2p = mutableStateOf(false) + val mediaUploadsViaI2p = mutableStateOf(false) + + fun reset(settings: I2pSettings) { + i2pType.value = settings.i2pType + socksPortStr.value = settings.externalSocksPort.toString() + i2pRelaysViaI2p.value = settings.i2pRelaysViaI2p + dmRelaysViaI2p.value = settings.dmRelaysViaI2p + newRelaysViaI2p.value = settings.newRelaysViaI2p + trustedRelaysViaI2p.value = settings.trustedRelaysViaI2p + urlPreviewsViaI2p.value = settings.urlPreviewsViaI2p + profilePicsViaI2p.value = settings.profilePicsViaI2p + imagesViaI2p.value = settings.imagesViaI2p + videosViaI2p.value = settings.videosViaI2p + moneyOperationsViaI2p.value = settings.moneyOperationsViaI2p + nip05VerificationsViaI2p.value = settings.nip05VerificationsViaI2p + mediaUploadsViaI2p.value = settings.mediaUploadsViaI2p + } + + fun save(): I2pSettings = + I2pSettings( + i2pType = i2pType.value, + externalSocksPort = Integer.parseInt(socksPortStr.value), + i2pRelaysViaI2p = i2pRelaysViaI2p.value, + dmRelaysViaI2p = dmRelaysViaI2p.value, + newRelaysViaI2p = newRelaysViaI2p.value, + trustedRelaysViaI2p = trustedRelaysViaI2p.value, + urlPreviewsViaI2p = urlPreviewsViaI2p.value, + profilePicsViaI2p = profilePicsViaI2p.value, + imagesViaI2p = imagesViaI2p.value, + videosViaI2p = videosViaI2p.value, + moneyOperationsViaI2p = moneyOperationsViaI2p.value, + nip05VerificationsViaI2p = nip05VerificationsViaI2p.value, + mediaUploadsViaI2p = mediaUploadsViaI2p.value, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pManager.kt new file mode 100644 index 000000000..4735d2254 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pManager.kt @@ -0,0 +1,84 @@ +/* + * 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.ui.i2p + +import com.vitorpamplona.amethyst.commons.i2p.I2pServiceStatus +import com.vitorpamplona.amethyst.commons.i2p.I2pType +import com.vitorpamplona.amethyst.model.preferences.I2pSharedPreferences +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +/** + * EXTERNAL-only I2P manager. Mirrors TorManager's status surface but never starts + * a daemon itself: I2P bootstrap on Android is structurally minutes-long (no + * equivalent of Tor's hardcoded directory authorities — the protocol requires + * NetDB peer discovery), so the embedded route would ship a permanently-warming + * experience. Users who want I2P run i2pd / Java I2P independently and point + * Amethyst at its SOCKS port via the Privacy Options screen. + * + * There is intentionally no Connecting state, no bootstrap timeout, and no + * "session bypass" — when EXTERNAL is selected the daemon's lifecycle is the + * user's responsibility and the connection either works or it doesn't. + */ +class I2pManager( + i2pPrefs: I2pSharedPreferences, + scope: CoroutineScope, +) { + val status: StateFlow = + combine( + i2pPrefs.value.i2pType, + i2pPrefs.value.externalSocksPort, + ) { type, port -> + when (type) { + I2pType.OFF -> I2pServiceStatus.Off + I2pType.EXTERNAL -> if (port > 0) I2pServiceStatus.Active(port) else I2pServiceStatus.Off + } + }.catch { e -> + Log.e("I2pManager") { "I2P service error: ${e.message}" } + emit(I2pServiceStatus.Off) + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.WhileSubscribed(30000), + I2pServiceStatus.Off, + ) + + val activePortOrNull: StateFlow = + status + .map { (it as? I2pServiceStatus.Active)?.port } + .stateIn( + scope, + SharingStarted.WhileSubscribed(2000), + (status.value as? I2pServiceStatus.Active)?.port, + ) + + fun isSocksReady() = status.value is I2pServiceStatus.Active + + fun socksPort(): Int? = (status.value as? I2pServiceStatus.Active)?.port +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettings.kt new file mode 100644 index 000000000..7497a8008 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettings.kt @@ -0,0 +1,32 @@ +/* + * 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.ui.i2p + +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.i2p.I2pType + +// Android-specific resource IDs for I2pType. +val I2pType.resourceId: Int + get() = + when (this) { + I2pType.OFF -> R.string.i2p_off + I2pType.EXTERNAL -> R.string.i2p_external + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettingsDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettingsDialog.kt new file mode 100644 index 000000000..e608e1be6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettingsDialog.kt @@ -0,0 +1,182 @@ +/* + * 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.ui.i2p + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.i2p.I2pType +import com.vitorpamplona.amethyst.commons.i2p.parseI2pType +import com.vitorpamplona.amethyst.ui.components.TitleExplainer +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.amethyst.ui.tor.SwitchSettingsRow +import kotlinx.collections.immutable.persistentListOf + +// Settings body for I2P. Mirrors PrivacySettingsBody (Tor) but without presets — +// no canonical "default I2P configuration" exists, and the per-feature toggles +// only take effect when I2P is also the preferred clearnet transport (see the +// global picker above this section in PrivacyOptionsScreen). +// +// I2P is EXTERNAL-only: connects to a user-run i2pd / Java I2P installation on +// the device (default SOCKS port 4447). We intentionally do not embed a router — +// see commons I2pType for the rationale. +@Composable +fun I2pSettingsBody(dialogViewModel: I2pDialogViewModel) { + Column( + modifier = Modifier.padding(horizontal = 5.dp), + verticalArrangement = Arrangement.spacedBy(Size10dp), + ) { + SettingsRow( + R.string.use_i2p, + R.string.use_i2p_explainer, + persistentListOf( + TitleExplainer(stringRes(I2pType.OFF.resourceId)), + TitleExplainer(stringRes(I2pType.EXTERNAL.resourceId)), + ), + when (dialogViewModel.i2pType.value) { + I2pType.OFF -> 0 + I2pType.EXTERNAL -> 1 + }, + ) { idx -> + dialogViewModel.i2pType.value = + when (idx) { + 1 -> parseI2pType(I2pType.EXTERNAL.screenCode) + else -> parseI2pType(I2pType.OFF.screenCode) + } + } + + AnimatedVisibility( + visible = dialogViewModel.i2pType.value == I2pType.EXTERNAL, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + SettingsRow( + R.string.i2p_socks_port, + R.string.i2p_socks_port_explainer, + ) { + OutlinedTextField( + value = dialogViewModel.socksPortStr.value, + onValueChange = { dialogViewModel.socksPortStr.value = it }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Number, + ), + placeholder = { + Text( + text = "4447", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + ) + } + } + } + + AnimatedVisibility( + visible = dialogViewModel.i2pType.value != I2pType.OFF, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Column( + modifier = Modifier.padding(horizontal = 5.dp), + verticalArrangement = Arrangement.spacedBy(Size10dp), + ) { + SwitchSettingsRow( + R.string.i2p_use_i2p_address, + R.string.i2p_use_i2p_address_explainer, + dialogViewModel.i2pRelaysViaI2p, + ) + + SwitchSettingsRow( + R.string.i2p_use_dm_relays, + R.string.i2p_use_dm_relays_explainer, + dialogViewModel.dmRelaysViaI2p, + ) + + SwitchSettingsRow( + R.string.i2p_use_new_relays, + R.string.i2p_use_new_relays_explainer, + dialogViewModel.newRelaysViaI2p, + ) + + SwitchSettingsRow( + R.string.i2p_use_trusted_relays, + R.string.i2p_use_trusted_relays_explainer, + dialogViewModel.trustedRelaysViaI2p, + ) + + SwitchSettingsRow( + R.string.i2p_use_money_operations, + R.string.i2p_use_money_operations_explainer, + dialogViewModel.moneyOperationsViaI2p, + ) + + SwitchSettingsRow( + R.string.i2p_use_nip05_verification, + R.string.i2p_use_nip05_verification_explainer, + dialogViewModel.nip05VerificationsViaI2p, + ) + + SwitchSettingsRow( + R.string.i2p_use_url_previews, + R.string.i2p_use_url_previews_explainer, + dialogViewModel.urlPreviewsViaI2p, + ) + + SwitchSettingsRow( + R.string.i2p_use_images, + R.string.i2p_use_images_explainer, + dialogViewModel.imagesViaI2p, + ) + + SwitchSettingsRow( + R.string.i2p_use_videos, + R.string.i2p_use_videos_explainer, + dialogViewModel.videosViaI2p, + ) + + SwitchSettingsRow( + R.string.i2p_use_media_uploads, + R.string.i2p_use_media_uploads_explainer, + dialogViewModel.mediaUploadsViaI2p, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettingsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettingsFlow.kt new file mode 100644 index 000000000..6ad085fb7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettingsFlow.kt @@ -0,0 +1,174 @@ +/* + * 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.ui.i2p + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.i2p.I2pSettings +import com.vitorpamplona.amethyst.commons.i2p.I2pType +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine + +@Stable +class I2pSettingsFlow( + val i2pType: MutableStateFlow = MutableStateFlow(I2pType.OFF), + val externalSocksPort: MutableStateFlow = MutableStateFlow(4447), + val i2pRelaysViaI2p: MutableStateFlow = MutableStateFlow(true), + val dmRelaysViaI2p: MutableStateFlow = MutableStateFlow(false), + val newRelaysViaI2p: MutableStateFlow = MutableStateFlow(false), + val trustedRelaysViaI2p: MutableStateFlow = MutableStateFlow(false), + val urlPreviewsViaI2p: MutableStateFlow = MutableStateFlow(false), + val profilePicsViaI2p: MutableStateFlow = MutableStateFlow(false), + val imagesViaI2p: MutableStateFlow = MutableStateFlow(false), + val videosViaI2p: MutableStateFlow = MutableStateFlow(false), + val moneyOperationsViaI2p: MutableStateFlow = MutableStateFlow(false), + val nip05VerificationsViaI2p: MutableStateFlow = MutableStateFlow(false), + val mediaUploadsViaI2p: MutableStateFlow = MutableStateFlow(false), +) { + val propertyWatchFlow = + combine( + listOf( + i2pType, + externalSocksPort, + i2pRelaysViaI2p, + dmRelaysViaI2p, + newRelaysViaI2p, + trustedRelaysViaI2p, + urlPreviewsViaI2p, + profilePicsViaI2p, + imagesViaI2p, + videosViaI2p, + moneyOperationsViaI2p, + nip05VerificationsViaI2p, + mediaUploadsViaI2p, + ), + ) { flows -> + I2pSettings( + flows[0] as I2pType, + flows[1] as Int, + flows[2] as Boolean, + flows[3] as Boolean, + flows[4] as Boolean, + flows[5] as Boolean, + flows[6] as Boolean, + flows[7] as Boolean, + flows[8] as Boolean, + flows[9] as Boolean, + flows[10] as Boolean, + flows[11] as Boolean, + flows[12] as Boolean, + ) + } + + fun toSettings(): I2pSettings = + I2pSettings( + i2pType.value, + externalSocksPort.value, + i2pRelaysViaI2p.value, + dmRelaysViaI2p.value, + newRelaysViaI2p.value, + trustedRelaysViaI2p.value, + urlPreviewsViaI2p.value, + profilePicsViaI2p.value, + imagesViaI2p.value, + videosViaI2p.value, + moneyOperationsViaI2p.value, + nip05VerificationsViaI2p.value, + mediaUploadsViaI2p.value, + ) + + fun update(settings: I2pSettings): Boolean { + var any = false + + if (i2pType.value != settings.i2pType) { + i2pType.tryEmit(settings.i2pType) + any = true + } + if (externalSocksPort.value != settings.externalSocksPort) { + externalSocksPort.tryEmit(settings.externalSocksPort) + any = true + } + if (i2pRelaysViaI2p.value != settings.i2pRelaysViaI2p) { + i2pRelaysViaI2p.tryEmit(settings.i2pRelaysViaI2p) + any = true + } + if (dmRelaysViaI2p.value != settings.dmRelaysViaI2p) { + dmRelaysViaI2p.tryEmit(settings.dmRelaysViaI2p) + any = true + } + if (newRelaysViaI2p.value != settings.newRelaysViaI2p) { + newRelaysViaI2p.tryEmit(settings.newRelaysViaI2p) + any = true + } + if (trustedRelaysViaI2p.value != settings.trustedRelaysViaI2p) { + trustedRelaysViaI2p.tryEmit(settings.trustedRelaysViaI2p) + any = true + } + if (urlPreviewsViaI2p.value != settings.urlPreviewsViaI2p) { + urlPreviewsViaI2p.tryEmit(settings.urlPreviewsViaI2p) + any = true + } + if (profilePicsViaI2p.value != settings.profilePicsViaI2p) { + profilePicsViaI2p.tryEmit(settings.profilePicsViaI2p) + any = true + } + if (imagesViaI2p.value != settings.imagesViaI2p) { + imagesViaI2p.tryEmit(settings.imagesViaI2p) + any = true + } + if (videosViaI2p.value != settings.videosViaI2p) { + videosViaI2p.tryEmit(settings.videosViaI2p) + any = true + } + if (moneyOperationsViaI2p.value != settings.moneyOperationsViaI2p) { + moneyOperationsViaI2p.tryEmit(settings.moneyOperationsViaI2p) + any = true + } + if (nip05VerificationsViaI2p.value != settings.nip05VerificationsViaI2p) { + nip05VerificationsViaI2p.tryEmit(settings.nip05VerificationsViaI2p) + any = true + } + if (mediaUploadsViaI2p.value != settings.mediaUploadsViaI2p) { + mediaUploadsViaI2p.tryEmit(settings.mediaUploadsViaI2p) + any = true + } + + return any + } + + companion object { + fun build(settings: I2pSettings): I2pSettingsFlow = + I2pSettingsFlow( + MutableStateFlow(settings.i2pType), + MutableStateFlow(settings.externalSocksPort), + MutableStateFlow(settings.i2pRelaysViaI2p), + MutableStateFlow(settings.dmRelaysViaI2p), + MutableStateFlow(settings.newRelaysViaI2p), + MutableStateFlow(settings.trustedRelaysViaI2p), + MutableStateFlow(settings.urlPreviewsViaI2p), + MutableStateFlow(settings.profilePicsViaI2p), + MutableStateFlow(settings.imagesViaI2p), + MutableStateFlow(settings.videosViaI2p), + MutableStateFlow(settings.moneyOperationsViaI2p), + MutableStateFlow(settings.nip05VerificationsViaI2p), + MutableStateFlow(settings.mediaUploadsViaI2p), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/privacy/PrivacyTransportPicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/privacy/PrivacyTransportPicker.kt new file mode 100644 index 000000000..fc8ea236f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/privacy/PrivacyTransportPicker.kt @@ -0,0 +1,82 @@ +/* + * 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.ui.privacy + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.privacy.PrivacyTransport +import com.vitorpamplona.amethyst.ui.components.TitleExplainer +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import kotlinx.collections.immutable.persistentListOf + +@Stable +class PrivacyTransportPickerViewModel : ViewModel() { + val preferred = mutableStateOf(PrivacyTransport.DIRECT) + + fun reset(transport: PrivacyTransport) { + preferred.value = transport + } + + fun save(): PrivacyTransport = preferred.value +} + +// Single picker for "which transport carries clearnet traffic". Hidden services +// (.onion / .i2p) are NOT affected by this choice — they always route through +// their matching daemon and fail closed when it's off. See PrivacyRouter. +@Composable +fun PreferredTransportPicker(viewModel: PrivacyTransportPickerViewModel) { + Column( + modifier = Modifier.padding(horizontal = 5.dp), + verticalArrangement = Arrangement.spacedBy(Size10dp), + ) { + SettingsRow( + R.string.privacy_clearnet_transport, + R.string.privacy_clearnet_transport_explainer, + persistentListOf( + TitleExplainer(stringRes(R.string.privacy_clearnet_transport_direct)), + TitleExplainer(stringRes(R.string.privacy_clearnet_transport_tor)), + TitleExplainer(stringRes(R.string.privacy_clearnet_transport_i2p)), + ), + when (viewModel.preferred.value) { + PrivacyTransport.DIRECT -> 0 + PrivacyTransport.TOR -> 1 + PrivacyTransport.I2P -> 2 + }, + ) { idx -> + viewModel.preferred.value = + when (idx) { + 1 -> PrivacyTransport.TOR + 2 -> PrivacyTransport.I2P + else -> PrivacyTransport.DIRECT + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt index 306447fc5..f9ab0e5a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt @@ -20,12 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -34,9 +36,16 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.i2p.I2pSettings +import com.vitorpamplona.amethyst.commons.privacy.PrivacyTransport import com.vitorpamplona.amethyst.commons.tor.TorSettings +import com.vitorpamplona.amethyst.ui.i2p.I2pDialogViewModel +import com.vitorpamplona.amethyst.ui.i2p.I2pSettingsBody +import com.vitorpamplona.amethyst.ui.i2p.I2pSettingsFlow import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar +import com.vitorpamplona.amethyst.ui.privacy.PreferredTransportPicker +import com.vitorpamplona.amethyst.ui.privacy.PrivacyTransportPickerViewModel import com.vitorpamplona.amethyst.ui.tor.PrivacySettingsBody import com.vitorpamplona.amethyst.ui.tor.TorDialogViewModel import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow @@ -44,46 +53,69 @@ import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow @OptIn(ExperimentalMaterial3Api::class) @Composable fun PrivacyOptionsScreen(nav: INav) { - PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, nav) + val app = Amethyst.instance + PrivacyOptionsScreen( + torSettingsFlow = app.torPrefs.value, + i2pSettingsFlow = app.i2pPrefs.value, + preferredTransport = app.privacyPrefs.preferredClearnetTransport.value, + onSavePreferred = { app.privacyPrefs.preferredClearnetTransport.value = it }, + nav = nav, + ) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun PrivacyOptionsScreen( torSettingsFlow: TorSettingsFlow, + i2pSettingsFlow: I2pSettingsFlow, + preferredTransport: PrivacyTransport, + onSavePreferred: (PrivacyTransport) -> Unit, nav: INav, ) { - val dialogViewModel = viewModel() + val torVm = viewModel() + val i2pVm = viewModel() + val pickerVm = viewModel() - // runs only once and before the rest of the screen is build - // to avoid blinking and animations from the default/previous - // state to the current state - val init = - remember(dialogViewModel) { - val torSettings = torSettingsFlow.toSettings() - dialogViewModel.reset(torSettings) - torSettings - } + // Reset all three view models from disk-loaded state exactly once, + // mirroring how the old screen reset only the Tor VM. + remember(torVm, i2pVm, pickerVm) { + torVm.reset(torSettingsFlow.toSettings()) + i2pVm.reset(i2pSettingsFlow.toSettings()) + pickerVm.reset(preferredTransport) + Unit + } - PrivacyOptionsScreenContents(dialogViewModel, onPost = torSettingsFlow::update, nav) + PrivacyOptionsScreenContents( + torVm = torVm, + i2pVm = i2pVm, + pickerVm = pickerVm, + onPostTor = torSettingsFlow::update, + onPostI2p = i2pSettingsFlow::update, + onPostPreferred = onSavePreferred, + nav = nav, + ) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun PrivacyOptionsScreenContents( - dialogViewModel: TorDialogViewModel, - onPost: (TorSettings) -> Unit, + torVm: TorDialogViewModel, + i2pVm: I2pDialogViewModel, + pickerVm: PrivacyTransportPickerViewModel, + onPostTor: (TorSettings) -> Unit, + onPostI2p: (I2pSettings) -> Unit, + onPostPreferred: (PrivacyTransport) -> Unit, nav: INav, ) { Scaffold( topBar = { SavingTopBar( titleRes = R.string.privacy_options, - onCancel = { - nav.popBack() - }, + onCancel = { nav.popBack() }, onPost = { - onPost(dialogViewModel.save()) + onPostTor(torVm.save()) + onPostI2p(i2pVm.save()) + onPostPreferred(pickerVm.save()) nav.popBack() }, ) @@ -93,11 +125,19 @@ fun PrivacyOptionsScreenContents( Modifier .padding(it) .fillMaxSize() - .verticalScroll( - rememberScrollState(), - ).padding(horizontal = 10.dp), + .verticalScroll(rememberScrollState()) + .padding(horizontal = 10.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - PrivacySettingsBody(dialogViewModel) + PreferredTransportPicker(pickerVm) + + HorizontalDivider() + + PrivacySettingsBody(torVm) + + HorizontalDivider() + + I2pSettingsBody(i2pVm) } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 9f310a9be..d5a6d8ca5 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1101,6 +1101,54 @@ Use Orbot Disconnect Tor/Orbot + + Active I2P Engine + Use an external I2P router (i2pd / Java I2P) + + Off + External Router + + I2P Socks Port + SOCKS port your local I2P router exposes (i2pd default: 4447) + + .i2p Url/Relays + Use I2P for any .i2p url + + DM Relays + Force I2P to send and receive DMs + + Untrusted Relays + Force I2P on outbox/inbox relays + + Trusted Relays + Force I2P on all relays in your lists + + URL Previews + Force I2P when loading url previews + + Images + Force I2P when loading images + + Videos + Force I2P when loading videos + + Money Operations + Force I2P for zaps and payments + + NIP-05 Verification + Force I2P when verifying NIP-05 identifiers + + Media Uploads + Force I2P when uploading media + + + Privacy Network + Routes regular web traffic through this network. Hidden service URLs (.onion / .i2p) always use their matching network. + Direct (no privacy) + Tor + I2P + DefaultChannelID New notification arrived diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlockedRouteExceptionTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlockedRouteExceptionTest.kt new file mode 100644 index 000000000..60ea602b4 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlockedRouteExceptionTest.kt @@ -0,0 +1,52 @@ +/* + * 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.service.okhttp + +import com.vitorpamplona.amethyst.commons.privacy.BlockReason +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class BlockedRouteExceptionTest { + @Test + fun onionMessage_mentionsTorAndOnion() { + val e = BlockedRouteException(BlockReason.ONION_REQUIRES_TOR) + assertSame(BlockReason.ONION_REQUIRES_TOR, e.reason) + assertTrue("message should mention Tor: ${e.message}", e.message!!.contains("Tor", ignoreCase = true)) + assertTrue("message should mention .onion: ${e.message}", e.message!!.contains(".onion")) + } + + @Test + fun i2pMessage_mentionsI2pAndDotI2p() { + val e = BlockedRouteException(BlockReason.I2P_REQUIRES_I2P) + assertSame(BlockReason.I2P_REQUIRES_I2P, e.reason) + assertTrue("message should mention I2P: ${e.message}", e.message!!.contains("I2P")) + assertTrue("message should mention .i2p: ${e.message}", e.message!!.contains(".i2p")) + } + + @Test + fun dualHttpClientManagerCompanion_returnsBlockedRouteException_withReason() { + // Pins the factory return type so callers can pattern-match without losing the reason. + val e: BlockedRouteException = DualHttpClientManager.blockedException(BlockReason.ONION_REQUIRES_TOR) + assertEquals(BlockReason.ONION_REQUIRES_TOR, e.reason) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pRelayEvaluation.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pRelayEvaluation.kt new file mode 100644 index 000000000..39ce3460c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pRelayEvaluation.kt @@ -0,0 +1,48 @@ +/* + * 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.commons.i2p + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isI2p +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost + +class I2pRelayEvaluation( + val i2pSettings: I2pRelaySettings, + val trustedRelayList: Set, + val dmRelayList: Set, +) { + fun useI2p(relay: NormalizedRelayUrl): Boolean = + if (i2pSettings.i2pType == I2pType.OFF) { + false + } else { + if (relay.isLocalHost()) { + false + } else if (relay.isI2p()) { + i2pSettings.i2pRelaysViaI2p + } else if (relay in dmRelayList) { + i2pSettings.dmRelaysViaI2p + } else if (relay in trustedRelayList) { + i2pSettings.trustedRelaysViaI2p + } else { + i2pSettings.newRelaysViaI2p + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pRelaySettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pRelaySettings.kt new file mode 100644 index 000000000..ee5496599 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pRelaySettings.kt @@ -0,0 +1,29 @@ +/* + * 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.commons.i2p + +data class I2pRelaySettings( + val i2pType: I2pType = I2pType.OFF, + val i2pRelaysViaI2p: Boolean = true, + val dmRelaysViaI2p: Boolean = false, + val newRelaysViaI2p: Boolean = false, + val trustedRelaysViaI2p: Boolean = false, +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pServiceStatus.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pServiceStatus.kt new file mode 100644 index 000000000..8422d77a0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pServiceStatus.kt @@ -0,0 +1,35 @@ +/* + * 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.commons.i2p + +sealed class I2pServiceStatus { + data class Active( + val port: Int, + ) : I2pServiceStatus() + + data object Off : I2pServiceStatus() + + data object Connecting : I2pServiceStatus() + + data class Error( + val message: String, + ) : I2pServiceStatus() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pSettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pSettings.kt new file mode 100644 index 000000000..cd27ba36d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pSettings.kt @@ -0,0 +1,60 @@ +/* + * 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.commons.i2p + +// Shape mirrors TorSettings. Both daemons can be enabled side-by-side (needed so +// .onion and .i2p hidden services stay reachable independently), but only one +// transport carries clearnet at a time — see PrivacySettings.preferredClearnetTransport. +// The per-feature booleans here only take effect when I2P is the preferred clearnet +// transport; otherwise the matching TorSettings flag wins. +data class I2pSettings( + val i2pType: I2pType = I2pType.OFF, + val externalSocksPort: Int = 4447, + val i2pRelaysViaI2p: Boolean = true, + val dmRelaysViaI2p: Boolean = false, + val newRelaysViaI2p: Boolean = false, + val trustedRelaysViaI2p: Boolean = false, + val urlPreviewsViaI2p: Boolean = false, + val profilePicsViaI2p: Boolean = false, + val imagesViaI2p: Boolean = false, + val videosViaI2p: Boolean = false, + val moneyOperationsViaI2p: Boolean = false, + val nip05VerificationsViaI2p: Boolean = false, + val mediaUploadsViaI2p: Boolean = false, +) + +// EXTERNAL-only: this app does not embed an I2P router. Bootstrap on Android is +// minutes-long for a fresh netDb and the protocol provides no shortcut analogous +// to Tor's hardcoded directory authorities; we'd ship a "permanently warming up" +// experience. Users who want I2P run i2pd or Java I2P independently and point +// Amethyst at its SOCKS port (default 4447). See I2pManager. +enum class I2pType( + val screenCode: Int, +) { + OFF(0), + EXTERNAL(2), +} + +fun parseI2pType(code: Int?): I2pType = + when (code) { + I2pType.EXTERNAL.screenCode -> I2pType.EXTERNAL + else -> I2pType.OFF + } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/II2pManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/II2pManager.kt new file mode 100644 index 000000000..c976c0a87 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/II2pManager.kt @@ -0,0 +1,36 @@ +/* + * 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.commons.i2p + +import kotlinx.coroutines.flow.StateFlow + +// Platform-agnostic interface for the I2P daemon lifecycle. +// Android internal mode bundles net.i2p:router; external mode just opens a SOCKS +// proxy to a user-run daemon. activePortOrNull surfaces whichever local SOCKS +// port the HTTP manager should route I2P traffic through. +interface II2pManager { + val status: StateFlow + val activePortOrNull: StateFlow + + suspend fun dormant() + + suspend fun active() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/II2pSettingsPersistence.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/II2pSettingsPersistence.kt new file mode 100644 index 000000000..db9c11aff --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/II2pSettingsPersistence.kt @@ -0,0 +1,27 @@ +/* + * 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.commons.i2p + +interface II2pSettingsPersistence { + fun load(): I2pSettings + + fun save(settings: I2pSettings) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/FeatureRole.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/FeatureRole.kt new file mode 100644 index 000000000..3ed68518c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/FeatureRole.kt @@ -0,0 +1,35 @@ +/* + * 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.commons.privacy + +// What the network call is for. PrivacyRouter uses this to look up the matching +// per-feature toggle on whichever transport is preferred for clearnet +// (TorSettings.imagesViaTor vs I2pSettings.imagesViaI2p, etc.). Hidden-service +// hostnames ignore the role and hard-pin to their matching network. +enum class FeatureRole { + IMAGE, + VIDEO, + URL_PREVIEW, + PROFILE_PIC, + NIP05, + MONEY, + UPLOAD, +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRelayEvaluation.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRelayEvaluation.kt new file mode 100644 index 000000000..6a942b8e7 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRelayEvaluation.kt @@ -0,0 +1,46 @@ +/* + * 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.commons.privacy + +import com.vitorpamplona.amethyst.commons.i2p.I2pRelayEvaluation +import com.vitorpamplona.amethyst.commons.tor.TorRelayEvaluation +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isI2p +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion + +// Per-relay transport pick. Onion always wins for Tor, I2P always wins for I2P +// (matching PrivacyRouter's hostname pin). For non-hidden hosts, prefer I2P if +// both transports want the relay — arbitrary tiebreak documented here so the +// behavior is testable. +class PrivacyRelayEvaluation( + private val tor: TorRelayEvaluation, + private val i2p: I2pRelayEvaluation, +) { + fun transport(relay: NormalizedRelayUrl): PrivacyTransport { + if (relay.isOnion()) return if (tor.useTor(relay)) PrivacyTransport.TOR else PrivacyTransport.DIRECT + if (relay.isI2p()) return if (i2p.useI2p(relay)) PrivacyTransport.I2P else PrivacyTransport.DIRECT + return when { + i2p.useI2p(relay) -> PrivacyTransport.I2P + tor.useTor(relay) -> PrivacyTransport.TOR + else -> PrivacyTransport.DIRECT + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRoute.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRoute.kt new file mode 100644 index 000000000..88b180cd9 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRoute.kt @@ -0,0 +1,43 @@ +/* + * 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.commons.privacy + +// Outcome of PrivacyRouter.route. Either an actionable transport, or Blocked +// when a hidden-service hostname cannot be reached (matching daemon is OFF) +// and we fail closed rather than leak the request over the clearnet. +sealed interface PrivacyRoute { + data object Direct : PrivacyRoute + + data object Tor : PrivacyRoute + + data object I2p : PrivacyRoute + + // Hidden-service request whose matching daemon is disabled. Callers must + // surface this as a visible failure — don't fall back to DIRECT. + data class Blocked( + val reason: BlockReason, + ) : PrivacyRoute +} + +enum class BlockReason { + ONION_REQUIRES_TOR, + I2P_REQUIRES_I2P, +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouter.kt new file mode 100644 index 000000000..504c83c49 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouter.kt @@ -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.commons.privacy + +import com.vitorpamplona.amethyst.commons.i2p.I2pSettings +import com.vitorpamplona.amethyst.commons.tor.TorSettings +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.HiddenServiceKind +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +// Decides how a request is routed. +// +// Hidden-service hostnames hard-pin to their matching network — .onion needs +// Tor, .i2p needs I2P — and fail closed (Blocked) when that daemon is OFF +// rather than leak the request over the clearnet. +// +// For clearnet hostnames there is exactly one active privacy transport at a +// time (PrivacySettings.preferredClearnetTransport). Per-feature toggles on +// that transport's own settings (TorSettings.*ViaTor or I2pSettings.*ViaI2p) +// decide whether this particular request is routed through it; otherwise the +// request goes Direct. +object PrivacyRouter { + fun route( + url: String, + role: FeatureRole, + settings: PrivacySettings, + ): PrivacyRoute = + when (RelayUrlNormalizer.classifyHidden(url)) { + HiddenServiceKind.LOCALHOST -> PrivacyRoute.Direct + HiddenServiceKind.ONION -> + if (settings.torAvailable) PrivacyRoute.Tor else PrivacyRoute.Blocked(BlockReason.ONION_REQUIRES_TOR) + HiddenServiceKind.I2P -> + if (settings.i2pAvailable) PrivacyRoute.I2p else PrivacyRoute.Blocked(BlockReason.I2P_REQUIRES_I2P) + HiddenServiceKind.CLEARNET -> clearnet(role, settings) + } + + private fun clearnet( + role: FeatureRole, + settings: PrivacySettings, + ): PrivacyRoute = + when (settings.preferredClearnetTransport) { + PrivacyTransport.TOR -> + if (settings.torAvailable && torEnables(role, settings.tor)) PrivacyRoute.Tor else PrivacyRoute.Direct + PrivacyTransport.I2P -> + if (settings.i2pAvailable && i2pEnables(role, settings.i2p)) PrivacyRoute.I2p else PrivacyRoute.Direct + PrivacyTransport.DIRECT -> PrivacyRoute.Direct + } + + private fun torEnables( + role: FeatureRole, + tor: TorSettings, + ): Boolean = + when (role) { + FeatureRole.IMAGE -> tor.imagesViaTor + FeatureRole.VIDEO -> tor.videosViaTor + FeatureRole.URL_PREVIEW -> tor.urlPreviewsViaTor + FeatureRole.PROFILE_PIC -> tor.profilePicsViaTor + FeatureRole.NIP05 -> tor.nip05VerificationsViaTor + FeatureRole.MONEY -> tor.moneyOperationsViaTor + FeatureRole.UPLOAD -> tor.mediaUploadsViaTor + } + + private fun i2pEnables( + role: FeatureRole, + i2p: I2pSettings, + ): Boolean = + when (role) { + FeatureRole.IMAGE -> i2p.imagesViaI2p + FeatureRole.VIDEO -> i2p.videosViaI2p + FeatureRole.URL_PREVIEW -> i2p.urlPreviewsViaI2p + FeatureRole.PROFILE_PIC -> i2p.profilePicsViaI2p + FeatureRole.NIP05 -> i2p.nip05VerificationsViaI2p + FeatureRole.MONEY -> i2p.moneyOperationsViaI2p + FeatureRole.UPLOAD -> i2p.mediaUploadsViaI2p + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacySettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacySettings.kt new file mode 100644 index 000000000..88e35ab50 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacySettings.kt @@ -0,0 +1,40 @@ +/* + * 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.commons.privacy + +import com.vitorpamplona.amethyst.commons.i2p.I2pSettings +import com.vitorpamplona.amethyst.commons.i2p.I2pType +import com.vitorpamplona.amethyst.commons.tor.TorSettings +import com.vitorpamplona.amethyst.commons.tor.TorType + +// Aggregate of all privacy configuration. Both Tor and I2P daemons can run +// side-by-side so hidden services on each network stay reachable, but only one +// transport carries clearnet at a time: preferredClearnetTransport picks it. +// The per-feature booleans on each TorSettings / I2pSettings only take effect +// for the preferred transport. +data class PrivacySettings( + val tor: TorSettings = TorSettings(), + val i2p: I2pSettings = I2pSettings(), + val preferredClearnetTransport: PrivacyTransport = PrivacyTransport.DIRECT, +) { + val torAvailable: Boolean get() = tor.torType != TorType.OFF + val i2pAvailable: Boolean get() = i2p.i2pType != I2pType.OFF +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyTransport.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyTransport.kt new file mode 100644 index 000000000..c141029b3 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyTransport.kt @@ -0,0 +1,27 @@ +/* + * 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.commons.privacy + +enum class PrivacyTransport { + DIRECT, + TOR, + I2P, +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouterTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouterTest.kt new file mode 100644 index 000000000..5a7522962 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouterTest.kt @@ -0,0 +1,190 @@ +/* + * 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.commons.privacy + +import com.vitorpamplona.amethyst.commons.i2p.I2pSettings +import com.vitorpamplona.amethyst.commons.i2p.I2pType +import com.vitorpamplona.amethyst.commons.tor.TorSettings +import com.vitorpamplona.amethyst.commons.tor.TorType +import kotlin.test.Test +import kotlin.test.assertEquals + +class PrivacyRouterTest { + private val clearnet = "wss://relay.damus.io/" + private val onion = "wss://abc123.onion/" + private val i2p = "wss://abc.i2p/" + private val localhost = "ws://127.0.0.1:8080/" + + private fun settings( + torOn: Boolean = false, + i2pOn: Boolean = false, + preferred: PrivacyTransport = PrivacyTransport.DIRECT, + tor: TorSettings = TorSettings(torType = if (torOn) TorType.INTERNAL else TorType.OFF), + i2p: I2pSettings = I2pSettings(i2pType = if (i2pOn) I2pType.EXTERNAL else I2pType.OFF), + ) = PrivacySettings( + tor = tor, + i2p = i2p, + preferredClearnetTransport = preferred, + ) + + @Test + fun localhost_alwaysDirect() { + assertEquals( + PrivacyRoute.Direct, + PrivacyRouter.route(localhost, FeatureRole.IMAGE, settings(torOn = true, i2pOn = true, preferred = PrivacyTransport.TOR)), + ) + } + + @Test + fun onion_pinsToTor_whenAvailable() { + assertEquals(PrivacyRoute.Tor, PrivacyRouter.route(onion, FeatureRole.IMAGE, settings(torOn = true))) + } + + @Test + fun onion_blocksWhenTorOff() { + assertEquals( + PrivacyRoute.Blocked(BlockReason.ONION_REQUIRES_TOR), + PrivacyRouter.route(onion, FeatureRole.IMAGE, settings(torOn = false, i2pOn = true, preferred = PrivacyTransport.I2P)), + ) + } + + @Test + fun i2p_pinsToI2p_whenAvailable() { + assertEquals(PrivacyRoute.I2p, PrivacyRouter.route(i2p, FeatureRole.IMAGE, settings(i2pOn = true))) + } + + @Test + fun i2p_blocksWhenI2pOff() { + assertEquals( + PrivacyRoute.Blocked(BlockReason.I2P_REQUIRES_I2P), + PrivacyRouter.route(i2p, FeatureRole.IMAGE, settings(torOn = true, i2pOn = false, preferred = PrivacyTransport.TOR)), + ) + } + + @Test + fun b32_i2p_address_pinsToI2p() { + val b32 = "wss://abcdefghijklmnopqrstuvwxyz234567.b32.i2p/" + assertEquals(PrivacyRoute.I2p, PrivacyRouter.route(b32, FeatureRole.IMAGE, settings(i2pOn = true))) + } + + @Test + fun clearnet_directWhenNoPreferred() { + assertEquals( + PrivacyRoute.Direct, + PrivacyRouter.route( + clearnet, + FeatureRole.IMAGE, + settings(torOn = true, i2pOn = true, preferred = PrivacyTransport.DIRECT, tor = TorSettings(torType = TorType.INTERNAL, imagesViaTor = true)), + ), + ) + } + + @Test + fun clearnet_torRoutes_whenPreferredAndFeatureOn() { + val s = + settings( + torOn = true, + preferred = PrivacyTransport.TOR, + tor = TorSettings(torType = TorType.INTERNAL, imagesViaTor = true), + ) + assertEquals(PrivacyRoute.Tor, PrivacyRouter.route(clearnet, FeatureRole.IMAGE, s)) + } + + @Test + fun clearnet_torDirect_whenPreferredButFeatureOff() { + val s = + settings( + torOn = true, + preferred = PrivacyTransport.TOR, + tor = TorSettings(torType = TorType.INTERNAL, imagesViaTor = false), + ) + assertEquals(PrivacyRoute.Direct, PrivacyRouter.route(clearnet, FeatureRole.IMAGE, s)) + } + + @Test + fun clearnet_i2pRoutes_whenPreferredAndFeatureOn() { + val s = + settings( + i2pOn = true, + preferred = PrivacyTransport.I2P, + i2p = I2pSettings(i2pType = I2pType.EXTERNAL, videosViaI2p = true), + ) + assertEquals(PrivacyRoute.I2p, PrivacyRouter.route(clearnet, FeatureRole.VIDEO, s)) + } + + @Test + fun clearnet_i2pDirect_whenPreferredButFeatureOff() { + val s = + settings( + i2pOn = true, + preferred = PrivacyTransport.I2P, + i2p = I2pSettings(i2pType = I2pType.EXTERNAL, videosViaI2p = false), + ) + assertEquals(PrivacyRoute.Direct, PrivacyRouter.route(clearnet, FeatureRole.VIDEO, s)) + } + + @Test + fun clearnet_preferredButDaemonOff_isDirect() { + // User picked TOR but the daemon is off — go Direct instead of erroring. + val s = + settings( + torOn = false, + preferred = PrivacyTransport.TOR, + tor = TorSettings(torType = TorType.OFF, imagesViaTor = true), + ) + assertEquals(PrivacyRoute.Direct, PrivacyRouter.route(clearnet, FeatureRole.IMAGE, s)) + } + + @Test + fun clearnet_i2pTogglesIgnored_whenTorIsPreferred() { + // I2P is the only daemon running but TOR is the preferred clearnet transport, + // so I2P toggles must not take effect — fall through to Direct. + val s = + settings( + torOn = false, + i2pOn = true, + preferred = PrivacyTransport.TOR, + i2p = I2pSettings(i2pType = I2pType.EXTERNAL, imagesViaI2p = true), + ) + assertEquals(PrivacyRoute.Direct, PrivacyRouter.route(clearnet, FeatureRole.IMAGE, s)) + } + + @Test + fun bothDaemonsRunning_clearnetUsesOnlyPreferred() { + // Both daemons up, both have images toggled on, preferred is I2P → I2P wins. + val s = + settings( + torOn = true, + i2pOn = true, + preferred = PrivacyTransport.I2P, + tor = TorSettings(torType = TorType.INTERNAL, imagesViaTor = true), + i2p = I2pSettings(i2pType = I2pType.EXTERNAL, imagesViaI2p = true), + ) + assertEquals(PrivacyRoute.I2p, PrivacyRouter.route(clearnet, FeatureRole.IMAGE, s)) + } + + @Test + fun hiddenServiceIgnoresClearnetPreference() { + // .onion still pins to Tor even when I2P is the preferred clearnet transport. + val s = settings(torOn = true, i2pOn = true, preferred = PrivacyTransport.I2P) + assertEquals(PrivacyRoute.Tor, PrivacyRouter.route(onion, FeatureRole.IMAGE, s)) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/HiddenServiceKind.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/HiddenServiceKind.kt new file mode 100644 index 000000000..aec407c61 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/HiddenServiceKind.kt @@ -0,0 +1,28 @@ +/* + * 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.quartz.nip01Core.relay.normalizer + +enum class HiddenServiceKind { + CLEARNET, + LOCALHOST, + ONION, + I2P, +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt index ac3f69a1f..d35c87b2b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt @@ -46,4 +46,8 @@ fun NormalizedRelayUrl.toHttp() = fun NormalizedRelayUrl.isOnion() = url.contains(".onion/") +fun NormalizedRelayUrl.isI2p() = RelayUrlNormalizer.isI2p(this.url) + fun NormalizedRelayUrl.isLocalHost() = RelayUrlNormalizer.isLocalHost(this.url) + +fun NormalizedRelayUrl.classifyHidden(): HiddenServiceKind = RelayUrlNormalizer.classifyHidden(this.url) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt index ffd1da4d6..d7a11df76 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt @@ -48,6 +48,16 @@ class RelayUrlNormalizer { fun isOnion(url: String) = url.endsWith(".onion") || url.contains(".onion/") + fun isI2p(url: String) = url.endsWith(".i2p") || url.contains(".i2p/") + + fun classifyHidden(url: String): HiddenServiceKind = + when { + isLocalHost(url) -> HiddenServiceKind.LOCALHOST + isOnion(url) -> HiddenServiceKind.ONION + isI2p(url) -> HiddenServiceKind.I2P + else -> HiddenServiceKind.CLEARNET + } + fun isRelaySchemePrefix(url: String) = url.length > 6 && url[0] == 'w' && url[1] == 's' fun isRelaySchemePrefixSecure(url: String) = url[2] == 's' && url[3] == ':' && url[4] == '/' && url[5] == '/' && url[6] != '/' @@ -149,7 +159,7 @@ class RelayUrlNormalizer { return null } - return if (isOnion(trimmed) || isLocalHost(trimmed)) { + return if (isOnion(trimmed) || isI2p(trimmed) || isLocalHost(trimmed)) { "ws://$trimmed" } else { "wss://$trimmed"