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