Merge pull request #2990 from vitorpamplona/claude/add-i2p-privacy-option-nK2X7

Add I2P support with unified privacy routing
This commit is contained in:
Vitor Pamplona
2026-05-19 16:56:54 -04:00
committed by GitHub
34 changed files with 2132 additions and 176 deletions
@@ -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,
)
@@ -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}" }
}
}
}
}
@@ -0,0 +1,90 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.preferences
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.vitorpamplona.amethyst.commons.privacy.PrivacyTransport
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlin.coroutines.cancellation.CancellationException
// Global pick of which privacy transport carries clearnet traffic. Lives apart
// from TorSharedPreferences / I2pSharedPreferences because it spans both — each
// transport owns its own daemon-enable + per-feature toggles; this owns the
// "which one is in charge of clearnet" decision.
@Stable
class PrivacySharedPreferences(
initial: PrivacyTransport,
val context: Context,
val scope: CoroutineScope,
) {
val preferredClearnetTransport: MutableStateFlow<PrivacyTransport> = MutableStateFlow(initial)
@OptIn(FlowPreview::class)
val saving =
preferredClearnetTransport
.debounce(1000)
.distinctUntilChanged()
.onEach { save(it, context) }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, initial)
companion object {
val PREFERRED_CLEARNET_TRANSPORT_KEY = stringPreferencesKey("privacy.preferredClearnetTransport")
suspend fun preferredClearnetTransport(context: Context): PrivacyTransport =
try {
val name = context.sharedPreferencesDataStore.data.first()[PREFERRED_CLEARNET_TRANSPORT_KEY]
name?.let { runCatching { PrivacyTransport.valueOf(it) }.getOrNull() } ?: PrivacyTransport.DIRECT
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("SharedPreferences") { "Error reading preferredClearnetTransport: ${e.message}" }
PrivacyTransport.DIRECT
}
suspend fun save(
transport: PrivacyTransport,
context: Context,
) {
try {
context.sharedPreferencesDataStore.edit { prefs ->
prefs[PREFERRED_CLEARNET_TRANSPORT_KEY] = transport.name
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("SharedPreferences") { "Error saving preferredClearnetTransport: ${e.message}" }
}
}
}
}
@@ -0,0 +1,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,
)
}
@@ -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()
@@ -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."
}
}
}
@@ -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)
}
@@ -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)
}
}
@@ -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),
)
}
}
@@ -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
}
}
}
}
@@ -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)
}
}
}
+48
View File
@@ -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>
@@ -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)
}
}
@@ -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
}
}
}
@@ -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,
)
@@ -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()
}
@@ -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)
}
@@ -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,
}
@@ -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
}
}
}
@@ -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,
}
@@ -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
}
}
@@ -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
}
@@ -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,
}
@@ -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))
}
}
@@ -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,
}
@@ -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)
@@ -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"