refactor(privacy): drop I2pType.INTERNAL — EXTERNAL is the permanent answer

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
This commit is contained in:
Claude
2026-05-19 20:37:53 +00:00
parent 4e796c8e8b
commit 40995b9670
7 changed files with 29 additions and 29 deletions
@@ -83,7 +83,13 @@ class I2pSharedPreferences(
try { try {
val preferences = context.sharedPreferencesDataStore.data.first() val preferences = context.sharedPreferencesDataStore.data.first()
I2pSettings( 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, externalSocksPort = preferences[EXTERNAL_SOCKS_PORT_KEY] ?: 4447,
i2pRelaysViaI2p = preferences[I2P_RELAYS_VIA_I2P_KEY] ?: true, i2pRelaysViaI2p = preferences[I2P_RELAYS_VIA_I2P_KEY] ?: true,
dmRelaysViaI2p = preferences[DM_RELAYS_VIA_I2P_KEY] ?: false, dmRelaysViaI2p = preferences[DM_RELAYS_VIA_I2P_KEY] ?: false,
@@ -35,18 +35,16 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
/** /**
* Mirror of TorManager, but EXTERNAL-only for now — no embedded I2P daemon ships * EXTERNAL-only I2P manager. Mirrors TorManager's status surface but never starts
* with this branch. When [I2pType.EXTERNAL] is selected, this manager surfaces the * a daemon itself: I2P bootstrap on Android is structurally minutes-long (no
* configured SOCKS port; otherwise it emits [I2pServiceStatus.Off]. * 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 * There is intentionally no Connecting state, no bootstrap timeout, and no
* "session bypass": with EXTERNAL the daemon's lifecycle is the user's * "session bypass" — when EXTERNAL is selected the daemon's lifecycle is the
* responsibility (i2pd / Java I2P running on the device), and the connection * user's responsibility and the connection either works or it doesn't.
* 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( class I2pManager(
i2pPrefs: I2pSharedPreferences, i2pPrefs: I2pSharedPreferences,
@@ -59,8 +57,6 @@ class I2pManager(
) { type, port -> ) { type, port ->
when (type) { when (type) {
I2pType.OFF -> I2pServiceStatus.Off 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 I2pType.EXTERNAL -> if (port > 0) I2pServiceStatus.Active(port) else I2pServiceStatus.Off
} }
}.catch { e -> }.catch { e ->
@@ -28,6 +28,5 @@ val I2pType.resourceId: Int
get() = get() =
when (this) { when (this) {
I2pType.OFF -> R.string.i2p_off I2pType.OFF -> R.string.i2p_off
I2pType.INTERNAL -> R.string.i2p_internal
I2pType.EXTERNAL -> R.string.i2p_external I2pType.EXTERNAL -> R.string.i2p_external
} }
@@ -49,13 +49,13 @@ import com.vitorpamplona.amethyst.ui.tor.SwitchSettingsRow
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
// Settings body for I2P. Mirrors PrivacySettingsBody (Tor) but without presets — // 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 // only take effect when I2P is also the preferred clearnet transport (see the
// global picker above this section in PrivacyOptionsScreen). // global picker above this section in PrivacyOptionsScreen).
// //
// INTERNAL is intentionally hidden from the picker until an embedded I2P daemon // I2P is EXTERNAL-only: connects to a user-run i2pd / Java I2P installation on
// ships. EXTERNAL connects to a user-run i2pd / Java I2P installation on the // the device (default SOCKS port 4447). We intentionally do not embed a router —
// device (default SOCKS port 4447). // see commons I2pType for the rationale.
@Composable @Composable
fun I2pSettingsBody(dialogViewModel: I2pDialogViewModel) { fun I2pSettingsBody(dialogViewModel: I2pDialogViewModel) {
Column( Column(
@@ -69,11 +69,8 @@ fun I2pSettingsBody(dialogViewModel: I2pDialogViewModel) {
TitleExplainer(stringRes(I2pType.OFF.resourceId)), TitleExplainer(stringRes(I2pType.OFF.resourceId)),
TitleExplainer(stringRes(I2pType.EXTERNAL.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) { when (dialogViewModel.i2pType.value) {
I2pType.OFF -> 0 I2pType.OFF -> 0
I2pType.INTERNAL -> 0 // Treat any persisted INTERNAL as OFF until daemon ships.
I2pType.EXTERNAL -> 1 I2pType.EXTERNAL -> 1
}, },
) { idx -> ) { idx ->
-1
View File
@@ -1103,7 +1103,6 @@
<string name="use_i2p_explainer">Use an external I2P router (i2pd / Java I2P)</string> <string name="use_i2p_explainer">Use an external I2P router (i2pd / Java I2P)</string>
<string name="i2p_off">Off</string> <string name="i2p_off">Off</string>
<string name="i2p_internal">Internal</string>
<string name="i2p_external">External Router</string> <string name="i2p_external">External Router</string>
<string name="i2p_socks_port">I2P Socks Port</string> <string name="i2p_socks_port">I2P Socks Port</string>
@@ -41,17 +41,20 @@ data class I2pSettings(
val mediaUploadsViaI2p: Boolean = false, val mediaUploadsViaI2p: Boolean = false,
) )
// EXTERNAL-only: this app does not embed an I2P router. Bootstrap on Android is
// minutes-long for a fresh netDb and the protocol provides no shortcut analogous
// to Tor's hardcoded directory authorities; we'd ship a "permanently warming up"
// experience. Users who want I2P run i2pd or Java I2P independently and point
// Amethyst at its SOCKS port (default 4447). See I2pManager.
enum class I2pType( enum class I2pType(
val screenCode: Int, val screenCode: Int,
) { ) {
OFF(0), OFF(0),
INTERNAL(1),
EXTERNAL(2), EXTERNAL(2),
} }
fun parseI2pType(code: Int?): I2pType = fun parseI2pType(code: Int?): I2pType =
when (code) { when (code) {
I2pType.INTERNAL.screenCode -> I2pType.INTERNAL
I2pType.EXTERNAL.screenCode -> I2pType.EXTERNAL I2pType.EXTERNAL.screenCode -> I2pType.EXTERNAL
else -> I2pType.OFF else -> I2pType.OFF
} }
@@ -38,7 +38,7 @@ class PrivacyRouterTest {
i2pOn: Boolean = false, i2pOn: Boolean = false,
preferred: PrivacyTransport = PrivacyTransport.DIRECT, preferred: PrivacyTransport = PrivacyTransport.DIRECT,
tor: TorSettings = TorSettings(torType = if (torOn) TorType.INTERNAL else TorType.OFF), 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( ) = PrivacySettings(
tor = tor, tor = tor,
i2p = i2p, i2p = i2p,
@@ -125,7 +125,7 @@ class PrivacyRouterTest {
settings( settings(
i2pOn = true, i2pOn = true,
preferred = PrivacyTransport.I2P, 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)) assertEquals(PrivacyRoute.I2p, PrivacyRouter.route(clearnet, FeatureRole.VIDEO, s))
} }
@@ -136,7 +136,7 @@ class PrivacyRouterTest {
settings( settings(
i2pOn = true, i2pOn = true,
preferred = PrivacyTransport.I2P, 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)) assertEquals(PrivacyRoute.Direct, PrivacyRouter.route(clearnet, FeatureRole.VIDEO, s))
} }
@@ -162,7 +162,7 @@ class PrivacyRouterTest {
torOn = false, torOn = false,
i2pOn = true, i2pOn = true,
preferred = PrivacyTransport.TOR, 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)) assertEquals(PrivacyRoute.Direct, PrivacyRouter.route(clearnet, FeatureRole.IMAGE, s))
} }
@@ -176,7 +176,7 @@ class PrivacyRouterTest {
i2pOn = true, i2pOn = true,
preferred = PrivacyTransport.I2P, preferred = PrivacyTransport.I2P,
tor = TorSettings(torType = TorType.INTERNAL, imagesViaTor = true), 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)) assertEquals(PrivacyRoute.I2p, PrivacyRouter.route(clearnet, FeatureRole.IMAGE, s))
} }