feat(privacy): persist I2pSettings and preferredClearnetTransport

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<PrivacyTransport> 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.
This commit is contained in:
Claude
2026-05-16 20:44:28 +00:00
parent a011421ff5
commit 637bb4aba5
4 changed files with 426 additions and 0 deletions
@@ -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")
@@ -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}" }
}
}
}
}
@@ -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<PrivacyTransport> = 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}" }
}
}
}
}
@@ -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<I2pType> = MutableStateFlow(I2pType.OFF),
val externalSocksPort: MutableStateFlow<Int> = MutableStateFlow(4447),
val i2pRelaysViaI2p: MutableStateFlow<Boolean> = MutableStateFlow(true),
val dmRelaysViaI2p: MutableStateFlow<Boolean> = MutableStateFlow(false),
val newRelaysViaI2p: MutableStateFlow<Boolean> = MutableStateFlow(false),
val trustedRelaysViaI2p: MutableStateFlow<Boolean> = MutableStateFlow(false),
val urlPreviewsViaI2p: MutableStateFlow<Boolean> = MutableStateFlow(false),
val profilePicsViaI2p: MutableStateFlow<Boolean> = MutableStateFlow(false),
val imagesViaI2p: MutableStateFlow<Boolean> = MutableStateFlow(false),
val videosViaI2p: MutableStateFlow<Boolean> = MutableStateFlow(false),
val moneyOperationsViaI2p: MutableStateFlow<Boolean> = MutableStateFlow(false),
val nip05VerificationsViaI2p: MutableStateFlow<Boolean> = MutableStateFlow(false),
val mediaUploadsViaI2p: MutableStateFlow<Boolean> = MutableStateFlow(false),
) {
val propertyWatchFlow =
combine<Any, I2pSettings>(
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),
)
}
}