Merge pull request #2990 from vitorpamplona/claude/add-i2p-privacy-option-nK2X7
Add I2P support with unified privacy routing
This commit is contained in:
@@ -24,7 +24,10 @@ 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.privacy.BlockReason
|
||||
import com.vitorpamplona.amethyst.commons.privacy.PrivacyRoute
|
||||
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
|
||||
import com.vitorpamplona.amethyst.commons.services.lnurl.OkHttpLnurlEndpointResolver
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorSettings
|
||||
@@ -36,10 +39,13 @@ import com.vitorpamplona.amethyst.model.nip03Timestamp.BitcoinExplorerEndpoint
|
||||
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.PrivacyRoutingFlow
|
||||
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
|
||||
import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector
|
||||
import com.vitorpamplona.amethyst.model.torState.TorRelayState
|
||||
@@ -56,6 +62,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
|
||||
@@ -80,6 +87,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
|
||||
@@ -92,6 +100,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.okhttp.OkHttpBitcoinExplorer
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.ots.OtsBlockHeightCache
|
||||
@@ -154,6 +164,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")
|
||||
@@ -166,6 +188,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")
|
||||
@@ -192,6 +229,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
|
||||
@@ -228,6 +269,7 @@ class AppModules(
|
||||
DualHttpClientManager(
|
||||
userAgent = appAgent,
|
||||
proxyPortProvider = torManager.activePortOrNull,
|
||||
i2pProxyPortProvider = i2pManager.activePortOrNull,
|
||||
isMobileDataProvider = connManager.isMobileOrNull,
|
||||
keyCache = keyCache,
|
||||
scope = applicationIOScope,
|
||||
@@ -243,8 +285,15 @@ 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. 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")
|
||||
@@ -320,16 +369,40 @@ class AppModules(
|
||||
DualHttpClientManagerForRelays(
|
||||
userAgent = appAgent,
|
||||
proxyPortProvider = torManager.activePortOrNull,
|
||||
i2pProxyPortProvider = i2pManager.activePortOrNull,
|
||||
isMobileDataProvider = connManager.isMobileOrNull,
|
||||
scope = applicationIOScope,
|
||||
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
|
||||
@@ -582,7 +655,7 @@ class AppModules(
|
||||
diskCache = { diskCache },
|
||||
memoryCache = { memoryCache },
|
||||
blossomServerResolver = { blossomResolver },
|
||||
callFactory = { okHttpClients.getHttpClient(roleBasedHttpClientBuilder.shouldUseTorForImageDownload(it)) },
|
||||
callFactory = { roleBasedHttpClientBuilder.okHttpClientForImage(it) },
|
||||
thumbnailCache = thumbnailDiskCache,
|
||||
backgroundScope = applicationIOScope,
|
||||
)
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* 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(
|
||||
// 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,
|
||||
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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
@@ -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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -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,
|
||||
)
|
||||
}
|
||||
+46
-107
@@ -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()
|
||||
|
||||
|
||||
+48
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -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
|
||||
@@ -39,6 +42,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<Int?> = MutableStateFlow(null),
|
||||
shouldBridgeBlossomCache: (() -> Boolean)? = null,
|
||||
) : IHttpClientManager {
|
||||
val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache)
|
||||
@@ -62,8 +69,19 @@ class DualHttpClientManager(
|
||||
factory.buildHttpClient(isMobileDataProvider.value),
|
||||
)
|
||||
|
||||
val i2pHttpClient: StateFlow<OkHttpClient> =
|
||||
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 +96,57 @@ 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)
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,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)
|
||||
}
|
||||
|
||||
+30
@@ -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<Boolean?>,
|
||||
scope: CoroutineScope,
|
||||
dns: SurgeDns,
|
||||
private val i2pProxyPortProvider: StateFlow<Int?> = MutableStateFlow(null),
|
||||
) : IHttpClientManager {
|
||||
val factory = OkHttpClientFactoryForRelays(userAgent, dns)
|
||||
|
||||
@@ -58,8 +61,19 @@ class DualHttpClientManagerForRelays(
|
||||
factory.buildHttpClient(isMobileDataProvider.value),
|
||||
)
|
||||
|
||||
val i2pHttpClient: StateFlow<OkHttpClient> =
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+23
-40
@@ -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<Int, MediaSessionPool>()
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* 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" — 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,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
val status: StateFlow<I2pServiceStatus> =
|
||||
combine(
|
||||
i2pPrefs.value.i2pType,
|
||||
i2pPrefs.value.externalSocksPort,
|
||||
) { type, port ->
|
||||
when (type) {
|
||||
I2pType.OFF -> 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<Int?> =
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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.EXTERNAL -> R.string.i2p_external
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* 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, 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).
|
||||
//
|
||||
// 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(
|
||||
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)),
|
||||
),
|
||||
when (dialogViewModel.i2pType.value) {
|
||||
I2pType.OFF -> 0
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+82
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
-22
@@ -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<TorDialogViewModel>()
|
||||
val torVm = viewModel<TorDialogViewModel>()
|
||||
val i2pVm = viewModel<I2pDialogViewModel>()
|
||||
val pickerVm = viewModel<PrivacyTransportPickerViewModel>()
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1101,6 +1101,54 @@
|
||||
<string name="use_orbot">Use Orbot</string>
|
||||
<string name="disconnect_from_your_orbot_setup">Disconnect Tor/Orbot</string>
|
||||
|
||||
<!-- I2P daemon picker + per-feature toggles. Shape mirrors the Tor strings above.
|
||||
Take effect only when I2P is also selected as the preferred clearnet transport. -->
|
||||
<string name="use_i2p">Active I2P Engine</string>
|
||||
<string name="use_i2p_explainer">Use an external I2P router (i2pd / Java I2P)</string>
|
||||
|
||||
<string name="i2p_off">Off</string>
|
||||
<string name="i2p_external">External Router</string>
|
||||
|
||||
<string name="i2p_socks_port">I2P Socks Port</string>
|
||||
<string name="i2p_socks_port_explainer">SOCKS port your local I2P router exposes (i2pd default: 4447)</string>
|
||||
|
||||
<string name="i2p_use_i2p_address">.i2p Url/Relays</string>
|
||||
<string name="i2p_use_i2p_address_explainer">Use I2P for any .i2p url</string>
|
||||
|
||||
<string name="i2p_use_dm_relays">DM Relays</string>
|
||||
<string name="i2p_use_dm_relays_explainer">Force I2P to send and receive DMs</string>
|
||||
|
||||
<string name="i2p_use_new_relays">Untrusted Relays</string>
|
||||
<string name="i2p_use_new_relays_explainer">Force I2P on outbox/inbox relays</string>
|
||||
|
||||
<string name="i2p_use_trusted_relays">Trusted Relays</string>
|
||||
<string name="i2p_use_trusted_relays_explainer">Force I2P on all relays in your lists</string>
|
||||
|
||||
<string name="i2p_use_url_previews">URL Previews</string>
|
||||
<string name="i2p_use_url_previews_explainer">Force I2P when loading url previews</string>
|
||||
|
||||
<string name="i2p_use_images">Images</string>
|
||||
<string name="i2p_use_images_explainer">Force I2P when loading images</string>
|
||||
|
||||
<string name="i2p_use_videos">Videos</string>
|
||||
<string name="i2p_use_videos_explainer">Force I2P when loading videos</string>
|
||||
|
||||
<string name="i2p_use_money_operations">Money Operations</string>
|
||||
<string name="i2p_use_money_operations_explainer">Force I2P for zaps and payments</string>
|
||||
|
||||
<string name="i2p_use_nip05_verification">NIP-05 Verification</string>
|
||||
<string name="i2p_use_nip05_verification_explainer">Force I2P when verifying NIP-05 identifiers</string>
|
||||
|
||||
<string name="i2p_use_media_uploads">Media Uploads</string>
|
||||
<string name="i2p_use_media_uploads_explainer">Force I2P when uploading media</string>
|
||||
|
||||
<!-- Global picker: which transport carries clearnet traffic. -->
|
||||
<string name="privacy_clearnet_transport">Privacy Network</string>
|
||||
<string name="privacy_clearnet_transport_explainer">Routes regular web traffic through this network. Hidden service URLs (.onion / .i2p) always use their matching network.</string>
|
||||
<string name="privacy_clearnet_transport_direct">Direct (no privacy)</string>
|
||||
<string name="privacy_clearnet_transport_tor">Tor</string>
|
||||
<string name="privacy_clearnet_transport_i2p">I2P</string>
|
||||
|
||||
<string name="app_notification_channel_id" translatable="false">DefaultChannelID</string>
|
||||
<string name="app_notification_private_message" translatable="false">New notification arrived</string>
|
||||
|
||||
|
||||
+52
@@ -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)
|
||||
}
|
||||
}
|
||||
+48
@@ -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<NormalizedRelayUrl>,
|
||||
val dmRelayList: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -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,
|
||||
)
|
||||
+35
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
// 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,
|
||||
val i2pRelaysViaI2p: Boolean = true,
|
||||
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,
|
||||
)
|
||||
|
||||
// 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),
|
||||
EXTERNAL(2),
|
||||
}
|
||||
|
||||
fun parseI2pType(code: Int?): I2pType =
|
||||
when (code) {
|
||||
I2pType.EXTERNAL.screenCode -> I2pType.EXTERNAL
|
||||
else -> I2pType.OFF
|
||||
}
|
||||
@@ -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<I2pServiceStatus>
|
||||
val activePortOrNull: StateFlow<Int?>
|
||||
|
||||
suspend fun dormant()
|
||||
|
||||
suspend fun active()
|
||||
}
|
||||
+27
@@ -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)
|
||||
}
|
||||
+35
@@ -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.privacy
|
||||
|
||||
// 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,
|
||||
URL_PREVIEW,
|
||||
PROFILE_PIC,
|
||||
NIP05,
|
||||
MONEY,
|
||||
UPLOAD,
|
||||
}
|
||||
+46
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
enum class BlockReason {
|
||||
ONION_REQUIRES_TOR,
|
||||
I2P_REQUIRES_I2P,
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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.tor.TorSettings
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.HiddenServiceKind
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
|
||||
// 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,
|
||||
): PrivacyRoute =
|
||||
when (RelayUrlNormalizer.classifyHidden(url)) {
|
||||
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 clearnet(
|
||||
role: FeatureRole,
|
||||
settings: PrivacySettings,
|
||||
): 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
|
||||
}
|
||||
}
|
||||
+40
@@ -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. 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 preferredClearnetTransport: PrivacyTransport = PrivacyTransport.DIRECT,
|
||||
) {
|
||||
val torAvailable: Boolean get() = tor.torType != TorType.OFF
|
||||
val i2pAvailable: Boolean get() = i2p.i2pType != I2pType.OFF
|
||||
}
|
||||
+27
@@ -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,
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* 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,
|
||||
preferred: PrivacyTransport = PrivacyTransport.DIRECT,
|
||||
tor: TorSettings = TorSettings(torType = if (torOn) TorType.INTERNAL else TorType.OFF),
|
||||
i2p: I2pSettings = I2pSettings(i2pType = if (i2pOn) I2pType.EXTERNAL else I2pType.OFF),
|
||||
) = PrivacySettings(
|
||||
tor = tor,
|
||||
i2p = i2p,
|
||||
preferredClearnetTransport = preferred,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun localhost_alwaysDirect() {
|
||||
assertEquals(
|
||||
PrivacyRoute.Direct,
|
||||
PrivacyRouter.route(localhost, FeatureRole.IMAGE, settings(torOn = true, i2pOn = true, preferred = PrivacyTransport.TOR)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onion_pinsToTor_whenAvailable() {
|
||||
assertEquals(PrivacyRoute.Tor, PrivacyRouter.route(onion, FeatureRole.IMAGE, settings(torOn = true)))
|
||||
}
|
||||
|
||||
@Test
|
||||
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(PrivacyRoute.I2p, PrivacyRouter.route(i2p, FeatureRole.IMAGE, settings(i2pOn = true)))
|
||||
}
|
||||
|
||||
@Test
|
||||
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(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.EXTERNAL, 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.EXTERNAL, 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.EXTERNAL, 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.EXTERNAL, 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))
|
||||
}
|
||||
}
|
||||
+28
@@ -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,
|
||||
}
|
||||
+4
@@ -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)
|
||||
|
||||
+11
-1
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user