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)) } }