From 6a0801427bd36058cd237d9bccb3ee4d76bcfa82 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 23:10:56 +0000 Subject: [PATCH] Revert "Merge pull request #2990 from vitorpamplona/claude/add-i2p-privacy-option-nK2X7" This reverts commit d42482ff56b4f8ea2155f62a517eefc7c09a71d7, reversing changes made to a8b6766f497de09b98029fcb6f93f4cac75a2e21. --- .../com/vitorpamplona/amethyst/AppModules.kt | 85 +------- .../model/preferences/I2pSharedPreferences.kt | 138 ------------- .../preferences/PrivacySharedPreferences.kt | 90 --------- .../privacyOptions/PrivacyRoutingFlow.kt | 51 ----- .../RoleBasedHttpClientBuilder.kt | 153 +++++++++----- .../service/okhttp/BlockedRouteException.kt | 48 ----- .../service/okhttp/DualHttpClientManager.kt | 81 -------- .../okhttp/DualHttpClientManagerForRelays.kt | 30 --- .../playback/service/PlaybackService.kt | 63 +++--- .../amethyst/ui/i2p/I2pDialogViewModel.kt | 78 ------- .../amethyst/ui/i2p/I2pManager.kt | 84 -------- .../amethyst/ui/i2p/I2pSettings.kt | 32 --- .../amethyst/ui/i2p/I2pSettingsDialog.kt | 182 ----------------- .../amethyst/ui/i2p/I2pSettingsFlow.kt | 174 ---------------- .../ui/privacy/PrivacyTransportPicker.kt | 82 -------- .../loggedIn/privacy/PrivacyOptionsScreen.kt | 85 +++----- amethyst/src/main/res/values/strings.xml | 48 ----- .../okhttp/BlockedRouteExceptionTest.kt | 52 ----- .../commons/i2p/I2pRelayEvaluation.kt | 48 ----- .../amethyst/commons/i2p/I2pRelaySettings.kt | 29 --- .../amethyst/commons/i2p/I2pServiceStatus.kt | 35 ---- .../amethyst/commons/i2p/I2pSettings.kt | 60 ------ .../amethyst/commons/i2p/II2pManager.kt | 36 ---- .../commons/i2p/II2pSettingsPersistence.kt | 27 --- .../amethyst/commons/privacy/FeatureRole.kt | 35 ---- .../commons/privacy/PrivacyRelayEvaluation.kt | 46 ----- .../amethyst/commons/privacy/PrivacyRoute.kt | 43 ---- .../amethyst/commons/privacy/PrivacyRouter.kt | 93 --------- .../commons/privacy/PrivacySettings.kt | 40 ---- .../commons/privacy/PrivacyTransport.kt | 27 --- .../commons/privacy/PrivacyRouterTest.kt | 190 ------------------ .../relay/normalizer/HiddenServiceKind.kt | 28 --- .../relay/normalizer/NormalizedRelayUrl.kt | 4 - .../relay/normalizer/RelayUrlNormalizer.kt | 12 +- 34 files changed, 177 insertions(+), 2132 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/I2pSharedPreferences.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/PrivacySharedPreferences.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/PrivacyRoutingFlow.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/BlockedRouteException.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pDialogViewModel.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pManager.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettings.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettingsDialog.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettingsFlow.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/privacy/PrivacyTransportPicker.kt delete mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlockedRouteExceptionTest.kt delete mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pRelayEvaluation.kt delete mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pRelaySettings.kt delete mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pServiceStatus.kt delete mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pSettings.kt delete mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/II2pManager.kt delete mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/II2pSettingsPersistence.kt delete mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/FeatureRole.kt delete mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRelayEvaluation.kt delete mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRoute.kt delete mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouter.kt delete mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacySettings.kt delete mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyTransport.kt delete mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouterTest.kt delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/HiddenServiceKind.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 73d4c6976..976bec0c1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -24,10 +24,7 @@ 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 @@ -39,13 +36,10 @@ 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 @@ -62,7 +56,6 @@ 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 @@ -87,7 +80,6 @@ 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 @@ -100,8 +92,6 @@ 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 @@ -164,18 +154,6 @@ 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") @@ -188,21 +166,6 @@ 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") @@ -229,10 +192,6 @@ 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 @@ -269,7 +228,6 @@ class AppModules( DualHttpClientManager( userAgent = appAgent, proxyPortProvider = torManager.activePortOrNull, - i2pProxyPortProvider = i2pManager.activePortOrNull, isMobileDataProvider = connManager.isMobileOrNull, keyCache = keyCache, scope = applicationIOScope, @@ -285,15 +243,8 @@ class AppModules( }, ) - // 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) + // Offers easy methods to know when connections are happening through Tor or not + val roleBasedHttpClientBuilder = RoleBasedHttpClientBuilder(okHttpClients, torPrefs.value) val electrumXClient by lazy { Log.d("AppModules", "ElectrumXClient Init") @@ -369,40 +320,16 @@ class AppModules( DualHttpClientManagerForRelays( userAgent = appAgent, proxyPortProvider = torManager.activePortOrNull, - i2pProxyPortProvider = i2pManager.activePortOrNull, isMobileDataProvider = connManager.isMobileOrNull, scope = applicationIOScope, dns = surgeDns, ) - // 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). + // Connects the INostrClient class with okHttp val websocketBuilder = OkHttpWebSocket.Builder { url -> - 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) - } - } + val useTor = torEvaluatorFlow.flow.value.useTor(url) + okHttpClientForRelays.getHttpClient(useTor) } // Caches all events in Memory @@ -655,7 +582,7 @@ class AppModules( diskCache = { diskCache }, memoryCache = { memoryCache }, blossomServerResolver = { blossomResolver }, - callFactory = { roleBasedHttpClientBuilder.okHttpClientForImage(it) }, + callFactory = { okHttpClients.getHttpClient(roleBasedHttpClientBuilder.shouldUseTorForImageDownload(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 deleted file mode 100644 index 8d0a082f8..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/I2pSharedPreferences.kt +++ /dev/null @@ -1,138 +0,0 @@ -/* - * 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 deleted file mode 100644 index f98a3e9fe..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/PrivacySharedPreferences.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * 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 deleted file mode 100644 index 7b385a506..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/PrivacyRoutingFlow.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * 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 70bd026bc..a332044be 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,81 +20,142 @@ */ 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.tor.TorType 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 routing: PrivacyRoutingFlow, + val torSettings: TorSettingsFlow, ) : IRoleBasedHttpClientBuilder { - private fun routeFor( - role: FeatureRole, + fun shouldUseTorForImageDownload(url: String) = + shouldUseTorFor( + url, + torSettings.torType.value, + torSettings.imagesViaTor.value, + ) + + fun shouldUseTorFor( url: String, - ): PrivacyRoute = routing.routeFor(role, url) + torType: TorType, + imagesViaTor: Boolean, + ) = when (torType) { + TorType.OFF -> false + TorType.INTERNAL -> shouldUseTor(url, imagesViaTor) + TorType.EXTERNAL -> shouldUseTor(url, imagesViaTor) + } - override fun proxyPortForVideo(url: String): Int? = okHttpClient.getCurrentProxyPort(routeFor(FeatureRole.VIDEO, url)) + private fun shouldUseTor( + normalizedUrl: String, + final: Boolean, + ): Boolean = + if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) { + false + } else if (RelayUrlNormalizer.isOnion(normalizedUrl)) { + true + } else { + final + } - override fun okHttpClientForNip05(url: String): OkHttpClient = okHttpClient.getHttpClient(routeFor(FeatureRole.NIP05, url)) + fun shouldUseTorForVideoDownload() = + when (torSettings.torType.value) { + TorType.OFF -> false + TorType.INTERNAL -> torSettings.videosViaTor.value + TorType.EXTERNAL -> torSettings.videosViaTor.value + } - override fun okHttpClientForUploads(url: String): OkHttpClient = okHttpClient.getHttpClient(routeFor(FeatureRole.UPLOAD, 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 okHttpClientForImage(url: String): OkHttpClient = okHttpClient.getHttpClient(routeFor(FeatureRole.IMAGE, 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 okHttpClientForVideo(url: String): OkHttpClient = okHttpClient.getHttpClient(routeFor(FeatureRole.VIDEO, url)) + fun shouldUseTorForTrustedRelays() = + when (torSettings.torType.value) { + TorType.OFF -> false + TorType.INTERNAL -> torSettings.trustedRelaysViaTor.value + TorType.EXTERNAL -> torSettings.trustedRelaysViaTor.value + } - override fun okHttpClientForMoney(url: String): OkHttpClient = okHttpClient.getHttpClient(routeFor(FeatureRole.MONEY, url)) + private fun checkLocalHostOnionAndThen( + url: String, + final: Boolean, + ): Boolean = checkLocalHostOnionAndThen(url, torSettings.onionRelaysViaTor.value, final) - override fun okHttpClientForPreview(url: String): OkHttpClient = okHttpClient.getHttpClient(routeFor(FeatureRole.URL_PREVIEW, url)) + private fun checkLocalHostOnionAndThen( + normalizedUrl: String, + isOnionRelaysActive: Boolean, + final: Boolean, + ): Boolean = + if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) { + false + } else if (RelayUrlNormalizer.isOnion(normalizedUrl)) { + isOnionRelaysActive + } else { + final + } - // 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 shouldUseTorForMoneyOperations(url: String) = + when (torSettings.torType.value) { + TorType.OFF -> false + TorType.INTERNAL -> checkLocalHostOnionAndThen(url, torSettings.moneyOperationsViaTor.value) + TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, torSettings.moneyOperationsViaTor.value) + } - fun shouldUseTorForImageDownload(url: String) = routeFor(FeatureRole.IMAGE, url) is PrivacyRoute.Tor + 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 shouldUseTorForVideoDownload(url: String) = routeFor(FeatureRole.VIDEO, 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 shouldUseTorForPreviewUrl(url: String) = routeFor(FeatureRole.URL_PREVIEW, url) is PrivacyRoute.Tor + override fun proxyPortForVideo(url: String): Int? = okHttpClient.getCurrentProxyPort(shouldUseTorForVideoDownload(url)) - fun shouldUseTorForMoneyOperations(url: String) = routeFor(FeatureRole.MONEY, url) is PrivacyRoute.Tor + override fun okHttpClientForNip05(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForNIP05(url)) - fun shouldUseTorForNIP05(url: String) = routeFor(FeatureRole.NIP05, url) is PrivacyRoute.Tor + override fun okHttpClientForUploads(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForUploads(url)) - fun shouldUseTorForUploads(url: String) = routeFor(FeatureRole.UPLOAD, 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()) /** - * 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 - * 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. + * 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. */ 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 deleted file mode 100644 index 7c3fc9cb6..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/BlockedRouteException.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * 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 9576236d9..7cb4d3362 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,10 +20,7 @@ */ 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 @@ -42,10 +39,6 @@ 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) @@ -69,19 +62,8 @@ 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 @@ -96,57 +78,7 @@ 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) - } } /** @@ -158,16 +90,3 @@ 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 c125ff496..d190c885d 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,9 +20,7 @@ */ 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 @@ -38,7 +36,6 @@ class DualHttpClientManagerForRelays( isMobileDataProvider: StateFlow, scope: CoroutineScope, dns: SurgeDns, - private val i2pProxyPortProvider: StateFlow = MutableStateFlow(null), ) : IHttpClientManager { val factory = OkHttpClientFactoryForRelays(userAgent, dns) @@ -61,19 +58,8 @@ 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 @@ -87,20 +73,4 @@ 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 b73e7afb8..0c636f8a9 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,6 +34,7 @@ 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 @@ -43,19 +44,15 @@ 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() { - // 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() + private var poolNoProxy: MediaSessionPool? = null + private var poolWithProxy: MediaSessionPool? = null @OptIn(UnstableApi::class) fun newPool( videoCache: VideoCache, - okHttpClient: Call.Factory, + okHttpClient: DynamicCallFactory, blossomServerResolver: BlossomServerResolver, ): MediaSessionPool { val dataSourceFactory = OkHttpDataSource.Factory(okHttpClient) @@ -99,20 +96,39 @@ class PlaybackService : MediaSessionService() { @OptIn(UnstableApi::class) fun lazyPool(proxyPort: Int): MediaSessionPool { - // 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 } + return if (proxyPort <= 0) { + // no proxy + poolNoProxy?.let { return it } - val okHttpClient = Amethyst.instance.okHttpClients.getDynamicCallFactoryForPort(key) - val videoCache = Amethyst.instance.videoCache - val blossomServerResolver = Amethyst.instance.blossomResolver + val okHttpClient = Amethyst.instance.okHttpClients.getDynamicCallFactory(false) + val videoCache = Amethyst.instance.videoCache + val blossomServerResolver = Amethyst.instance.blossomResolver - 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) + // 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) + } } } @@ -124,8 +140,8 @@ class PlaybackService : MediaSessionService() { override fun onDestroy() { Log.d("PlaybackService", "PlaybackService.onDestroy") - poolsByPort.values.forEach { it.destroy() } - poolsByPort.clear() + poolWithProxy?.destroy() + poolNoProxy?.destroy() super.onDestroy() } @@ -145,11 +161,12 @@ class PlaybackService : MediaSessionService() { // 2. b. On screen video with volume on // 2. c. On screen video with volume off. - val playing = poolsByPort.values.flatMap { it.playingContent() } + val playing = (poolWithProxy?.playingContent() ?: emptyList()) + (poolNoProxy?.playingContent() ?: emptyList()) + // if nothing is pl if (playing.isEmpty() && BackgroundMedia.hasInstance()) { BackgroundMedia.bgInstance?.id?.let { id -> - poolsByPort.values.firstNotNullOfOrNull { it.getSession(id) }?.let { + (poolNoProxy?.getSession(id) ?: poolWithProxy?.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 deleted file mode 100644 index 03faa8c4b..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pDialogViewModel.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * 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 deleted file mode 100644 index 4735d2254..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pManager.kt +++ /dev/null @@ -1,84 +0,0 @@ -/* - * 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 deleted file mode 100644 index 7497a8008..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettings.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * 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 deleted file mode 100644 index e608e1be6..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettingsDialog.kt +++ /dev/null @@ -1,182 +0,0 @@ -/* - * 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 deleted file mode 100644 index 6ad085fb7..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettingsFlow.kt +++ /dev/null @@ -1,174 +0,0 @@ -/* - * 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 deleted file mode 100644 index fc8ea236f..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/privacy/PrivacyTransportPicker.kt +++ /dev/null @@ -1,82 +0,0 @@ -/* - * 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 4bd3863d2..306447fc5 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,32 +20,23 @@ */ 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.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier 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 @@ -53,68 +44,46 @@ import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow @OptIn(ExperimentalMaterial3Api::class) @Composable fun PrivacyOptionsScreen(nav: INav) { - 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, - ) + PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, nav) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun PrivacyOptionsScreen( torSettingsFlow: TorSettingsFlow, - i2pSettingsFlow: I2pSettingsFlow, - preferredTransport: PrivacyTransport, - onSavePreferred: (PrivacyTransport) -> Unit, nav: INav, ) { - val torVm = viewModel() - val i2pVm = viewModel() - val pickerVm = viewModel() + val dialogViewModel = viewModel() - // Reset all three view models from disk-loaded state exactly once, - // mirroring how the old screen reset only the Tor VM. - LaunchedEffect(torVm, i2pVm, pickerVm) { - torVm.reset(torSettingsFlow.toSettings()) - i2pVm.reset(i2pSettingsFlow.toSettings()) - pickerVm.reset(preferredTransport) - } + // 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 + } - PrivacyOptionsScreenContents( - torVm = torVm, - i2pVm = i2pVm, - pickerVm = pickerVm, - onPostTor = torSettingsFlow::update, - onPostI2p = i2pSettingsFlow::update, - onPostPreferred = onSavePreferred, - nav = nav, - ) + PrivacyOptionsScreenContents(dialogViewModel, onPost = torSettingsFlow::update, nav) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun PrivacyOptionsScreenContents( - torVm: TorDialogViewModel, - i2pVm: I2pDialogViewModel, - pickerVm: PrivacyTransportPickerViewModel, - onPostTor: (TorSettings) -> Unit, - onPostI2p: (I2pSettings) -> Unit, - onPostPreferred: (PrivacyTransport) -> Unit, + dialogViewModel: TorDialogViewModel, + onPost: (TorSettings) -> Unit, nav: INav, ) { Scaffold( topBar = { SavingTopBar( titleRes = R.string.privacy_options, - onCancel = { nav.popBack() }, + onCancel = { + nav.popBack() + }, onPost = { - onPostTor(torVm.save()) - onPostI2p(i2pVm.save()) - onPostPreferred(pickerVm.save()) + onPost(dialogViewModel.save()) nav.popBack() }, ) @@ -124,19 +93,11 @@ fun PrivacyOptionsScreenContents( Modifier .padding(it) .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 10.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + .verticalScroll( + rememberScrollState(), + ).padding(horizontal = 10.dp), ) { - PreferredTransportPicker(pickerVm) - - HorizontalDivider() - - PrivacySettingsBody(torVm) - - HorizontalDivider() - - I2pSettingsBody(i2pVm) + PrivacySettingsBody(dialogViewModel) } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index bb1c69e62..fd7a5d35b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1102,54 +1102,6 @@ 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 deleted file mode 100644 index 60ea602b4..000000000 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlockedRouteExceptionTest.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * 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 deleted file mode 100644 index 39ce3460c..000000000 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pRelayEvaluation.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * 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 deleted file mode 100644 index ee5496599..000000000 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pRelaySettings.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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 deleted file mode 100644 index 8422d77a0..000000000 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pServiceStatus.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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 deleted file mode 100644 index cd27ba36d..000000000 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pSettings.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * 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 deleted file mode 100644 index c976c0a87..000000000 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/II2pManager.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * 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 deleted file mode 100644 index db9c11aff..000000000 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/II2pSettingsPersistence.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * 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 deleted file mode 100644 index 3ed68518c..000000000 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/FeatureRole.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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 deleted file mode 100644 index 6a942b8e7..000000000 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRelayEvaluation.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * 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 deleted file mode 100644 index 88b180cd9..000000000 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRoute.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * 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 deleted file mode 100644 index 504c83c49..000000000 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouter.kt +++ /dev/null @@ -1,93 +0,0 @@ -/* - * 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 deleted file mode 100644 index 88e35ab50..000000000 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacySettings.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * 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 deleted file mode 100644 index c141029b3..000000000 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyTransport.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * 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 deleted file mode 100644 index 5a7522962..000000000 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouterTest.kt +++ /dev/null @@ -1,190 +0,0 @@ -/* - * 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 deleted file mode 100644 index aec407c61..000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/HiddenServiceKind.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * 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 d35c87b2b..ac3f69a1f 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,8 +46,4 @@ 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 d7a11df76..ffd1da4d6 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,16 +48,6 @@ 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] != '/' @@ -159,7 +149,7 @@ class RelayUrlNormalizer { return null } - return if (isOnion(trimmed) || isI2p(trimmed) || isLocalHost(trimmed)) { + return if (isOnion(trimmed) || isLocalHost(trimmed)) { "ws://$trimmed" } else { "wss://$trimmed"