From fb2f05d9cb50cccb08fc4258b4d1800737099fb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 19:37:06 +0000 Subject: [PATCH 01/10] feat: scaffold I2P as a parallel privacy transport to Tor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundational types for offering I2P alongside the existing internal Tor. No wiring yet — HTTP managers, RoleBasedHttpClientBuilder, the Android I2P service and the Privacy settings UI follow in later commits. Quartz (relay URL classifier): - Add isI2p() and classifyHidden() to RelayUrlNormalizer - Add HiddenServiceKind { CLEARNET, LOCALHOST, ONION, I2P } - Add NormalizedRelayUrl.isI2p() / classifyHidden() extensions - Extend the scheme-default branch so .i2p hosts default to ws:// like .onion Commons (transport-agnostic types): - PrivacyTransport enum { DIRECT, TOR, I2P } - TransportChoice (UI-facing per-feature picker, screen-coded for persistence) - FeatureRole + FeatureTransportChoices: per-feature picks for clearnet traffic - PrivacySettings aggregate { tor, i2p, features } - PrivacyRouter.route(url, role, settings): hostname pin for hidden services, per-feature choice for clearnet, downgrades to DIRECT if backing transport is OFF Commons (I2P settings model, mirrors tor/): - I2pSettings, I2pType (OFF/INTERNAL/EXTERNAL), I2pRelaySettings - I2pRelayEvaluation, I2pServiceStatus - II2pManager, II2pSettingsPersistence (platform-agnostic interfaces) - PrivacyRelayEvaluation composing TorRelayEvaluation + I2pRelayEvaluation Tests: - PrivacyRouterTest covers localhost bypass, onion pin, i2p pin, hostname-wins-over-picker, per-feature picks routing independently, downgrade-when-transport-OFF, .b32.i2p --- .../commons/i2p/I2pRelayEvaluation.kt | 48 +++++++ .../amethyst/commons/i2p/I2pRelaySettings.kt | 29 +++++ .../amethyst/commons/i2p/I2pServiceStatus.kt | 35 +++++ .../amethyst/commons/i2p/I2pSettings.kt | 49 +++++++ .../amethyst/commons/i2p/II2pManager.kt | 36 ++++++ .../commons/i2p/II2pSettingsPersistence.kt | 27 ++++ .../amethyst/commons/privacy/FeatureRole.kt | 33 +++++ .../privacy/FeatureTransportChoices.kt | 56 ++++++++ .../commons/privacy/PrivacyRelayEvaluation.kt | 46 +++++++ .../amethyst/commons/privacy/PrivacyRouter.kt | 52 ++++++++ .../commons/privacy/PrivacySettings.kt | 40 ++++++ .../commons/privacy/PrivacyTransport.kt | 27 ++++ .../commons/privacy/TransportChoice.kt | 45 +++++++ .../commons/privacy/PrivacyRouterTest.kt | 120 ++++++++++++++++++ .../relay/normalizer/HiddenServiceKind.kt | 28 ++++ .../relay/normalizer/NormalizedRelayUrl.kt | 4 + .../relay/normalizer/RelayUrlNormalizer.kt | 12 +- 17 files changed, 686 insertions(+), 1 deletion(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pRelayEvaluation.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pRelaySettings.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pServiceStatus.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pSettings.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/II2pManager.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/II2pSettingsPersistence.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/FeatureRole.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/FeatureTransportChoices.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRelayEvaluation.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouter.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacySettings.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyTransport.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/TransportChoice.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouterTest.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/HiddenServiceKind.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pRelayEvaluation.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pRelayEvaluation.kt new file mode 100644 index 000000000..39ce3460c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pRelayEvaluation.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.i2p + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isI2p +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost + +class I2pRelayEvaluation( + val i2pSettings: I2pRelaySettings, + val trustedRelayList: Set, + val dmRelayList: Set, +) { + fun useI2p(relay: NormalizedRelayUrl): Boolean = + if (i2pSettings.i2pType == I2pType.OFF) { + false + } else { + if (relay.isLocalHost()) { + false + } else if (relay.isI2p()) { + i2pSettings.i2pRelaysViaI2p + } else if (relay in dmRelayList) { + i2pSettings.dmRelaysViaI2p + } else if (relay in trustedRelayList) { + i2pSettings.trustedRelaysViaI2p + } else { + i2pSettings.newRelaysViaI2p + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pRelaySettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pRelaySettings.kt new file mode 100644 index 000000000..ee5496599 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pRelaySettings.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.i2p + +data class I2pRelaySettings( + val i2pType: I2pType = I2pType.OFF, + val i2pRelaysViaI2p: Boolean = true, + val dmRelaysViaI2p: Boolean = false, + val newRelaysViaI2p: Boolean = false, + val trustedRelaysViaI2p: Boolean = false, +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pServiceStatus.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pServiceStatus.kt new file mode 100644 index 000000000..8422d77a0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pServiceStatus.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.i2p + +sealed class I2pServiceStatus { + data class Active( + val port: Int, + ) : I2pServiceStatus() + + data object Off : I2pServiceStatus() + + data object Connecting : I2pServiceStatus() + + data class Error( + val message: String, + ) : I2pServiceStatus() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pSettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pSettings.kt new file mode 100644 index 000000000..cb3524385 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pSettings.kt @@ -0,0 +1,49 @@ +/* + * 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 + +// Mirror of TorSettings. Per-relay-class booleans answer "should I2P handle this +// kind of relay" — kept independent of TorSettings since the same questions can +// be answered differently for each transport. Per-feature picks (images, +// videos, etc.) live on PrivacySettings.features, not here. +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, +) + +enum class I2pType( + val screenCode: Int, +) { + OFF(0), + INTERNAL(1), + EXTERNAL(2), +} + +fun parseI2pType(code: Int?): I2pType = + when (code) { + I2pType.INTERNAL.screenCode -> I2pType.INTERNAL + I2pType.EXTERNAL.screenCode -> I2pType.EXTERNAL + else -> I2pType.OFF + } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/II2pManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/II2pManager.kt new file mode 100644 index 000000000..c976c0a87 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/II2pManager.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.i2p + +import kotlinx.coroutines.flow.StateFlow + +// Platform-agnostic interface for the I2P daemon lifecycle. +// Android internal mode bundles net.i2p:router; external mode just opens a SOCKS +// proxy to a user-run daemon. activePortOrNull surfaces whichever local SOCKS +// port the HTTP manager should route I2P traffic through. +interface II2pManager { + val status: StateFlow + val activePortOrNull: StateFlow + + suspend fun dormant() + + suspend fun active() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/II2pSettingsPersistence.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/II2pSettingsPersistence.kt new file mode 100644 index 000000000..db9c11aff --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/II2pSettingsPersistence.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.i2p + +interface II2pSettingsPersistence { + fun load(): I2pSettings + + fun save(settings: I2pSettings) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/FeatureRole.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/FeatureRole.kt new file mode 100644 index 000000000..a182e264e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/FeatureRole.kt @@ -0,0 +1,33 @@ +/* + * 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. Used by PrivacyRouter to pick a transport for +// clearnet traffic (hidden-service hostnames hard-pin and ignore the role). +enum class FeatureRole { + IMAGE, + VIDEO, + URL_PREVIEW, + PROFILE_PIC, + NIP05, + MONEY, + UPLOAD, +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/FeatureTransportChoices.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/FeatureTransportChoices.kt new file mode 100644 index 000000000..17e5236b0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/FeatureTransportChoices.kt @@ -0,0 +1,56 @@ +/* + * 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 + +data class FeatureTransportChoices( + val image: TransportChoice = TransportChoice.DIRECT, + val video: TransportChoice = TransportChoice.DIRECT, + val urlPreview: TransportChoice = TransportChoice.DIRECT, + val profilePic: TransportChoice = TransportChoice.DIRECT, + val nip05: TransportChoice = TransportChoice.DIRECT, + val money: TransportChoice = TransportChoice.DIRECT, + val upload: TransportChoice = TransportChoice.DIRECT, +) { + fun choiceFor(role: FeatureRole): TransportChoice = + when (role) { + FeatureRole.IMAGE -> image + FeatureRole.VIDEO -> video + FeatureRole.URL_PREVIEW -> urlPreview + FeatureRole.PROFILE_PIC -> profilePic + FeatureRole.NIP05 -> nip05 + FeatureRole.MONEY -> money + FeatureRole.UPLOAD -> upload + } + + fun with( + role: FeatureRole, + choice: TransportChoice, + ): FeatureTransportChoices = + when (role) { + FeatureRole.IMAGE -> copy(image = choice) + FeatureRole.VIDEO -> copy(video = choice) + FeatureRole.URL_PREVIEW -> copy(urlPreview = choice) + FeatureRole.PROFILE_PIC -> copy(profilePic = choice) + FeatureRole.NIP05 -> copy(nip05 = choice) + FeatureRole.MONEY -> copy(money = choice) + FeatureRole.UPLOAD -> copy(upload = choice) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRelayEvaluation.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRelayEvaluation.kt new file mode 100644 index 000000000..6a942b8e7 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRelayEvaluation.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.privacy + +import com.vitorpamplona.amethyst.commons.i2p.I2pRelayEvaluation +import com.vitorpamplona.amethyst.commons.tor.TorRelayEvaluation +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isI2p +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion + +// Per-relay transport pick. Onion always wins for Tor, I2P always wins for I2P +// (matching PrivacyRouter's hostname pin). For non-hidden hosts, prefer I2P if +// both transports want the relay — arbitrary tiebreak documented here so the +// behavior is testable. +class PrivacyRelayEvaluation( + private val tor: TorRelayEvaluation, + private val i2p: I2pRelayEvaluation, +) { + fun transport(relay: NormalizedRelayUrl): PrivacyTransport { + if (relay.isOnion()) return if (tor.useTor(relay)) PrivacyTransport.TOR else PrivacyTransport.DIRECT + if (relay.isI2p()) return if (i2p.useI2p(relay)) PrivacyTransport.I2P else PrivacyTransport.DIRECT + return when { + i2p.useI2p(relay) -> PrivacyTransport.I2P + tor.useTor(relay) -> PrivacyTransport.TOR + else -> PrivacyTransport.DIRECT + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouter.kt new file mode 100644 index 000000000..4014d451c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouter.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.privacy + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.HiddenServiceKind +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +// Decides which PrivacyTransport carries a request. Hidden-service hostnames +// hard-pin (onion → Tor, i2p → I2P) regardless of the per-feature picker, since +// no other transport can reach them. If the pinned or chosen transport is OFF, +// downgrade to DIRECT — the caller decides whether to make the request anyway. +object PrivacyRouter { + fun route( + url: String, + role: FeatureRole, + settings: PrivacySettings, + ): PrivacyTransport = + when (RelayUrlNormalizer.classifyHidden(url)) { + HiddenServiceKind.LOCALHOST -> PrivacyTransport.DIRECT + HiddenServiceKind.ONION -> if (settings.torAvailable) PrivacyTransport.TOR else PrivacyTransport.DIRECT + HiddenServiceKind.I2P -> if (settings.i2pAvailable) PrivacyTransport.I2P else PrivacyTransport.DIRECT + HiddenServiceKind.CLEARNET -> resolve(settings.features.choiceFor(role), settings) + } + + private fun resolve( + choice: TransportChoice, + settings: PrivacySettings, + ): PrivacyTransport = + when (choice) { + TransportChoice.DIRECT -> PrivacyTransport.DIRECT + TransportChoice.TOR -> if (settings.torAvailable) PrivacyTransport.TOR else PrivacyTransport.DIRECT + TransportChoice.I2P -> if (settings.i2pAvailable) PrivacyTransport.I2P else PrivacyTransport.DIRECT + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacySettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacySettings.kt new file mode 100644 index 000000000..862251584 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacySettings.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.privacy + +import com.vitorpamplona.amethyst.commons.i2p.I2pSettings +import com.vitorpamplona.amethyst.commons.i2p.I2pType +import com.vitorpamplona.amethyst.commons.tor.TorSettings +import com.vitorpamplona.amethyst.commons.tor.TorType + +// Aggregate of all privacy configuration. TorSettings and I2pSettings stay +// independent (each owns its daemon-type + per-relay-class booleans, persisted +// under its own pref-key namespace). FeatureTransportChoices is the single +// source of truth for per-feature clearnet routing — UI presents one 3-way +// picker per role rather than two parallel toggles. +data class PrivacySettings( + val tor: TorSettings = TorSettings(), + val i2p: I2pSettings = I2pSettings(), + val features: FeatureTransportChoices = FeatureTransportChoices(), +) { + val torAvailable: Boolean get() = tor.torType != TorType.OFF + val i2pAvailable: Boolean get() = i2p.i2pType != I2pType.OFF +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyTransport.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyTransport.kt new file mode 100644 index 000000000..c141029b3 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyTransport.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.privacy + +enum class PrivacyTransport { + DIRECT, + TOR, + I2P, +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/TransportChoice.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/TransportChoice.kt new file mode 100644 index 000000000..a418d0d4a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/TransportChoice.kt @@ -0,0 +1,45 @@ +/* + * 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 + +// UI-facing per-feature choice. Kept separate from PrivacyTransport so a future +// AUTO value can be added without churning the transport plumbing. +enum class TransportChoice( + val screenCode: Int, +) { + DIRECT(0), + TOR(1), + I2P(2), +} + +fun parseTransportChoice(code: Int?): TransportChoice = + when (code) { + TransportChoice.TOR.screenCode -> TransportChoice.TOR + TransportChoice.I2P.screenCode -> TransportChoice.I2P + else -> TransportChoice.DIRECT + } + +fun TransportChoice.toTransport(): PrivacyTransport = + when (this) { + TransportChoice.DIRECT -> PrivacyTransport.DIRECT + TransportChoice.TOR -> PrivacyTransport.TOR + TransportChoice.I2P -> PrivacyTransport.I2P + } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouterTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouterTest.kt new file mode 100644 index 000000000..8316e0353 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouterTest.kt @@ -0,0 +1,120 @@ +/* + * 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, + features: FeatureTransportChoices = FeatureTransportChoices(), + ) = PrivacySettings( + tor = TorSettings(torType = if (torOn) TorType.INTERNAL else TorType.OFF), + i2p = I2pSettings(i2pType = if (i2pOn) I2pType.INTERNAL else I2pType.OFF), + features = features, + ) + + @Test + fun localhost_alwaysDirect() { + assertEquals(PrivacyTransport.DIRECT, PrivacyRouter.route(localhost, FeatureRole.IMAGE, settings(torOn = true, i2pOn = true))) + } + + @Test + fun onion_pinsToTor_whenAvailable() { + assertEquals(PrivacyTransport.TOR, PrivacyRouter.route(onion, FeatureRole.IMAGE, settings(torOn = true))) + } + + @Test + fun onion_downgradesToDirect_whenTorOff() { + assertEquals(PrivacyTransport.DIRECT, PrivacyRouter.route(onion, FeatureRole.IMAGE, settings(torOn = false, i2pOn = true))) + } + + @Test + fun i2p_pinsToI2p_whenAvailable() { + assertEquals(PrivacyTransport.I2P, PrivacyRouter.route(i2p, FeatureRole.IMAGE, settings(i2pOn = true))) + } + + @Test + fun i2p_downgradesToDirect_whenI2pOff() { + assertEquals(PrivacyTransport.DIRECT, PrivacyRouter.route(i2p, FeatureRole.IMAGE, settings(torOn = true, i2pOn = false))) + } + + @Test + fun i2p_ignoresFeatureChoice() { + // Per-feature picker is irrelevant for hidden-service hosts. + val features = FeatureTransportChoices(image = TransportChoice.TOR) + assertEquals(PrivacyTransport.I2P, PrivacyRouter.route(i2p, FeatureRole.IMAGE, settings(torOn = true, i2pOn = true, features = features))) + } + + @Test + fun clearnet_followsFeatureChoice_tor() { + val features = FeatureTransportChoices(image = TransportChoice.TOR) + assertEquals(PrivacyTransport.TOR, PrivacyRouter.route(clearnet, FeatureRole.IMAGE, settings(torOn = true, features = features))) + } + + @Test + fun clearnet_followsFeatureChoice_i2p() { + val features = FeatureTransportChoices(video = TransportChoice.I2P) + assertEquals(PrivacyTransport.I2P, PrivacyRouter.route(clearnet, FeatureRole.VIDEO, settings(i2pOn = true, features = features))) + } + + @Test + fun clearnet_choiceWithoutBackingTransport_downgradesToDirect() { + val features = FeatureTransportChoices(image = TransportChoice.TOR) + assertEquals(PrivacyTransport.DIRECT, PrivacyRouter.route(clearnet, FeatureRole.IMAGE, settings(torOn = false, features = features))) + } + + @Test + fun clearnet_defaultChoice_isDirect() { + assertEquals(PrivacyTransport.DIRECT, PrivacyRouter.route(clearnet, FeatureRole.NIP05, settings(torOn = true, i2pOn = true))) + } + + @Test + fun perFeatureRouting_routesIndependently() { + val features = + FeatureTransportChoices( + image = TransportChoice.TOR, + video = TransportChoice.I2P, + urlPreview = TransportChoice.DIRECT, + ) + val s = settings(torOn = true, i2pOn = true, features = features) + assertEquals(PrivacyTransport.TOR, PrivacyRouter.route(clearnet, FeatureRole.IMAGE, s)) + assertEquals(PrivacyTransport.I2P, PrivacyRouter.route(clearnet, FeatureRole.VIDEO, s)) + assertEquals(PrivacyTransport.DIRECT, PrivacyRouter.route(clearnet, FeatureRole.URL_PREVIEW, s)) + } + + @Test + fun b32_i2p_address_pinsToI2p() { + val b32 = "wss://abcdefghijklmnopqrstuvwxyz234567.b32.i2p/" + assertEquals(PrivacyTransport.I2P, PrivacyRouter.route(b32, FeatureRole.IMAGE, settings(i2pOn = true))) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/HiddenServiceKind.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/HiddenServiceKind.kt new file mode 100644 index 000000000..aec407c61 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/HiddenServiceKind.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.normalizer + +enum class HiddenServiceKind { + CLEARNET, + LOCALHOST, + ONION, + I2P, +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt index ac3f69a1f..d35c87b2b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt @@ -46,4 +46,8 @@ fun NormalizedRelayUrl.toHttp() = fun NormalizedRelayUrl.isOnion() = url.contains(".onion/") +fun NormalizedRelayUrl.isI2p() = RelayUrlNormalizer.isI2p(this.url) + fun NormalizedRelayUrl.isLocalHost() = RelayUrlNormalizer.isLocalHost(this.url) + +fun NormalizedRelayUrl.classifyHidden(): HiddenServiceKind = RelayUrlNormalizer.classifyHidden(this.url) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt index ffd1da4d6..d7a11df76 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt @@ -48,6 +48,16 @@ class RelayUrlNormalizer { fun isOnion(url: String) = url.endsWith(".onion") || url.contains(".onion/") + fun isI2p(url: String) = url.endsWith(".i2p") || url.contains(".i2p/") + + fun classifyHidden(url: String): HiddenServiceKind = + when { + isLocalHost(url) -> HiddenServiceKind.LOCALHOST + isOnion(url) -> HiddenServiceKind.ONION + isI2p(url) -> HiddenServiceKind.I2P + else -> HiddenServiceKind.CLEARNET + } + fun isRelaySchemePrefix(url: String) = url.length > 6 && url[0] == 'w' && url[1] == 's' fun isRelaySchemePrefixSecure(url: String) = url[2] == 's' && url[3] == ':' && url[4] == '/' && url[5] == '/' && url[6] != '/' @@ -149,7 +159,7 @@ class RelayUrlNormalizer { return null } - return if (isOnion(trimmed) || isLocalHost(trimmed)) { + return if (isOnion(trimmed) || isI2p(trimmed) || isLocalHost(trimmed)) { "ws://$trimmed" } else { "wss://$trimmed" From a011421ff52ba140e37c1cb8efcd7714a49dfd6a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 20:06:35 +0000 Subject: [PATCH 02/10] refactor(privacy): single preferred clearnet transport, fail-closed hidden services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the per-feature 3-way picker — no splitting clearnet traffic between Tor and I2P at the same time. Both daemons can still run side-by-side so .onion and .i2p hidden services stay reachable independently, but only one transport carries clearnet traffic at a time. Routing model: - .onion → Tor required; Blocked when Tor is OFF (no clearnet fallback) - .i2p → I2P required; Blocked when I2P is OFF (no clearnet fallback) - clearnet → preferredClearnetTransport (NONE/TOR/I2P) picks the active transport; that transport's own per-feature toggle decides this request; otherwise Direct Commons: - Add PrivacyRoute sealed type { Direct, Tor, I2p, Blocked(BlockReason) } so fail-closed has somewhere to land — callers must surface Blocked instead of silently leaking over clearnet - PrivacySettings drops `features`, adds preferredClearnetTransport - I2pSettings gains imagesViaI2p / videosViaI2p / urlPreviewsViaI2p / profilePicsViaI2p / nip05VerificationsViaI2p / moneyOperationsViaI2p / mediaUploadsViaI2p — mirrors TorSettings; only effective when I2P is the preferred clearnet transport - PrivacyRouter rewritten to the new model - Delete FeatureTransportChoices and TransportChoice - Keep FeatureRole as the per-request hint that selects which toggle to read Tests: PrivacyRouterTest rewritten to cover the new outcomes, including both-daemons-running clearnet preference, fail-closed for .onion / .i2p, and that the non-preferred transport's toggles have no effect on clearnet. --- .../amethyst/commons/i2p/I2pSettings.kt | 16 +- .../amethyst/commons/privacy/FeatureRole.kt | 6 +- .../privacy/FeatureTransportChoices.kt | 56 ------ .../{TransportChoice.kt => PrivacyRoute.kt} | 40 ++-- .../amethyst/commons/privacy/PrivacyRouter.kt | 73 +++++-- .../commons/privacy/PrivacySettings.kt | 12 +- .../commons/privacy/PrivacyRouterTest.kt | 182 ++++++++++++------ 7 files changed, 224 insertions(+), 161 deletions(-) delete mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/FeatureTransportChoices.kt rename commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/{TransportChoice.kt => PrivacyRoute.kt} (61%) 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 index cb3524385..f0efd6ea9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pSettings.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pSettings.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.commons.i2p -// Mirror of TorSettings. Per-relay-class booleans answer "should I2P handle this -// kind of relay" — kept independent of TorSettings since the same questions can -// be answered differently for each transport. Per-feature picks (images, -// videos, etc.) live on PrivacySettings.features, not here. +// 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, @@ -31,6 +32,13 @@ data class I2pSettings( 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, ) enum class I2pType( 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 index a182e264e..3ed68518c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/FeatureRole.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/FeatureRole.kt @@ -20,8 +20,10 @@ */ package com.vitorpamplona.amethyst.commons.privacy -// What the network call is for. Used by PrivacyRouter to pick a transport for -// clearnet traffic (hidden-service hostnames hard-pin and ignore the role). +// 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, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/FeatureTransportChoices.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/FeatureTransportChoices.kt deleted file mode 100644 index 17e5236b0..000000000 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/FeatureTransportChoices.kt +++ /dev/null @@ -1,56 +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 - -data class FeatureTransportChoices( - val image: TransportChoice = TransportChoice.DIRECT, - val video: TransportChoice = TransportChoice.DIRECT, - val urlPreview: TransportChoice = TransportChoice.DIRECT, - val profilePic: TransportChoice = TransportChoice.DIRECT, - val nip05: TransportChoice = TransportChoice.DIRECT, - val money: TransportChoice = TransportChoice.DIRECT, - val upload: TransportChoice = TransportChoice.DIRECT, -) { - fun choiceFor(role: FeatureRole): TransportChoice = - when (role) { - FeatureRole.IMAGE -> image - FeatureRole.VIDEO -> video - FeatureRole.URL_PREVIEW -> urlPreview - FeatureRole.PROFILE_PIC -> profilePic - FeatureRole.NIP05 -> nip05 - FeatureRole.MONEY -> money - FeatureRole.UPLOAD -> upload - } - - fun with( - role: FeatureRole, - choice: TransportChoice, - ): FeatureTransportChoices = - when (role) { - FeatureRole.IMAGE -> copy(image = choice) - FeatureRole.VIDEO -> copy(video = choice) - FeatureRole.URL_PREVIEW -> copy(urlPreview = choice) - FeatureRole.PROFILE_PIC -> copy(profilePic = choice) - FeatureRole.NIP05 -> copy(nip05 = choice) - FeatureRole.MONEY -> copy(money = choice) - FeatureRole.UPLOAD -> copy(upload = choice) - } -} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/TransportChoice.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRoute.kt similarity index 61% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/TransportChoice.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRoute.kt index a418d0d4a..88b180cd9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/TransportChoice.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRoute.kt @@ -20,26 +20,24 @@ */ package com.vitorpamplona.amethyst.commons.privacy -// UI-facing per-feature choice. Kept separate from PrivacyTransport so a future -// AUTO value can be added without churning the transport plumbing. -enum class TransportChoice( - val screenCode: Int, -) { - DIRECT(0), - TOR(1), - I2P(2), +// 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 } -fun parseTransportChoice(code: Int?): TransportChoice = - when (code) { - TransportChoice.TOR.screenCode -> TransportChoice.TOR - TransportChoice.I2P.screenCode -> TransportChoice.I2P - else -> TransportChoice.DIRECT - } - -fun TransportChoice.toTransport(): PrivacyTransport = - when (this) { - TransportChoice.DIRECT -> PrivacyTransport.DIRECT - TransportChoice.TOR -> PrivacyTransport.TOR - TransportChoice.I2P -> PrivacyTransport.I2P - } +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 index 4014d451c..504c83c49 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouter.kt @@ -20,33 +20,74 @@ */ 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 which PrivacyTransport carries a request. Hidden-service hostnames -// hard-pin (onion → Tor, i2p → I2P) regardless of the per-feature picker, since -// no other transport can reach them. If the pinned or chosen transport is OFF, -// downgrade to DIRECT — the caller decides whether to make the request anyway. +// 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, - ): PrivacyTransport = + ): PrivacyRoute = when (RelayUrlNormalizer.classifyHidden(url)) { - HiddenServiceKind.LOCALHOST -> PrivacyTransport.DIRECT - HiddenServiceKind.ONION -> if (settings.torAvailable) PrivacyTransport.TOR else PrivacyTransport.DIRECT - HiddenServiceKind.I2P -> if (settings.i2pAvailable) PrivacyTransport.I2P else PrivacyTransport.DIRECT - HiddenServiceKind.CLEARNET -> resolve(settings.features.choiceFor(role), settings) + 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 resolve( - choice: TransportChoice, + private fun clearnet( + role: FeatureRole, settings: PrivacySettings, - ): PrivacyTransport = - when (choice) { - TransportChoice.DIRECT -> PrivacyTransport.DIRECT - TransportChoice.TOR -> if (settings.torAvailable) PrivacyTransport.TOR else PrivacyTransport.DIRECT - TransportChoice.I2P -> if (settings.i2pAvailable) PrivacyTransport.I2P else PrivacyTransport.DIRECT + ): 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 index 862251584..88e35ab50 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacySettings.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacySettings.kt @@ -25,15 +25,15 @@ 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. TorSettings and I2pSettings stay -// independent (each owns its daemon-type + per-relay-class booleans, persisted -// under its own pref-key namespace). FeatureTransportChoices is the single -// source of truth for per-feature clearnet routing — UI presents one 3-way -// picker per role rather than two parallel toggles. +// 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 features: FeatureTransportChoices = FeatureTransportChoices(), + 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/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouterTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouterTest.kt index 8316e0353..22e1b713c 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouterTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouterTest.kt @@ -36,85 +36,155 @@ class PrivacyRouterTest { private fun settings( torOn: Boolean = false, i2pOn: Boolean = false, - features: FeatureTransportChoices = FeatureTransportChoices(), + preferred: PrivacyTransport = PrivacyTransport.DIRECT, + tor: TorSettings = TorSettings(torType = if (torOn) TorType.INTERNAL else TorType.OFF), + i2p: I2pSettings = I2pSettings(i2pType = if (i2pOn) I2pType.INTERNAL else I2pType.OFF), ) = PrivacySettings( - tor = TorSettings(torType = if (torOn) TorType.INTERNAL else TorType.OFF), - i2p = I2pSettings(i2pType = if (i2pOn) I2pType.INTERNAL else I2pType.OFF), - features = features, + tor = tor, + i2p = i2p, + preferredClearnetTransport = preferred, ) @Test fun localhost_alwaysDirect() { - assertEquals(PrivacyTransport.DIRECT, PrivacyRouter.route(localhost, FeatureRole.IMAGE, settings(torOn = true, i2pOn = true))) + assertEquals( + PrivacyRoute.Direct, + PrivacyRouter.route(localhost, FeatureRole.IMAGE, settings(torOn = true, i2pOn = true, preferred = PrivacyTransport.TOR)), + ) } @Test fun onion_pinsToTor_whenAvailable() { - assertEquals(PrivacyTransport.TOR, PrivacyRouter.route(onion, FeatureRole.IMAGE, settings(torOn = true))) + assertEquals(PrivacyRoute.Tor, PrivacyRouter.route(onion, FeatureRole.IMAGE, settings(torOn = true))) } @Test - fun onion_downgradesToDirect_whenTorOff() { - assertEquals(PrivacyTransport.DIRECT, PrivacyRouter.route(onion, FeatureRole.IMAGE, settings(torOn = false, i2pOn = true))) + 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(PrivacyTransport.I2P, PrivacyRouter.route(i2p, FeatureRole.IMAGE, settings(i2pOn = true))) + assertEquals(PrivacyRoute.I2p, PrivacyRouter.route(i2p, FeatureRole.IMAGE, settings(i2pOn = true))) } @Test - fun i2p_downgradesToDirect_whenI2pOff() { - assertEquals(PrivacyTransport.DIRECT, PrivacyRouter.route(i2p, FeatureRole.IMAGE, settings(torOn = true, i2pOn = false))) - } - - @Test - fun i2p_ignoresFeatureChoice() { - // Per-feature picker is irrelevant for hidden-service hosts. - val features = FeatureTransportChoices(image = TransportChoice.TOR) - assertEquals(PrivacyTransport.I2P, PrivacyRouter.route(i2p, FeatureRole.IMAGE, settings(torOn = true, i2pOn = true, features = features))) - } - - @Test - fun clearnet_followsFeatureChoice_tor() { - val features = FeatureTransportChoices(image = TransportChoice.TOR) - assertEquals(PrivacyTransport.TOR, PrivacyRouter.route(clearnet, FeatureRole.IMAGE, settings(torOn = true, features = features))) - } - - @Test - fun clearnet_followsFeatureChoice_i2p() { - val features = FeatureTransportChoices(video = TransportChoice.I2P) - assertEquals(PrivacyTransport.I2P, PrivacyRouter.route(clearnet, FeatureRole.VIDEO, settings(i2pOn = true, features = features))) - } - - @Test - fun clearnet_choiceWithoutBackingTransport_downgradesToDirect() { - val features = FeatureTransportChoices(image = TransportChoice.TOR) - assertEquals(PrivacyTransport.DIRECT, PrivacyRouter.route(clearnet, FeatureRole.IMAGE, settings(torOn = false, features = features))) - } - - @Test - fun clearnet_defaultChoice_isDirect() { - assertEquals(PrivacyTransport.DIRECT, PrivacyRouter.route(clearnet, FeatureRole.NIP05, settings(torOn = true, i2pOn = true))) - } - - @Test - fun perFeatureRouting_routesIndependently() { - val features = - FeatureTransportChoices( - image = TransportChoice.TOR, - video = TransportChoice.I2P, - urlPreview = TransportChoice.DIRECT, - ) - val s = settings(torOn = true, i2pOn = true, features = features) - assertEquals(PrivacyTransport.TOR, PrivacyRouter.route(clearnet, FeatureRole.IMAGE, s)) - assertEquals(PrivacyTransport.I2P, PrivacyRouter.route(clearnet, FeatureRole.VIDEO, s)) - assertEquals(PrivacyTransport.DIRECT, PrivacyRouter.route(clearnet, FeatureRole.URL_PREVIEW, s)) + 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(PrivacyTransport.I2P, PrivacyRouter.route(b32, FeatureRole.IMAGE, settings(i2pOn = true))) + 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.INTERNAL, 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.INTERNAL, 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.INTERNAL, 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.INTERNAL, 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)) } } From 637bb4aba53040b852944aa8520d14abbc647cb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 20:44:28 +0000 Subject: [PATCH 03/10] feat(privacy): persist I2pSettings and preferredClearnetTransport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the existing Tor persistence stack so I2P settings + the global clearnet-transport preference survive process restart. New on Android: - I2pSettingsFlow — StateFlow-per-field shape matching TorSettingsFlow, including the propertyWatchFlow that the prefs class listens to. - I2pSharedPreferences — DataStore-backed load/save with `i2p.*` pref keys, debounced-save wired to propertyWatchFlow. - PrivacySharedPreferences — single MutableStateFlow for preferredClearnetTransport, persisted under `privacy.preferredClearnetTransport`. Kept separate from Tor/I2p prefs because the decision spans both transports. AppModules: - Parallel-load i2pPrefs and privacyPrefs alongside torPrefs in async/await pairs so cold-start blocking time stays at ~max() not sum(). - Lazy accessors mirror torPrefs/uiPrefs. No routing wired yet — RoleBasedHttpClientBuilder still consumes torPrefs only. That swap is the next chunk. --- .../com/vitorpamplona/amethyst/AppModules.kt | 30 +++ .../model/preferences/I2pSharedPreferences.kt | 132 +++++++++++++ .../preferences/PrivacySharedPreferences.kt | 90 +++++++++ .../amethyst/ui/i2p/I2pSettingsFlow.kt | 174 ++++++++++++++++++ 4 files changed, 426 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/I2pSharedPreferences.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/PrivacySharedPreferences.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettingsFlow.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index acdad276d..9fdf12fcf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -24,6 +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.robohash.CachedRobohash import com.vitorpamplona.amethyst.commons.tor.TorSettings @@ -34,8 +35,10 @@ import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState 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.RoleBasedHttpClientBuilder @@ -149,6 +152,18 @@ class AppModules( TorSharedPreferences(prefs, appContext, applicationIOScope) } + private val i2pPrefsDeferred = + applicationIOScope.async { + val prefs = I2pSharedPreferences.i2pPreferences(appContext) ?: I2pSettings() + I2pSharedPreferences(prefs, appContext, applicationIOScope) + } + + private val privacyPrefsDeferred = + applicationIOScope.async { + val initial = PrivacySharedPreferences.preferredClearnetTransport(appContext) + PrivacySharedPreferences(initial, appContext, applicationIOScope) + } + // Blocking load of UI Preferences to avoid theme/language blinking val uiPrefs by lazy { Log.d("AppModules", "UiSharedPreferences Init") @@ -161,6 +176,21 @@ class AppModules( runBlocking { torPrefsDeferred.await() } } + // Blocking load of I2P settings — paired with torPrefs since both feed the + // privacy transport routing decision and we don't want clearnet to leak before + // either is restored. + val i2pPrefs by lazy { + Log.d("AppModules", "I2pSharedPreferences Init") + runBlocking { i2pPrefsDeferred.await() } + } + + // Picks which transport (if any) carries clearnet traffic. Loaded blocking for + // the same reason as torPrefs/i2pPrefs. + val privacyPrefs by lazy { + Log.d("AppModules", "PrivacySharedPreferences Init") + runBlocking { privacyPrefsDeferred.await() } + } + // Namecoin ElectrumX server preferences (global, like Tor settings) val namecoinPrefs by lazy { Log.d("AppModules", "NamecoinSharedPreferences Init") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/I2pSharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/I2pSharedPreferences.kt new file mode 100644 index 000000000..ccaf45356 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/I2pSharedPreferences.kt @@ -0,0 +1,132 @@ +/* + * 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( + i2pType = preferences[I2P_TYPE_KEY]?.let { I2pType.valueOf(it) } ?: I2pType.OFF, + externalSocksPort = preferences[EXTERNAL_SOCKS_PORT_KEY] ?: 4447, + i2pRelaysViaI2p = preferences[I2P_RELAYS_VIA_I2P_KEY] ?: true, + dmRelaysViaI2p = preferences[DM_RELAYS_VIA_I2P_KEY] ?: false, + newRelaysViaI2p = preferences[NEW_RELAYS_VIA_I2P_KEY] ?: false, + trustedRelaysViaI2p = preferences[TRUSTED_RELAYS_VIA_I2P_KEY] ?: false, + urlPreviewsViaI2p = preferences[URL_PREVIEWS_VIA_I2P_KEY] ?: false, + profilePicsViaI2p = preferences[PROFILE_PICS_VIA_I2P_KEY] ?: false, + imagesViaI2p = preferences[IMAGES_VIA_I2P_KEY] ?: false, + videosViaI2p = preferences[VIDEOS_VIA_I2P_KEY] ?: false, + moneyOperationsViaI2p = preferences[MONEY_OPERATIONS_VIA_I2P_KEY] ?: false, + nip05VerificationsViaI2p = preferences[NIP05_VERIFICATIONS_VIA_I2P_KEY] ?: false, + mediaUploadsViaI2p = preferences[MEDIA_UPLOADS_VIA_I2P_KEY] ?: false, + ) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("SharedPreferences") { "Error reading I2P DataStore preferences: ${e.message}" } + null + } + + suspend fun save( + settings: I2pSettings, + context: Context, + ) { + try { + context.sharedPreferencesDataStore.edit { preferences -> + preferences[I2P_TYPE_KEY] = settings.i2pType.name + preferences[EXTERNAL_SOCKS_PORT_KEY] = settings.externalSocksPort + preferences[I2P_RELAYS_VIA_I2P_KEY] = settings.i2pRelaysViaI2p + preferences[DM_RELAYS_VIA_I2P_KEY] = settings.dmRelaysViaI2p + preferences[NEW_RELAYS_VIA_I2P_KEY] = settings.newRelaysViaI2p + preferences[TRUSTED_RELAYS_VIA_I2P_KEY] = settings.trustedRelaysViaI2p + preferences[URL_PREVIEWS_VIA_I2P_KEY] = settings.urlPreviewsViaI2p + preferences[PROFILE_PICS_VIA_I2P_KEY] = settings.profilePicsViaI2p + preferences[IMAGES_VIA_I2P_KEY] = settings.imagesViaI2p + preferences[VIDEOS_VIA_I2P_KEY] = settings.videosViaI2p + preferences[MONEY_OPERATIONS_VIA_I2P_KEY] = settings.moneyOperationsViaI2p + preferences[NIP05_VERIFICATIONS_VIA_I2P_KEY] = settings.nip05VerificationsViaI2p + preferences[MEDIA_UPLOADS_VIA_I2P_KEY] = settings.mediaUploadsViaI2p + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("SharedPreferences") { "Error saving I2P DataStore preferences: ${e.message}" } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/PrivacySharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/PrivacySharedPreferences.kt new file mode 100644 index 000000000..f98a3e9fe --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/PrivacySharedPreferences.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.preferences + +import android.content.Context +import androidx.compose.runtime.Stable +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.commons.privacy.PrivacyTransport +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlin.coroutines.cancellation.CancellationException + +// Global pick of which privacy transport carries clearnet traffic. Lives apart +// from TorSharedPreferences / I2pSharedPreferences because it spans both — each +// transport owns its own daemon-enable + per-feature toggles; this owns the +// "which one is in charge of clearnet" decision. +@Stable +class PrivacySharedPreferences( + initial: PrivacyTransport, + val context: Context, + val scope: CoroutineScope, +) { + val preferredClearnetTransport: MutableStateFlow = MutableStateFlow(initial) + + @OptIn(FlowPreview::class) + val saving = + preferredClearnetTransport + .debounce(1000) + .distinctUntilChanged() + .onEach { save(it, context) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, initial) + + companion object { + val PREFERRED_CLEARNET_TRANSPORT_KEY = stringPreferencesKey("privacy.preferredClearnetTransport") + + suspend fun preferredClearnetTransport(context: Context): PrivacyTransport = + try { + val name = context.sharedPreferencesDataStore.data.first()[PREFERRED_CLEARNET_TRANSPORT_KEY] + name?.let { runCatching { PrivacyTransport.valueOf(it) }.getOrNull() } ?: PrivacyTransport.DIRECT + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("SharedPreferences") { "Error reading preferredClearnetTransport: ${e.message}" } + PrivacyTransport.DIRECT + } + + suspend fun save( + transport: PrivacyTransport, + context: Context, + ) { + try { + context.sharedPreferencesDataStore.edit { prefs -> + prefs[PREFERRED_CLEARNET_TRANSPORT_KEY] = transport.name + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("SharedPreferences") { "Error saving preferredClearnetTransport: ${e.message}" } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettingsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettingsFlow.kt new file mode 100644 index 000000000..6ad085fb7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettingsFlow.kt @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.i2p + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.i2p.I2pSettings +import com.vitorpamplona.amethyst.commons.i2p.I2pType +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine + +@Stable +class I2pSettingsFlow( + val i2pType: MutableStateFlow = MutableStateFlow(I2pType.OFF), + val externalSocksPort: MutableStateFlow = MutableStateFlow(4447), + val i2pRelaysViaI2p: MutableStateFlow = MutableStateFlow(true), + val dmRelaysViaI2p: MutableStateFlow = MutableStateFlow(false), + val newRelaysViaI2p: MutableStateFlow = MutableStateFlow(false), + val trustedRelaysViaI2p: MutableStateFlow = MutableStateFlow(false), + val urlPreviewsViaI2p: MutableStateFlow = MutableStateFlow(false), + val profilePicsViaI2p: MutableStateFlow = MutableStateFlow(false), + val imagesViaI2p: MutableStateFlow = MutableStateFlow(false), + val videosViaI2p: MutableStateFlow = MutableStateFlow(false), + val moneyOperationsViaI2p: MutableStateFlow = MutableStateFlow(false), + val nip05VerificationsViaI2p: MutableStateFlow = MutableStateFlow(false), + val mediaUploadsViaI2p: MutableStateFlow = MutableStateFlow(false), +) { + val propertyWatchFlow = + combine( + listOf( + i2pType, + externalSocksPort, + i2pRelaysViaI2p, + dmRelaysViaI2p, + newRelaysViaI2p, + trustedRelaysViaI2p, + urlPreviewsViaI2p, + profilePicsViaI2p, + imagesViaI2p, + videosViaI2p, + moneyOperationsViaI2p, + nip05VerificationsViaI2p, + mediaUploadsViaI2p, + ), + ) { flows -> + I2pSettings( + flows[0] as I2pType, + flows[1] as Int, + flows[2] as Boolean, + flows[3] as Boolean, + flows[4] as Boolean, + flows[5] as Boolean, + flows[6] as Boolean, + flows[7] as Boolean, + flows[8] as Boolean, + flows[9] as Boolean, + flows[10] as Boolean, + flows[11] as Boolean, + flows[12] as Boolean, + ) + } + + fun toSettings(): I2pSettings = + I2pSettings( + i2pType.value, + externalSocksPort.value, + i2pRelaysViaI2p.value, + dmRelaysViaI2p.value, + newRelaysViaI2p.value, + trustedRelaysViaI2p.value, + urlPreviewsViaI2p.value, + profilePicsViaI2p.value, + imagesViaI2p.value, + videosViaI2p.value, + moneyOperationsViaI2p.value, + nip05VerificationsViaI2p.value, + mediaUploadsViaI2p.value, + ) + + fun update(settings: I2pSettings): Boolean { + var any = false + + if (i2pType.value != settings.i2pType) { + i2pType.tryEmit(settings.i2pType) + any = true + } + if (externalSocksPort.value != settings.externalSocksPort) { + externalSocksPort.tryEmit(settings.externalSocksPort) + any = true + } + if (i2pRelaysViaI2p.value != settings.i2pRelaysViaI2p) { + i2pRelaysViaI2p.tryEmit(settings.i2pRelaysViaI2p) + any = true + } + if (dmRelaysViaI2p.value != settings.dmRelaysViaI2p) { + dmRelaysViaI2p.tryEmit(settings.dmRelaysViaI2p) + any = true + } + if (newRelaysViaI2p.value != settings.newRelaysViaI2p) { + newRelaysViaI2p.tryEmit(settings.newRelaysViaI2p) + any = true + } + if (trustedRelaysViaI2p.value != settings.trustedRelaysViaI2p) { + trustedRelaysViaI2p.tryEmit(settings.trustedRelaysViaI2p) + any = true + } + if (urlPreviewsViaI2p.value != settings.urlPreviewsViaI2p) { + urlPreviewsViaI2p.tryEmit(settings.urlPreviewsViaI2p) + any = true + } + if (profilePicsViaI2p.value != settings.profilePicsViaI2p) { + profilePicsViaI2p.tryEmit(settings.profilePicsViaI2p) + any = true + } + if (imagesViaI2p.value != settings.imagesViaI2p) { + imagesViaI2p.tryEmit(settings.imagesViaI2p) + any = true + } + if (videosViaI2p.value != settings.videosViaI2p) { + videosViaI2p.tryEmit(settings.videosViaI2p) + any = true + } + if (moneyOperationsViaI2p.value != settings.moneyOperationsViaI2p) { + moneyOperationsViaI2p.tryEmit(settings.moneyOperationsViaI2p) + any = true + } + if (nip05VerificationsViaI2p.value != settings.nip05VerificationsViaI2p) { + nip05VerificationsViaI2p.tryEmit(settings.nip05VerificationsViaI2p) + any = true + } + if (mediaUploadsViaI2p.value != settings.mediaUploadsViaI2p) { + mediaUploadsViaI2p.tryEmit(settings.mediaUploadsViaI2p) + any = true + } + + return any + } + + companion object { + fun build(settings: I2pSettings): I2pSettingsFlow = + I2pSettingsFlow( + MutableStateFlow(settings.i2pType), + MutableStateFlow(settings.externalSocksPort), + MutableStateFlow(settings.i2pRelaysViaI2p), + MutableStateFlow(settings.dmRelaysViaI2p), + MutableStateFlow(settings.newRelaysViaI2p), + MutableStateFlow(settings.trustedRelaysViaI2p), + MutableStateFlow(settings.urlPreviewsViaI2p), + MutableStateFlow(settings.profilePicsViaI2p), + MutableStateFlow(settings.imagesViaI2p), + MutableStateFlow(settings.videosViaI2p), + MutableStateFlow(settings.moneyOperationsViaI2p), + MutableStateFlow(settings.nip05VerificationsViaI2p), + MutableStateFlow(settings.mediaUploadsViaI2p), + ) + } +} From fdd954c2ed1c7792309290eb04b33b4e85973b9a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 22:31:26 +0000 Subject: [PATCH 04/10] feat(privacy): PrivacyRoutingFlow facade reading from live pref flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-side seam that snapshots TorSettingsFlow + I2pSettingsFlow + the preferredClearnetTransport StateFlow into a PrivacySettings, then delegates to PrivacyRouter.route. Gives the I2P daemon manager and the eventual HTTP routing rewrite a single stable API to consult instead of poking every flow themselves. DualHttpClientManager is not touched in this commit — that grows to tri-state in the chunk where the I2P daemon lands a real proxy port. AppModules: - Constructs `privacyRouting` alongside roleBasedHttpClientBuilder - Uses torPrefs.value + i2pPrefs.value + privacyPrefs (added last commit) No callers wired yet — this is the seam those callers will move onto. --- .../com/vitorpamplona/amethyst/AppModules.kt | 6 +++ .../privacyOptions/PrivacyRoutingFlow.kt | 51 +++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/PrivacyRoutingFlow.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 9fdf12fcf..ac50fd793 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -41,6 +41,7 @@ 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 @@ -271,6 +272,11 @@ class AppModules( // Offers easy methods to know when connections are happening through Tor or not val roleBasedHttpClientBuilder = RoleBasedHttpClientBuilder(okHttpClients, torPrefs.value) + // Read-side facade over PrivacyRouter consulted by upcoming HTTP/daemon work. + // 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) + val electrumXClient by lazy { Log.d("AppModules", "ElectrumXClient Init") val client = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/PrivacyRoutingFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/PrivacyRoutingFlow.kt new file mode 100644 index 000000000..7b385a506 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/PrivacyRoutingFlow.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.privacyOptions + +import com.vitorpamplona.amethyst.commons.privacy.FeatureRole +import com.vitorpamplona.amethyst.commons.privacy.PrivacyRoute +import com.vitorpamplona.amethyst.commons.privacy.PrivacyRouter +import com.vitorpamplona.amethyst.commons.privacy.PrivacySettings +import com.vitorpamplona.amethyst.model.preferences.PrivacySharedPreferences +import com.vitorpamplona.amethyst.ui.i2p.I2pSettingsFlow +import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow + +// Read-side facade over PrivacyRouter that snapshot-reads from the three live +// pref flows (Tor, I2P, preferred clearnet transport). Exists so callers don't +// have to know how to assemble a PrivacySettings every time — and so the daemon +// work and HTTP routing rewrite that come next have one stable seam to consult. +class PrivacyRoutingFlow( + val tor: TorSettingsFlow, + val i2p: I2pSettingsFlow, + val privacy: PrivacySharedPreferences, +) { + fun routeFor( + role: FeatureRole, + url: String, + ): PrivacyRoute = PrivacyRouter.route(url, role, snapshot()) + + fun snapshot(): PrivacySettings = + PrivacySettings( + tor = tor.toSettings(), + i2p = i2p.toSettings(), + preferredClearnetTransport = privacy.preferredClearnetTransport.value, + ) +} From a42a156f1331956ab39eac02231479fb744aad9c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 23:35:37 +0000 Subject: [PATCH 05/10] feat(privacy): extend PrivacyOptionsScreen with I2P section and clearnet picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same Privacy Options screen as before, now also surfaces: - A three-way "Privacy Network" picker (Direct / Tor / I2P) for clearnet traffic. Hidden services aren't affected — .onion / .i2p still hard-pin to their matching daemon (and fail closed via PrivacyRouter when it's off). - An I2P engine + per-feature toggle section mirroring the existing Tor section. Only OFF and EXTERNAL are offered in the picker until an embedded daemon ships; persisted INTERNAL is treated as OFF in the UI but kept in the enum so a future commit can light it up without a migration. - Save action posts all three settings — Tor, I2P, preferred transport — to their separate DataStore-backed writers in one click. New UI pieces: - I2pDialogViewModel — mirror of TorDialogViewModel, no preset DSL since there's no canonical I2P configuration yet - I2pSettingsBody — composable mirror of PrivacySettingsBody, shares SwitchSettingsRow with the Tor body - PrivacyTransportPickerViewModel + PreferredTransportPicker — global picker - I2pType.resourceId — Android string-id extension matching TorType.resourceId Strings: i2p_* per-feature toggles, i2p_off/internal/external, i2p_socks_port, privacy_clearnet_transport (+ direct / tor / i2p labels). UI is wired against the live flows from AppModules (torPrefs / i2pPrefs / privacyPrefs from the previous commits). No HTTP routing change — those flows still go through TorManager only; the new toggles only have routing impact once DualHttpClientManager grows tri-state in the daemon chunk. --- .../amethyst/ui/i2p/I2pDialogViewModel.kt | 78 ++++++++ .../amethyst/ui/i2p/I2pSettings.kt | 33 ++++ .../amethyst/ui/i2p/I2pSettingsDialog.kt | 185 ++++++++++++++++++ .../ui/privacy/PrivacyTransportPicker.kt | 82 ++++++++ .../loggedIn/privacy/PrivacyOptionsScreen.kt | 84 +++++--- amethyst/src/main/res/values/strings.xml | 49 +++++ 6 files changed, 489 insertions(+), 22 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pDialogViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettings.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettingsDialog.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/privacy/PrivacyTransportPicker.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pDialogViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pDialogViewModel.kt new file mode 100644 index 000000000..03faa8c4b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pDialogViewModel.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.i2p + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.commons.i2p.I2pSettings +import com.vitorpamplona.amethyst.commons.i2p.I2pType + +@Stable +class I2pDialogViewModel : ViewModel() { + val i2pType = mutableStateOf(I2pType.OFF) + val socksPortStr = mutableStateOf("4447") + + val i2pRelaysViaI2p = mutableStateOf(true) + val dmRelaysViaI2p = mutableStateOf(false) + val newRelaysViaI2p = mutableStateOf(false) + val trustedRelaysViaI2p = mutableStateOf(false) + val urlPreviewsViaI2p = mutableStateOf(false) + val profilePicsViaI2p = mutableStateOf(false) + val imagesViaI2p = mutableStateOf(false) + val videosViaI2p = mutableStateOf(false) + val moneyOperationsViaI2p = mutableStateOf(false) + val nip05VerificationsViaI2p = mutableStateOf(false) + val mediaUploadsViaI2p = mutableStateOf(false) + + fun reset(settings: I2pSettings) { + i2pType.value = settings.i2pType + socksPortStr.value = settings.externalSocksPort.toString() + i2pRelaysViaI2p.value = settings.i2pRelaysViaI2p + dmRelaysViaI2p.value = settings.dmRelaysViaI2p + newRelaysViaI2p.value = settings.newRelaysViaI2p + trustedRelaysViaI2p.value = settings.trustedRelaysViaI2p + urlPreviewsViaI2p.value = settings.urlPreviewsViaI2p + profilePicsViaI2p.value = settings.profilePicsViaI2p + imagesViaI2p.value = settings.imagesViaI2p + videosViaI2p.value = settings.videosViaI2p + moneyOperationsViaI2p.value = settings.moneyOperationsViaI2p + nip05VerificationsViaI2p.value = settings.nip05VerificationsViaI2p + mediaUploadsViaI2p.value = settings.mediaUploadsViaI2p + } + + fun save(): I2pSettings = + I2pSettings( + i2pType = i2pType.value, + externalSocksPort = Integer.parseInt(socksPortStr.value), + i2pRelaysViaI2p = i2pRelaysViaI2p.value, + dmRelaysViaI2p = dmRelaysViaI2p.value, + newRelaysViaI2p = newRelaysViaI2p.value, + trustedRelaysViaI2p = trustedRelaysViaI2p.value, + urlPreviewsViaI2p = urlPreviewsViaI2p.value, + profilePicsViaI2p = profilePicsViaI2p.value, + imagesViaI2p = imagesViaI2p.value, + videosViaI2p = videosViaI2p.value, + moneyOperationsViaI2p = moneyOperationsViaI2p.value, + nip05VerificationsViaI2p = nip05VerificationsViaI2p.value, + mediaUploadsViaI2p = mediaUploadsViaI2p.value, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettings.kt new file mode 100644 index 000000000..b48085585 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettings.kt @@ -0,0 +1,33 @@ +/* + * 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.INTERNAL -> R.string.i2p_internal + I2pType.EXTERNAL -> R.string.i2p_external + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettingsDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettingsDialog.kt new file mode 100644 index 000000000..80a10552d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettingsDialog.kt @@ -0,0 +1,185 @@ +/* + * 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 yet, 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). +// +// INTERNAL is intentionally hidden from the picker until an embedded I2P daemon +// ships. EXTERNAL connects to a user-run i2pd / Java I2P installation on the +// device (default SOCKS port 4447). +@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)), + ), + // OFF screenCode is 0, EXTERNAL is 2. Spinner indices are 0/1. + // Map: dropdown index 0 → OFF, 1 → EXTERNAL. + when (dialogViewModel.i2pType.value) { + I2pType.OFF -> 0 + I2pType.INTERNAL -> 0 // Treat any persisted INTERNAL as OFF until daemon ships. + 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/privacy/PrivacyTransportPicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/privacy/PrivacyTransportPicker.kt new file mode 100644 index 000000000..fc8ea236f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/privacy/PrivacyTransportPicker.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.privacy + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.privacy.PrivacyTransport +import com.vitorpamplona.amethyst.ui.components.TitleExplainer +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import kotlinx.collections.immutable.persistentListOf + +@Stable +class PrivacyTransportPickerViewModel : ViewModel() { + val preferred = mutableStateOf(PrivacyTransport.DIRECT) + + fun reset(transport: PrivacyTransport) { + preferred.value = transport + } + + fun save(): PrivacyTransport = preferred.value +} + +// Single picker for "which transport carries clearnet traffic". Hidden services +// (.onion / .i2p) are NOT affected by this choice — they always route through +// their matching daemon and fail closed when it's off. See PrivacyRouter. +@Composable +fun PreferredTransportPicker(viewModel: PrivacyTransportPickerViewModel) { + Column( + modifier = Modifier.padding(horizontal = 5.dp), + verticalArrangement = Arrangement.spacedBy(Size10dp), + ) { + SettingsRow( + R.string.privacy_clearnet_transport, + R.string.privacy_clearnet_transport_explainer, + persistentListOf( + TitleExplainer(stringRes(R.string.privacy_clearnet_transport_direct)), + TitleExplainer(stringRes(R.string.privacy_clearnet_transport_tor)), + TitleExplainer(stringRes(R.string.privacy_clearnet_transport_i2p)), + ), + when (viewModel.preferred.value) { + PrivacyTransport.DIRECT -> 0 + PrivacyTransport.TOR -> 1 + PrivacyTransport.I2P -> 2 + }, + ) { idx -> + viewModel.preferred.value = + when (idx) { + 1 -> PrivacyTransport.TOR + 2 -> PrivacyTransport.I2P + else -> PrivacyTransport.DIRECT + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt index 306447fc5..f9ab0e5a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt @@ -20,12 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -34,9 +36,16 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.i2p.I2pSettings +import com.vitorpamplona.amethyst.commons.privacy.PrivacyTransport import com.vitorpamplona.amethyst.commons.tor.TorSettings +import com.vitorpamplona.amethyst.ui.i2p.I2pDialogViewModel +import com.vitorpamplona.amethyst.ui.i2p.I2pSettingsBody +import com.vitorpamplona.amethyst.ui.i2p.I2pSettingsFlow import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar +import com.vitorpamplona.amethyst.ui.privacy.PreferredTransportPicker +import com.vitorpamplona.amethyst.ui.privacy.PrivacyTransportPickerViewModel import com.vitorpamplona.amethyst.ui.tor.PrivacySettingsBody import com.vitorpamplona.amethyst.ui.tor.TorDialogViewModel import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow @@ -44,46 +53,69 @@ import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow @OptIn(ExperimentalMaterial3Api::class) @Composable fun PrivacyOptionsScreen(nav: INav) { - PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, nav) + val app = Amethyst.instance + PrivacyOptionsScreen( + torSettingsFlow = app.torPrefs.value, + i2pSettingsFlow = app.i2pPrefs.value, + preferredTransport = app.privacyPrefs.preferredClearnetTransport.value, + onSavePreferred = { app.privacyPrefs.preferredClearnetTransport.value = it }, + nav = nav, + ) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun PrivacyOptionsScreen( torSettingsFlow: TorSettingsFlow, + i2pSettingsFlow: I2pSettingsFlow, + preferredTransport: PrivacyTransport, + onSavePreferred: (PrivacyTransport) -> Unit, nav: INav, ) { - val dialogViewModel = viewModel() + val torVm = viewModel() + val i2pVm = viewModel() + val pickerVm = viewModel() - // runs only once and before the rest of the screen is build - // to avoid blinking and animations from the default/previous - // state to the current state - val init = - remember(dialogViewModel) { - val torSettings = torSettingsFlow.toSettings() - dialogViewModel.reset(torSettings) - torSettings - } + // Reset all three view models from disk-loaded state exactly once, + // mirroring how the old screen reset only the Tor VM. + remember(torVm, i2pVm, pickerVm) { + torVm.reset(torSettingsFlow.toSettings()) + i2pVm.reset(i2pSettingsFlow.toSettings()) + pickerVm.reset(preferredTransport) + Unit + } - PrivacyOptionsScreenContents(dialogViewModel, onPost = torSettingsFlow::update, nav) + PrivacyOptionsScreenContents( + torVm = torVm, + i2pVm = i2pVm, + pickerVm = pickerVm, + onPostTor = torSettingsFlow::update, + onPostI2p = i2pSettingsFlow::update, + onPostPreferred = onSavePreferred, + nav = nav, + ) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun PrivacyOptionsScreenContents( - dialogViewModel: TorDialogViewModel, - onPost: (TorSettings) -> Unit, + torVm: TorDialogViewModel, + i2pVm: I2pDialogViewModel, + pickerVm: PrivacyTransportPickerViewModel, + onPostTor: (TorSettings) -> Unit, + onPostI2p: (I2pSettings) -> Unit, + onPostPreferred: (PrivacyTransport) -> Unit, nav: INav, ) { Scaffold( topBar = { SavingTopBar( titleRes = R.string.privacy_options, - onCancel = { - nav.popBack() - }, + onCancel = { nav.popBack() }, onPost = { - onPost(dialogViewModel.save()) + onPostTor(torVm.save()) + onPostI2p(i2pVm.save()) + onPostPreferred(pickerVm.save()) nav.popBack() }, ) @@ -93,11 +125,19 @@ fun PrivacyOptionsScreenContents( Modifier .padding(it) .fillMaxSize() - .verticalScroll( - rememberScrollState(), - ).padding(horizontal = 10.dp), + .verticalScroll(rememberScrollState()) + .padding(horizontal = 10.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - PrivacySettingsBody(dialogViewModel) + PreferredTransportPicker(pickerVm) + + HorizontalDivider() + + PrivacySettingsBody(torVm) + + HorizontalDivider() + + I2pSettingsBody(i2pVm) } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0438aefc1..099194301 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1097,6 +1097,55 @@ Use Orbot Disconnect Tor/Orbot + + Active I2P Engine + Use an external I2P router (i2pd / Java I2P) + + Off + Internal + 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 From 5da20a9b444d24088fd98d92119ea235645c346b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 00:09:29 +0000 Subject: [PATCH 06/10] feat(privacy): I2pManager + tri-state DualHttpClientManager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I2pManager: EXTERNAL-only mirror of TorManager. When the persisted I2pType is EXTERNAL it emits Active(externalSocksPort); OFF and INTERNAL both surface as Off (no embedded daemon in this branch). Exposes activePortOrNull as a StateFlow with the same shape Tor uses, so the http client managers can keep the proxy-port wiring symmetric. DualHttpClientManager + DualHttpClientManagerForRelays: - New constructor param i2pProxyPortProvider: StateFlow (defaults to a closed null flow so existing tests / callers compile unchanged). - Third StateFlow built against the I2P SOCKS port. - New route-aware methods: getHttpClient(route: PrivacyRoute): OkHttpClient getCurrentProxyPort(route: PrivacyRoute): Int? Direct → no-proxy client, Tor → Tor-proxy client, I2p → I2P-proxy client, Blocked → throws IOException with a user-readable reason so the call fails closed instead of silently leaking through the clearnet. - Existing boolean-based methods (getHttpClient/useProxy) untouched. AppModules wires i2pManager.activePortOrNull into both http managers. No caller switched onto the new methods yet — that's the next chunk (RoleBasedHttpClientBuilder reshape). --- .../com/vitorpamplona/amethyst/AppModules.kt | 7 ++ .../service/okhttp/DualHttpClientManager.kt | 57 ++++++++++++ .../okhttp/DualHttpClientManagerForRelays.kt | 30 +++++++ .../amethyst/ui/i2p/I2pManager.kt | 88 +++++++++++++++++++ 4 files changed, 182 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pManager.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index ac50fd793..7ccb54b7f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.LocalBlossomCacheProbe import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory +import com.vitorpamplona.amethyst.ui.i2p.I2pManager import com.vitorpamplona.amethyst.ui.resourceCacheInit import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.AccountState @@ -218,6 +219,10 @@ class AppModules( val torManager = TorManager(torPrefs, appContext, applicationIOScope) + // I2P manager: EXTERNAL-only (i2pd / Java I2P at the configured SOCKS port). + // No embedded daemon in this branch; INTERNAL is reserved for a follow-up. + val i2pManager = I2pManager(i2pPrefs, applicationIOScope) + // Whenever the underlying network identity changes (wifi↔cellular, regained from // offline, etc.) we clear any active Tor session bypass so the manager re-attempts // bootstrap on the new network. The remembered-approval window is unaffected: if Tor @@ -254,6 +259,7 @@ class AppModules( DualHttpClientManager( userAgent = appAgent, proxyPortProvider = torManager.activePortOrNull, + i2pProxyPortProvider = i2pManager.activePortOrNull, isMobileDataProvider = connManager.isMobileOrNull, keyCache = keyCache, scope = applicationIOScope, @@ -351,6 +357,7 @@ class AppModules( DualHttpClientManagerForRelays( userAgent = appAgent, proxyPortProvider = torManager.activePortOrNull, + i2pProxyPortProvider = i2pManager.activePortOrNull, isMobileDataProvider = connManager.isMobileOrNull, scope = applicationIOScope, dns = surgeDns, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt index 7cb4d3362..7b271de2e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt @@ -20,7 +20,10 @@ */ package com.vitorpamplona.amethyst.service.okhttp +import com.vitorpamplona.amethyst.commons.privacy.BlockReason +import com.vitorpamplona.amethyst.commons.privacy.PrivacyRoute import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -29,6 +32,7 @@ import kotlinx.coroutines.flow.stateIn import okhttp3.Call import okhttp3.OkHttpClient import okhttp3.Request +import java.io.IOException import java.net.InetSocketAddress import java.net.Proxy @@ -39,6 +43,10 @@ class DualHttpClientManager( keyCache: EncryptionKeyCache, scope: CoroutineScope, dns: SurgeDns, + // EXTERNAL-only for now (the i2pd / Java I2P SOCKS port). Null while I2P is + // OFF or not configured; tri-state routing falls back to Direct rather than + // hold a request when the daemon is unavailable on a clearnet feature. + private val i2pProxyPortProvider: StateFlow = MutableStateFlow(null), shouldBridgeBlossomCache: (() -> Boolean)? = null, ) : IHttpClientManager { val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache) @@ -62,8 +70,19 @@ class DualHttpClientManager( factory.buildHttpClient(isMobileDataProvider.value), ) + val i2pHttpClient: StateFlow = + combine(i2pProxyPortProvider, isMobileDataProvider) { proxy, mobile -> + factory.buildHttpClient(proxy, mobile) + }.stateIn( + scope, + SharingStarted.WhileSubscribed(1000), + factory.buildHttpClient(i2pProxyPortProvider.value, isMobileDataProvider.value), + ) + fun getCurrentProxy(): Proxy? = defaultHttpClient.value.proxy + fun getCurrentI2pProxy(): Proxy? = i2pHttpClient.value.proxy + override fun getCurrentProxyPort(useProxy: Boolean): Int? = if (useProxy) { (getCurrentProxy()?.address() as? InetSocketAddress)?.port @@ -78,7 +97,45 @@ 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) + + companion object { + fun blockedException(reason: BlockReason): IOException = + IOException( + 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/DualHttpClientManagerForRelays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManagerForRelays.kt index d190c885d..c125ff496 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManagerForRelays.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManagerForRelays.kt @@ -20,7 +20,9 @@ */ package com.vitorpamplona.amethyst.service.okhttp +import com.vitorpamplona.amethyst.commons.privacy.PrivacyRoute import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -36,6 +38,7 @@ class DualHttpClientManagerForRelays( isMobileDataProvider: StateFlow, scope: CoroutineScope, dns: SurgeDns, + private val i2pProxyPortProvider: StateFlow = MutableStateFlow(null), ) : IHttpClientManager { val factory = OkHttpClientFactoryForRelays(userAgent, dns) @@ -58,8 +61,19 @@ class DualHttpClientManagerForRelays( factory.buildHttpClient(isMobileDataProvider.value), ) + val i2pHttpClient: StateFlow = + combine(i2pProxyPortProvider, isMobileDataProvider) { proxy, mobile -> + factory.buildHttpClient(proxy, mobile) + }.stateIn( + scope, + SharingStarted.WhileSubscribed(1000), + factory.buildHttpClient(i2pProxyPortProvider.value, isMobileDataProvider.value), + ) + fun getCurrentProxy(): Proxy? = defaultHttpClient.value.proxy + fun getCurrentI2pProxy(): Proxy? = i2pHttpClient.value.proxy + override fun getCurrentProxyPort(useProxy: Boolean): Int? = if (useProxy) { (getCurrentProxy()?.address() as? InetSocketAddress)?.port @@ -73,4 +87,20 @@ class DualHttpClientManagerForRelays( } else { defaultHttpClientWithoutProxy.value } + + fun getHttpClient(route: PrivacyRoute): OkHttpClient = + when (route) { + PrivacyRoute.Direct -> defaultHttpClientWithoutProxy.value + PrivacyRoute.Tor -> defaultHttpClient.value + PrivacyRoute.I2p -> i2pHttpClient.value + is PrivacyRoute.Blocked -> throw DualHttpClientManager.blockedException(route.reason) + } + + fun getCurrentProxyPort(route: PrivacyRoute): Int? = + when (route) { + PrivacyRoute.Direct -> null + PrivacyRoute.Tor -> (getCurrentProxy()?.address() as? InetSocketAddress)?.port + PrivacyRoute.I2p -> (getCurrentI2pProxy()?.address() as? InetSocketAddress)?.port + is PrivacyRoute.Blocked -> throw DualHttpClientManager.blockedException(route.reason) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pManager.kt new file mode 100644 index 000000000..d4c68a622 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pManager.kt @@ -0,0 +1,88 @@ +/* + * 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 + +/** + * Mirror of TorManager, but EXTERNAL-only for now — no embedded I2P daemon ships + * with this branch. When [I2pType.EXTERNAL] is selected, this manager surfaces the + * configured SOCKS port; otherwise it emits [I2pServiceStatus.Off]. + * + * There is intentionally no Connecting state, no bootstrap timeout, and no + * "session bypass": with EXTERNAL the daemon's lifecycle is the user's + * responsibility (i2pd / Java I2P running on the device), and the connection + * either works or it doesn't — there's no in-app bootstrap to bypass. + * + * When INTERNAL is wired up in a follow-up, it will land here as a third branch + * that starts an embedded daemon and emits Connecting / Active over its + * lifecycle, mirroring the Tor flow. + */ +class I2pManager( + i2pPrefs: I2pSharedPreferences, + scope: CoroutineScope, +) { + val status: StateFlow = + combine( + i2pPrefs.value.i2pType, + i2pPrefs.value.externalSocksPort, + ) { type, port -> + when (type) { + I2pType.OFF -> I2pServiceStatus.Off + // Persisted INTERNAL is treated as Off until an embedded daemon ships. + I2pType.INTERNAL -> 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 +} From 2a9514d657d29b199b7ba6503bd454281ee9c697 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 00:11:23 +0000 Subject: [PATCH 07/10] refactor(privacy): RoleBasedHttpClientBuilder routes via PrivacyRouter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All ad-hoc HTTP traffic now consults PrivacyRoutingFlow → PrivacyRouter: images, videos, URL previews, NIP-05 lookups, Money operations, uploads, push registration. Each call computes a PrivacyRoute (Direct / Tor / I2p / Blocked) and asks DualHttpClientManager for the matching OkHttpClient, which in turn picks the right SOCKS-attached client or — for Blocked hidden-service routes — throws IOException so the request fails closed instead of silently leaking to the clearnet. Constructor switched from TorSettingsFlow to PrivacyRoutingFlow. AppModules swaps the wiring; the flow construction order now puts privacyRouting before the builder. Legacy shouldUseTorFor* booleans are kept but now return true iff the route would actually end up on Tor — they no longer drive routing themselves. External method-references in AppModules (OtsResolver, etc.) keep working with the same semantics they had before for clearnet calls. Push registration still routes through the URL_PREVIEW role (closest analogue to the old trustedRelaysViaTor global flag, which had no URL to consult). That's fine for now — trusted-relay push services live on the clearnet and hard-pin behavior is unchanged for hidden-service push URLs. --- .../com/vitorpamplona/amethyst/AppModules.kt | 14 +- .../RoleBasedHttpClientBuilder.kt | 153 ++++++------------ 2 files changed, 54 insertions(+), 113 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 7ccb54b7f..221f6858c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -275,14 +275,16 @@ class AppModules( }, ) - // Offers easy methods to know when connections are happening through Tor or not - val roleBasedHttpClientBuilder = RoleBasedHttpClientBuilder(okHttpClients, torPrefs.value) - - // Read-side facade over PrivacyRouter consulted by upcoming HTTP/daemon work. - // Routes hidden services strictly to their matching daemon (Blocked when off) - // and clearnet to whichever transport the user picked as preferred. + // Read-side facade over PrivacyRouter. Routes hidden services strictly to their + // matching daemon (Blocked when off) and clearnet to whichever transport the + // user picked as preferred. val privacyRouting = PrivacyRoutingFlow(torPrefs.value, i2pPrefs.value, privacyPrefs) + // Resolves a transport per ad-hoc HTTP call (images, NIP-05, etc.) and hands + // back the matching OkHttpClient from okHttpClients. Hidden-service URLs fail + // closed via DualHttpClientManager.blockedException when their daemon is off. + val roleBasedHttpClientBuilder = RoleBasedHttpClientBuilder(okHttpClients, privacyRouting) + val electrumXClient by lazy { Log.d("AppModules", "ElectrumXClient Init") val client = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt index a332044be..70bd026bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt @@ -20,142 +20,81 @@ */ package com.vitorpamplona.amethyst.model.privacyOptions -import com.vitorpamplona.amethyst.commons.tor.TorType +import com.vitorpamplona.amethyst.commons.privacy.FeatureRole +import com.vitorpamplona.amethyst.commons.privacy.PrivacyRoute import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager -import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import okhttp3.OkHttpClient import java.net.InetSocketAddress import java.net.Proxy import javax.net.SocketFactory +/** + * Role-based router for ad-hoc HTTP traffic (images, videos, NIP-05, etc.). + * + * Delegates to [PrivacyRoutingFlow] for the per-call decision and to + * [DualHttpClientManager] for the actual OkHttpClient with the right SOCKS + * proxy attached. Hidden-service hostnames hard-pin to their matching + * daemon — [DualHttpClientManager.getHttpClient] throws on `Blocked` rather + * than silently leak to the clearnet. + * + * Legacy `shouldUseTorForX` methods remain, returning `true` iff the route + * would end up on Tor (call-site references in AppModules still depend on + * them as method-references); they no longer drive routing themselves. + */ class RoleBasedHttpClientBuilder( val okHttpClient: DualHttpClientManager, - val torSettings: TorSettingsFlow, + val routing: PrivacyRoutingFlow, ) : IRoleBasedHttpClientBuilder { - fun shouldUseTorForImageDownload(url: String) = - shouldUseTorFor( - url, - torSettings.torType.value, - torSettings.imagesViaTor.value, - ) - - fun shouldUseTorFor( + private fun routeFor( + role: FeatureRole, url: String, - torType: TorType, - imagesViaTor: Boolean, - ) = when (torType) { - TorType.OFF -> false - TorType.INTERNAL -> shouldUseTor(url, imagesViaTor) - TorType.EXTERNAL -> shouldUseTor(url, imagesViaTor) - } + ): PrivacyRoute = routing.routeFor(role, url) - private fun shouldUseTor( - normalizedUrl: String, - final: Boolean, - ): Boolean = - if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) { - false - } else if (RelayUrlNormalizer.isOnion(normalizedUrl)) { - true - } else { - final - } + override fun proxyPortForVideo(url: String): Int? = okHttpClient.getCurrentProxyPort(routeFor(FeatureRole.VIDEO, url)) - fun shouldUseTorForVideoDownload() = - when (torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> torSettings.videosViaTor.value - TorType.EXTERNAL -> torSettings.videosViaTor.value - } + override fun okHttpClientForNip05(url: String): OkHttpClient = okHttpClient.getHttpClient(routeFor(FeatureRole.NIP05, url)) - fun shouldUseTorForVideoDownload(url: String) = - when (torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> checkLocalHostOnionAndThen(url, torSettings.videosViaTor.value) - TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, torSettings.videosViaTor.value) - } + override fun okHttpClientForUploads(url: String): OkHttpClient = okHttpClient.getHttpClient(routeFor(FeatureRole.UPLOAD, url)) - fun shouldUseTorForPreviewUrl(url: String) = - when (torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> checkLocalHostOnionAndThen(url, torSettings.urlPreviewsViaTor.value) - TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, torSettings.urlPreviewsViaTor.value) - } + override fun okHttpClientForImage(url: String): OkHttpClient = okHttpClient.getHttpClient(routeFor(FeatureRole.IMAGE, url)) - fun shouldUseTorForTrustedRelays() = - when (torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> torSettings.trustedRelaysViaTor.value - TorType.EXTERNAL -> torSettings.trustedRelaysViaTor.value - } + override fun okHttpClientForVideo(url: String): OkHttpClient = okHttpClient.getHttpClient(routeFor(FeatureRole.VIDEO, url)) - private fun checkLocalHostOnionAndThen( - url: String, - final: Boolean, - ): Boolean = checkLocalHostOnionAndThen(url, torSettings.onionRelaysViaTor.value, final) + override fun okHttpClientForMoney(url: String): OkHttpClient = okHttpClient.getHttpClient(routeFor(FeatureRole.MONEY, url)) - private fun checkLocalHostOnionAndThen( - normalizedUrl: String, - isOnionRelaysActive: Boolean, - final: Boolean, - ): Boolean = - if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) { - false - } else if (RelayUrlNormalizer.isOnion(normalizedUrl)) { - isOnionRelaysActive - } else { - final - } + override fun okHttpClientForPreview(url: String): OkHttpClient = okHttpClient.getHttpClient(routeFor(FeatureRole.URL_PREVIEW, url)) - fun shouldUseTorForMoneyOperations(url: String) = - when (torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> checkLocalHostOnionAndThen(url, torSettings.moneyOperationsViaTor.value) - TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, torSettings.moneyOperationsViaTor.value) - } + // Push registration goes to a trusted relay. Reuse the URL_PREVIEW role for now — + // the legacy code used `shouldUseTorForTrustedRelays()` which was a no-URL + // global flag; under the new model we need a URL to compute a route, so the + // closest analogue is "treat it like any other clearnet ad-hoc HTTP call". + override fun okHttpClientForPushRegistration(url: String): OkHttpClient = okHttpClient.getHttpClient(routeFor(FeatureRole.URL_PREVIEW, url)) - fun shouldUseTorForNIP05(url: String) = - when (torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> checkLocalHostOnionAndThen(url, torSettings.nip05VerificationsViaTor.value) - TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, torSettings.nip05VerificationsViaTor.value) - } + fun shouldUseTorForImageDownload(url: String) = routeFor(FeatureRole.IMAGE, url) is PrivacyRoute.Tor - fun shouldUseTorForUploads(url: String) = - when (torSettings.torType.value) { - TorType.OFF -> false - TorType.INTERNAL -> checkLocalHostOnionAndThen(url, torSettings.mediaUploadsViaTor.value) - TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, torSettings.mediaUploadsViaTor.value) - } + fun shouldUseTorForVideoDownload(url: String) = routeFor(FeatureRole.VIDEO, url) is PrivacyRoute.Tor - override fun proxyPortForVideo(url: String): Int? = okHttpClient.getCurrentProxyPort(shouldUseTorForVideoDownload(url)) + fun shouldUseTorForPreviewUrl(url: String) = routeFor(FeatureRole.URL_PREVIEW, url) is PrivacyRoute.Tor - override fun okHttpClientForNip05(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForNIP05(url)) + fun shouldUseTorForMoneyOperations(url: String) = routeFor(FeatureRole.MONEY, url) is PrivacyRoute.Tor - override fun okHttpClientForUploads(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForUploads(url)) + fun shouldUseTorForNIP05(url: String) = routeFor(FeatureRole.NIP05, url) is PrivacyRoute.Tor - override fun okHttpClientForImage(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForImageDownload(url)) - - override fun okHttpClientForVideo(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForVideoDownload(url)) - - override fun okHttpClientForMoney(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForMoneyOperations(url)) - - override fun okHttpClientForPreview(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForPreviewUrl(url)) - - override fun okHttpClientForPushRegistration(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForTrustedRelays()) + fun shouldUseTorForUploads(url: String) = routeFor(FeatureRole.UPLOAD, url) is PrivacyRoute.Tor /** - * Returns a [SocketFactory] that routes through the user's Tor proxy - * when NIP-05 verification traffic should use Tor. + * Returns a [SocketFactory] that routes through the user's Tor proxy when + * NIP-05 verification traffic should use Tor. * - * Used by [ElectrumxClient] so that Namecoin lookups respect the - * same proxy/Tor settings as HTTP-based NIP-05 verification, - * preventing IP leaks through direct socket connections. + * Used by ElectrumXClient so that Namecoin lookups respect the same proxy + * settings as HTTP-based NIP-05 verification, preventing IP leaks through + * direct socket connections. + * + * I2P-routed NIP-05 lookups are NOT bridged here yet — ElectrumX over I2P + * needs a SAM/streaming endpoint, not a plain SOCKS hop, and Namecoin name + * servers aren't deployed on I2P in practice. Falls through to default. */ fun socketFactoryForNip05(): SocketFactory { - // ElectrumX servers are always external, so we use a dummy - // non-localhost, non-onion URL to query the Tor policy. val useTor = shouldUseTorForNIP05("https://electrumx.example.com") if (!useTor) return SocketFactory.getDefault() From 19c750dede04c552e85a7a74525cea0d284ae024 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 00:12:55 +0000 Subject: [PATCH 08/10] =?UTF-8?q?feat(privacy):=20BlockedRouteException=20?= =?UTF-8?q?=E2=80=94=20typed=20fail-closed=20signal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the inline IOException thrown by DualHttpClientManager.getHttpClient when a hidden-service URL is requested while its matching daemon is off. The new exception extends IOException so existing HTTP error paths (Coil, OkHttp call factories, etc.) propagate it unchanged, but carries the BlockReason so future UI work can surface a clear "Enable Tor / I2P to view this content" hint instead of just a broken-image placeholder. DualHttpClientManager.blockedException(...) now returns BlockedRouteException rather than a bare IOException; the messages move into the typed exception's companion. Tests cover the message wording (mentions Tor/.onion and I2P/.i2p) and pin the factory's return type so the typed information can't be silently widened back to a bare IOException without breaking the test. Surfacing this to the UI (snackbar on image-load fail, etc.) is left to a follow-up — that work is cross-cutting (Coil event listeners, NIP-05 error paths, etc.) and orthogonal to the routing decision itself. --- .../service/okhttp/BlockedRouteException.kt | 48 +++++++++++++++++ .../service/okhttp/DualHttpClientManager.kt | 11 +--- .../okhttp/BlockedRouteExceptionTest.kt | 52 +++++++++++++++++++ 3 files changed, 101 insertions(+), 10 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/BlockedRouteException.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlockedRouteExceptionTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/BlockedRouteException.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/BlockedRouteException.kt new file mode 100644 index 000000000..7c3fc9cb6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/BlockedRouteException.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.okhttp + +import com.vitorpamplona.amethyst.commons.privacy.BlockReason +import java.io.IOException + +/** + * Thrown when a request targets a hidden-service hostname whose matching + * privacy daemon is disabled. The fail-closed semantics chosen by the user — + * we never silently fall back to a clearnet route for `.onion` / `.i2p` URLs. + * + * Subclass of [IOException] so the existing HTTP error paths in Coil, OkHttp + * call factories, etc. propagate it without changes. Callers that want to + * present a clearer UI ("enable Tor/I2P to view this content") can + * pattern-match on the type / reason instead of inspecting the message. + */ +class BlockedRouteException( + val reason: BlockReason, +) : IOException(messageFor(reason)) { + companion object { + fun messageFor(reason: BlockReason): String = + when (reason) { + BlockReason.ONION_REQUIRES_TOR -> + "Cannot reach .onion address: Tor is disabled. Enable Tor in Privacy Options." + BlockReason.I2P_REQUIRES_I2P -> + "Cannot reach .i2p address: I2P is disabled. Enable I2P in Privacy Options." + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt index 7b271de2e..0b5449001 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 @@ -32,7 +32,6 @@ import kotlinx.coroutines.flow.stateIn import okhttp3.Call import okhttp3.OkHttpClient import okhttp3.Request -import java.io.IOException import java.net.InetSocketAddress import java.net.Proxy @@ -126,15 +125,7 @@ class DualHttpClientManager( fun getDynamicCallFactory(useProxy: Boolean) = DynamicCallFactory(useProxy, this) companion object { - fun blockedException(reason: BlockReason): IOException = - IOException( - 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." - }, - ) + fun blockedException(reason: BlockReason): BlockedRouteException = BlockedRouteException(reason) } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlockedRouteExceptionTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlockedRouteExceptionTest.kt new file mode 100644 index 000000000..60ea602b4 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/BlockedRouteExceptionTest.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.okhttp + +import com.vitorpamplona.amethyst.commons.privacy.BlockReason +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class BlockedRouteExceptionTest { + @Test + fun onionMessage_mentionsTorAndOnion() { + val e = BlockedRouteException(BlockReason.ONION_REQUIRES_TOR) + assertSame(BlockReason.ONION_REQUIRES_TOR, e.reason) + assertTrue("message should mention Tor: ${e.message}", e.message!!.contains("Tor", ignoreCase = true)) + assertTrue("message should mention .onion: ${e.message}", e.message!!.contains(".onion")) + } + + @Test + fun i2pMessage_mentionsI2pAndDotI2p() { + val e = BlockedRouteException(BlockReason.I2P_REQUIRES_I2P) + assertSame(BlockReason.I2P_REQUIRES_I2P, e.reason) + assertTrue("message should mention I2P: ${e.message}", e.message!!.contains("I2P")) + assertTrue("message should mention .i2p: ${e.message}", e.message!!.contains(".i2p")) + } + + @Test + fun dualHttpClientManagerCompanion_returnsBlockedRouteException_withReason() { + // Pins the factory return type so callers can pattern-match without losing the reason. + val e: BlockedRouteException = DualHttpClientManager.blockedException(BlockReason.ONION_REQUIRES_TOR) + assertEquals(BlockReason.ONION_REQUIRES_TOR, e.reason) + } +} From 4e796c8e8b79126982a9271f5486d1e8b1950dce Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 00:16:13 +0000 Subject: [PATCH 09/10] feat(privacy): plug remaining HTTP paths into the route-aware stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three callers were still bypassing PrivacyRouter and would route Tor-only or direct-only regardless of the picker: - Coil ImageLoader (AppModules): called okHttpClients.getHttpClient(shouldUseTorForImageDownload(it)), which collapses to a boolean and can't pick I2P. Now uses roleBasedHttpClientBuilder.okHttpClientForImage(it) so images route through the user's preferred clearnet transport and hidden-service hostnames hard-pin. - ExoPlayer pool (PlaybackService): had separate poolNoProxy / poolWithProxy named fields and bound them to getDynamicCallFactory(useProxy: Boolean) — i.e. with-proxy was always the Tor-proxied client. Reshaped to a poolsByPort: Map keyed by the SOCKS port returned by proxyPortForVideo(url). DualHttpClientManager gains getHttpClientForPort and PortBasedCallFactory to resolve the right OkHttpClient (direct / Tor / I2P) live based on port. - Nostr relay websocket builder (AppModules): only consulted torEvaluator, which has no notion of .i2p. Now hard-pins .onion to Tor and .i2p to I2P (throwing BlockedRouteException via the fail-closed contract when the matching daemon isn't ready) and falls through to torEvaluator for clearnet relays — the existing TorRelayEvaluation per-relay-class booleans stay as-is for clearnet routing. --- .../com/vitorpamplona/amethyst/AppModules.kt | 36 +++++++++-- .../service/okhttp/DualHttpClientManager.kt | 33 ++++++++++ .../playback/service/PlaybackService.kt | 63 +++++++------------ 3 files changed, 88 insertions(+), 44 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 221f6858c..e34ebcf34 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -26,6 +26,8 @@ import coil3.disk.DiskCache import coil3.memory.MemoryCache import com.vitorpamplona.amethyst.commons.i2p.I2pSettings import com.vitorpamplona.amethyst.commons.model.NoteState +import com.vitorpamplona.amethyst.commons.privacy.BlockReason +import com.vitorpamplona.amethyst.commons.privacy.PrivacyRoute import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash import com.vitorpamplona.amethyst.commons.tor.TorSettings import com.vitorpamplona.amethyst.model.Account @@ -58,6 +60,7 @@ import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.notifications.AlwaysOnNotificationServiceManager import com.vitorpamplona.amethyst.service.notifications.NotificationDispatcher import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver +import com.vitorpamplona.amethyst.service.okhttp.BlockedRouteException import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache @@ -95,6 +98,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.HiddenServiceKind +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.classifyHidden import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache import com.vitorpamplona.quartz.nip03Timestamp.ots.OtsBlockHeightCache import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client @@ -365,11 +370,34 @@ class AppModules( dns = surgeDns, ) - // Connects the INostrClient class with okHttp + // Connects the INostrClient class with okHttp. + // + // Hidden-service hostnames (.onion / .i2p) hard-pin to their matching + // network — same fail-closed contract PrivacyRouter applies to ad-hoc HTTP + // traffic. Clearnet relays still go through the existing TorRelayEvaluation + // (its per-relay-class booleans are richer than the global picker for the + // relay case, and don't conflict with the picker since the picker is about + // clearnet ad-hoc HTTP traffic, not the relay subscription pool). val websocketBuilder = OkHttpWebSocket.Builder { url -> - val useTor = torEvaluatorFlow.flow.value.useTor(url) - okHttpClientForRelays.getHttpClient(useTor) + when (url.classifyHidden()) { + HiddenServiceKind.ONION -> + if (torManager.isSocksReady()) { + okHttpClientForRelays.getHttpClient(true) + } else { + throw BlockedRouteException(BlockReason.ONION_REQUIRES_TOR) + } + HiddenServiceKind.I2P -> + if (i2pManager.isSocksReady()) { + okHttpClientForRelays.getHttpClient(PrivacyRoute.I2p) + } else { + throw BlockedRouteException(BlockReason.I2P_REQUIRES_I2P) + } + HiddenServiceKind.LOCALHOST, HiddenServiceKind.CLEARNET -> { + val useTor = torEvaluatorFlow.flow.value.useTor(url) + okHttpClientForRelays.getHttpClient(useTor) + } + } } // Caches all events in Memory @@ -588,7 +616,7 @@ class AppModules( diskCache = { diskCache }, memoryCache = { memoryCache }, blossomServerResolver = { blossomResolver }, - callFactory = { okHttpClients.getHttpClient(roleBasedHttpClientBuilder.shouldUseTorForImageDownload(it)) }, + callFactory = { roleBasedHttpClientBuilder.okHttpClientForImage(it) }, thumbnailCache = thumbnailDiskCache, backgroundScope = applicationIOScope, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt index 0b5449001..9576236d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt @@ -124,6 +124,26 @@ class DualHttpClientManager( fun getDynamicCallFactory(useProxy: Boolean) = DynamicCallFactory(useProxy, this) + /** + * Resolves to the OkHttpClient whose attached SOCKS proxy matches [port]. Used + * by ExoPlayer's per-port pool to pick the correct transport — `0` / `null` + * means direct, the live Tor port maps to the Tor-proxied client, the live I2P + * port maps to the I2P-proxied client. Unknown non-zero ports fall back to + * direct rather than guess. + */ + fun getHttpClientForPort(port: Int?): OkHttpClient { + if (port == null || port <= 0) return defaultHttpClientWithoutProxy.value + val torPort = (defaultHttpClient.value.proxy?.address() as? InetSocketAddress)?.port + val i2pPort = (i2pHttpClient.value.proxy?.address() as? InetSocketAddress)?.port + return when (port) { + torPort -> defaultHttpClient.value + i2pPort -> i2pHttpClient.value + else -> defaultHttpClientWithoutProxy.value + } + } + + fun getDynamicCallFactoryForPort(port: Int) = PortBasedCallFactory(port, this) + companion object { fun blockedException(reason: BlockReason): BlockedRouteException = BlockedRouteException(reason) } @@ -138,3 +158,16 @@ class DynamicCallFactory( ) : Call.Factory { override fun newCall(request: Request): Call = manager.getHttpClient(useProxy).newCall(request) } + +/** + * Port-keyed version of [DynamicCallFactory]. Lets ExoPlayer's per-port pool + * route through Tor or I2P (or direct) based on the SOCKS port the caller asked + * for — resolved live so a proxy-port change without a pool rebuild still picks + * the right OkHttpClient. + */ +class PortBasedCallFactory( + val port: Int, + val manager: DualHttpClientManager, +) : Call.Factory { + override fun newCall(request: Request): Call = manager.getHttpClientForPort(port).newCall(request) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt index 0c636f8a9..b73e7afb8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt @@ -34,7 +34,6 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.service.okhttp.DynamicCallFactory import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerBuilder @@ -44,15 +43,19 @@ import com.vitorpamplona.amethyst.service.playback.playerPool.SimultaneousPlayba import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.runBlocking +import okhttp3.Call class PlaybackService : MediaSessionService() { - private var poolNoProxy: MediaSessionPool? = null - private var poolWithProxy: MediaSessionPool? = null + // One pool per distinct SOCKS port. With three privacy transports we have at + // most three pools (direct/0, Tor port, I2P port). Each pool's call factory is + // bound to its port so a transport switch via the privacy picker just creates + // a new pool instead of misrouting traffic through an old one. + private val poolsByPort = mutableMapOf() @OptIn(UnstableApi::class) fun newPool( videoCache: VideoCache, - okHttpClient: DynamicCallFactory, + okHttpClient: Call.Factory, blossomServerResolver: BlossomServerResolver, ): MediaSessionPool { val dataSourceFactory = OkHttpDataSource.Factory(okHttpClient) @@ -96,39 +99,20 @@ class PlaybackService : MediaSessionService() { @OptIn(UnstableApi::class) fun lazyPool(proxyPort: Int): MediaSessionPool { - return if (proxyPort <= 0) { - // no proxy - poolNoProxy?.let { return it } + // Normalize all "no proxy" intents onto port 0 so direct callers share a pool. + val key = if (proxyPort < 0) 0 else proxyPort + poolsByPort[key]?.let { return it } - val okHttpClient = Amethyst.instance.okHttpClients.getDynamicCallFactory(false) - val videoCache = Amethyst.instance.videoCache - val blossomServerResolver = Amethyst.instance.blossomResolver + val okHttpClient = Amethyst.instance.okHttpClients.getDynamicCallFactoryForPort(key) + val videoCache = Amethyst.instance.videoCache + val blossomServerResolver = Amethyst.instance.blossomResolver - // creates new - newPool(videoCache, okHttpClient, blossomServerResolver) - .also { - poolNoProxy = it - // Kick off the player pool warmup as soon as we know this pool is being used. - // It runs async on the main looper, yielding between builds, so the very first - // session still acquires synchronously while subsequent ones can grab a warm - // ExoPlayer instead of paying the build cost on the main thread. - it.exoPlayerPool.create(applicationContext) - } - } else { - poolWithProxy?.let { return it } - - // creates brand new - // proxy port can change without affecting the pool because - // the choice of okhttp is resolved in newCall - val okHttpClient = Amethyst.instance.okHttpClients.getDynamicCallFactory(true) - val videoCache = Amethyst.instance.videoCache - val blossomServerResolver = Amethyst.instance.blossomResolver - - newPool(videoCache, okHttpClient, blossomServerResolver) - .also { - poolWithProxy = it - it.exoPlayerPool.create(applicationContext) - } + return newPool(videoCache, okHttpClient, blossomServerResolver).also { + poolsByPort[key] = it + // Warm up the ExoPlayer pool for the first use of this transport so the very + // first session acquires synchronously while subsequent ones grab a warm + // ExoPlayer instead of paying the build cost on the main thread. + it.exoPlayerPool.create(applicationContext) } } @@ -140,8 +124,8 @@ class PlaybackService : MediaSessionService() { override fun onDestroy() { Log.d("PlaybackService", "PlaybackService.onDestroy") - poolWithProxy?.destroy() - poolNoProxy?.destroy() + poolsByPort.values.forEach { it.destroy() } + poolsByPort.clear() super.onDestroy() } @@ -161,12 +145,11 @@ class PlaybackService : MediaSessionService() { // 2. b. On screen video with volume on // 2. c. On screen video with volume off. - val playing = (poolWithProxy?.playingContent() ?: emptyList()) + (poolNoProxy?.playingContent() ?: emptyList()) + val playing = poolsByPort.values.flatMap { it.playingContent() } - // if nothing is pl if (playing.isEmpty() && BackgroundMedia.hasInstance()) { BackgroundMedia.bgInstance?.id?.let { id -> - (poolNoProxy?.getSession(id) ?: poolWithProxy?.getSession(id))?.let { + poolsByPort.values.firstNotNullOfOrNull { it.getSession(id) }?.let { super.onUpdateNotification(it, startInForegroundRequired) } } From 40995b9670f1628a44398f78e392ee110d70f531 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 20:37:53 +0000 Subject: [PATCH 10/10] =?UTF-8?q?refactor(privacy):=20drop=20I2pType.INTER?= =?UTF-8?q?NAL=20=E2=80=94=20EXTERNAL=20is=20the=20permanent=20answer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earlier commits kept I2pType.INTERNAL as a placeholder for a follow-up embedded daemon. We're not shipping that: I2P bootstrap on Android is structurally minutes-long (no equivalent of Tor's hardcoded directory authorities — NetDB peer discovery is protocol-inherent), so an embedded router would mean a permanently-warming UX. Users who want I2P run i2pd / Java I2P independently and point Amethyst at its SOCKS port. Changes: - commons I2pType drops the INTERNAL variant; parseI2pType collapses unknown codes to OFF - I2pManager loses its INTERNAL switch arm - I2pSettingsDialog drops the "treat INTERNAL as OFF" mapping it used to carry — the enum no longer has the case - Android UI I2pSettings.resourceId drops the i2p_internal branch - strings.xml drops the now-unused i2p_internal string - I2pSharedPreferences hardens load: a stored "INTERNAL" from an earlier branch is no longer a valid I2pType, so runCatching swallows the IllegalArgumentException and the user lands on OFF - PrivacyRouterTest fixtures use I2pType.EXTERNAL throughout --- .../model/preferences/I2pSharedPreferences.kt | 8 +++++++- .../amethyst/ui/i2p/I2pManager.kt | 20 ++++++++----------- .../amethyst/ui/i2p/I2pSettings.kt | 1 - .../amethyst/ui/i2p/I2pSettingsDialog.kt | 11 ++++------ amethyst/src/main/res/values/strings.xml | 1 - .../amethyst/commons/i2p/I2pSettings.kt | 7 +++++-- .../commons/privacy/PrivacyRouterTest.kt | 10 +++++----- 7 files changed, 29 insertions(+), 29 deletions(-) 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 index ccaf45356..8d0a082f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/I2pSharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/I2pSharedPreferences.kt @@ -83,7 +83,13 @@ class I2pSharedPreferences( try { val preferences = context.sharedPreferencesDataStore.data.first() I2pSettings( - i2pType = preferences[I2P_TYPE_KEY]?.let { I2pType.valueOf(it) } ?: I2pType.OFF, + // 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, 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 index d4c68a622..4735d2254 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pManager.kt @@ -35,18 +35,16 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn /** - * Mirror of TorManager, but EXTERNAL-only for now — no embedded I2P daemon ships - * with this branch. When [I2pType.EXTERNAL] is selected, this manager surfaces the - * configured SOCKS port; otherwise it emits [I2pServiceStatus.Off]. + * 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": with EXTERNAL the daemon's lifecycle is the user's - * responsibility (i2pd / Java I2P running on the device), and the connection - * either works or it doesn't — there's no in-app bootstrap to bypass. - * - * When INTERNAL is wired up in a follow-up, it will land here as a third branch - * that starts an embedded daemon and emits Connecting / Active over its - * lifecycle, mirroring the Tor flow. + * "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, @@ -59,8 +57,6 @@ class I2pManager( ) { type, port -> when (type) { I2pType.OFF -> I2pServiceStatus.Off - // Persisted INTERNAL is treated as Off until an embedded daemon ships. - I2pType.INTERNAL -> I2pServiceStatus.Off I2pType.EXTERNAL -> if (port > 0) I2pServiceStatus.Active(port) else I2pServiceStatus.Off } }.catch { e -> 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 index b48085585..7497a8008 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettings.kt @@ -28,6 +28,5 @@ val I2pType.resourceId: Int get() = when (this) { I2pType.OFF -> R.string.i2p_off - I2pType.INTERNAL -> R.string.i2p_internal 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 index 80a10552d..e608e1be6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettingsDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/i2p/I2pSettingsDialog.kt @@ -49,13 +49,13 @@ 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 yet, and the per-feature toggles +// 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). // -// INTERNAL is intentionally hidden from the picker until an embedded I2P daemon -// ships. EXTERNAL connects to a user-run i2pd / Java I2P installation on the -// device (default SOCKS port 4447). +// 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( @@ -69,11 +69,8 @@ fun I2pSettingsBody(dialogViewModel: I2pDialogViewModel) { TitleExplainer(stringRes(I2pType.OFF.resourceId)), TitleExplainer(stringRes(I2pType.EXTERNAL.resourceId)), ), - // OFF screenCode is 0, EXTERNAL is 2. Spinner indices are 0/1. - // Map: dropdown index 0 → OFF, 1 → EXTERNAL. when (dialogViewModel.i2pType.value) { I2pType.OFF -> 0 - I2pType.INTERNAL -> 0 // Treat any persisted INTERNAL as OFF until daemon ships. I2pType.EXTERNAL -> 1 }, ) { idx -> diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 099194301..cc56b7301 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1103,7 +1103,6 @@ Use an external I2P router (i2pd / Java I2P) Off - Internal External Router I2P Socks Port 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 index f0efd6ea9..cd27ba36d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pSettings.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/i2p/I2pSettings.kt @@ -41,17 +41,20 @@ data class I2pSettings( 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), - INTERNAL(1), EXTERNAL(2), } fun parseI2pType(code: Int?): I2pType = when (code) { - I2pType.INTERNAL.screenCode -> I2pType.INTERNAL I2pType.EXTERNAL.screenCode -> I2pType.EXTERNAL else -> I2pType.OFF } 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 index 22e1b713c..5a7522962 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouterTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacy/PrivacyRouterTest.kt @@ -38,7 +38,7 @@ class PrivacyRouterTest { 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.INTERNAL else I2pType.OFF), + i2p: I2pSettings = I2pSettings(i2pType = if (i2pOn) I2pType.EXTERNAL else I2pType.OFF), ) = PrivacySettings( tor = tor, i2p = i2p, @@ -125,7 +125,7 @@ class PrivacyRouterTest { settings( i2pOn = true, preferred = PrivacyTransport.I2P, - i2p = I2pSettings(i2pType = I2pType.INTERNAL, videosViaI2p = true), + i2p = I2pSettings(i2pType = I2pType.EXTERNAL, videosViaI2p = true), ) assertEquals(PrivacyRoute.I2p, PrivacyRouter.route(clearnet, FeatureRole.VIDEO, s)) } @@ -136,7 +136,7 @@ class PrivacyRouterTest { settings( i2pOn = true, preferred = PrivacyTransport.I2P, - i2p = I2pSettings(i2pType = I2pType.INTERNAL, videosViaI2p = false), + i2p = I2pSettings(i2pType = I2pType.EXTERNAL, videosViaI2p = false), ) assertEquals(PrivacyRoute.Direct, PrivacyRouter.route(clearnet, FeatureRole.VIDEO, s)) } @@ -162,7 +162,7 @@ class PrivacyRouterTest { torOn = false, i2pOn = true, preferred = PrivacyTransport.TOR, - i2p = I2pSettings(i2pType = I2pType.INTERNAL, imagesViaI2p = true), + i2p = I2pSettings(i2pType = I2pType.EXTERNAL, imagesViaI2p = true), ) assertEquals(PrivacyRoute.Direct, PrivacyRouter.route(clearnet, FeatureRole.IMAGE, s)) } @@ -176,7 +176,7 @@ class PrivacyRouterTest { i2pOn = true, preferred = PrivacyTransport.I2P, tor = TorSettings(torType = TorType.INTERNAL, imagesViaTor = true), - i2p = I2pSettings(i2pType = I2pType.INTERNAL, imagesViaI2p = true), + i2p = I2pSettings(i2pType = I2pType.EXTERNAL, imagesViaI2p = true), ) assertEquals(PrivacyRoute.I2p, PrivacyRouter.route(clearnet, FeatureRole.IMAGE, s)) }