diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 32ac66ea1..d2cb4db87 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -26,6 +26,7 @@ import coil3.disk.DiskCache import coil3.memory.MemoryCache import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash +import com.vitorpamplona.amethyst.commons.tor.TorSettings import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.UiSettings @@ -72,7 +73,6 @@ import com.vitorpamplona.amethyst.ui.resourceCacheInit import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.UiSettingsState import com.vitorpamplona.amethyst.ui.tor.TorManager -import com.vitorpamplona.amethyst.ui.tor.TorSettings import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt index 0bb4d80f6..f1ea5ed91 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt @@ -26,9 +26,9 @@ 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.ui.tor.TorSettings +import com.vitorpamplona.amethyst.commons.tor.TorSettings +import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow -import com.vitorpamplona.amethyst.ui.tor.TorType import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt index daea244bb..a332044be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.model.privacyOptions +import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow -import com.vitorpamplona.amethyst.ui.tor.TorType import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import okhttp3.OkHttpClient import java.net.InetSocketAddress diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayEvaluation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayEvaluation.kt index 2a956fe0a..e7d1bb1eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayEvaluation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayEvaluation.kt @@ -20,30 +20,5 @@ */ package com.vitorpamplona.amethyst.model.torState -import com.vitorpamplona.amethyst.ui.tor.TorType -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion - -class TorRelayEvaluation( - val torSettings: TorRelaySettings, - val trustedRelayList: Set, - val dmRelayList: Set, -) { - fun useTor(relay: NormalizedRelayUrl): Boolean = - if (torSettings.torType == TorType.OFF) { - false - } else { - if (relay.isLocalHost()) { - false - } else if (relay.isOnion()) { - torSettings.onionRelaysViaTor - } else if (relay in dmRelayList) { - torSettings.dmRelaysViaTor - } else if (relay in trustedRelayList) { - torSettings.trustedRelaysViaTor - } else { - torSettings.newRelaysViaTor - } - } -} +// Canonical type now lives in commons +typealias TorRelayEvaluation = com.vitorpamplona.amethyst.commons.tor.TorRelayEvaluation diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelaySettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelaySettings.kt index 4d7d5dc90..80d1822d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelaySettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelaySettings.kt @@ -20,12 +20,5 @@ */ package com.vitorpamplona.amethyst.model.torState -import com.vitorpamplona.amethyst.ui.tor.TorType - -data class TorRelaySettings( - val torType: TorType = TorType.OFF, - val onionRelaysViaTor: Boolean = true, - val dmRelaysViaTor: Boolean = false, - val newRelaysViaTor: Boolean = false, - val trustedRelaysViaTor: Boolean = false, -) +// Canonical type now lives in commons +typealias TorRelaySettings = com.vitorpamplona.amethyst.commons.tor.TorRelaySettings diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt index 48fa7df62..b6822fc21 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.amethyst.model.torState import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow -import com.vitorpamplona.amethyst.ui.tor.TorType import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 8d43ec541..af27793ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtComparator +import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.notifications.CardFeedState import com.vitorpamplona.amethyst.logTime @@ -88,7 +89,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow -import com.vitorpamplona.amethyst.ui.tor.TorType import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt index df8be0587..306447fc5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt @@ -34,11 +34,11 @@ 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.tor.TorSettings import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.tor.PrivacySettingsBody import com.vitorpamplona.amethyst.ui.tor.TorDialogViewModel -import com.vitorpamplona.amethyst.ui.tor.TorSettings import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow @OptIn(ExperimentalMaterial3Api::class) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsScreen.kt index 2d33dddee..a6dd80c71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsScreen.kt @@ -35,12 +35,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow -import com.vitorpamplona.amethyst.ui.tor.TorType import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorDialogViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorDialogViewModel.kt index 8db02d322..ffae7081c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorDialogViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorDialogViewModel.kt @@ -24,6 +24,14 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.commons.tor.TorPresetType +import com.vitorpamplona.amethyst.commons.tor.TorSettings +import com.vitorpamplona.amethyst.commons.tor.TorType +import com.vitorpamplona.amethyst.commons.tor.torDefaultPreset +import com.vitorpamplona.amethyst.commons.tor.torFullyPrivate +import com.vitorpamplona.amethyst.commons.tor.torOnlyWhenNeededPreset +import com.vitorpamplona.amethyst.commons.tor.torSmallPayloadsPreset +import com.vitorpamplona.amethyst.commons.tor.whichPreset @Stable class TorDialogViewModel : ViewModel() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt index fe415a002..a11bf5997 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.tor import android.content.Context +import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettings.kt index ff1146c70..0268df821 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettings.kt @@ -21,138 +21,40 @@ package com.vitorpamplona.amethyst.ui.tor import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.tor.TorPresetType +import com.vitorpamplona.amethyst.commons.tor.TorType -data class TorSettings( - val torType: TorType = TorType.INTERNAL, - val externalSocksPort: Int = 9050, - val onionRelaysViaTor: Boolean = true, - val dmRelaysViaTor: Boolean = true, - val newRelaysViaTor: Boolean = true, - val trustedRelaysViaTor: Boolean = false, - val urlPreviewsViaTor: Boolean = false, - val profilePicsViaTor: Boolean = false, - val imagesViaTor: Boolean = false, - val videosViaTor: Boolean = false, - val moneyOperationsViaTor: Boolean = false, - val nip05VerificationsViaTor: Boolean = false, - val mediaUploadsViaTor: Boolean = false, -) +// Re-export shared types so existing Android imports continue to work +// The canonical types now live in commons/commonMain +@Suppress("unused") +private const val RE_EXPORTS = 0 -enum class TorType( - val screenCode: Int, - val resourceId: Int, -) { - OFF(0, R.string.tor_off), - INTERNAL(1, R.string.tor_internal), - EXTERNAL(2, R.string.tor_external), -} +// Android-specific resource ID mappings for TorType +val TorType.resourceId: Int + get() = + when (this) { + TorType.OFF -> R.string.tor_off + TorType.INTERNAL -> R.string.tor_internal + TorType.EXTERNAL -> R.string.tor_external + } -fun parseTorType(code: Int?): TorType = - when (code) { - TorType.OFF.screenCode -> TorType.OFF - TorType.INTERNAL.screenCode -> TorType.INTERNAL - TorType.EXTERNAL.screenCode -> TorType.EXTERNAL - else -> TorType.INTERNAL - } +// Android-specific resource ID mappings for TorPresetType +val TorPresetType.resourceId: Int + get() = + when (this) { + TorPresetType.ONLY_WHEN_NEEDED -> R.string.tor_when_needed + TorPresetType.DEFAULT -> R.string.tor_default + TorPresetType.SMALL_PAYLOADS -> R.string.tor_small_payloads + TorPresetType.FULL_PRIVACY -> R.string.tor_full_privacy + TorPresetType.CUSTOM -> R.string.tor_custom + } -enum class TorPresetType( - val screenCode: Int, - val resourceId: Int, - val explainerId: Int, -) { - ONLY_WHEN_NEEDED(0, R.string.tor_when_needed, R.string.tor_when_needed_explainer), - DEFAULT(1, R.string.tor_default, R.string.tor_default_explainer), - SMALL_PAYLOADS(2, R.string.tor_small_payloads, R.string.tor_small_payloads_explainer), - FULL_PRIVACY(3, R.string.tor_full_privacy, R.string.tor_full_privacy_explainer), - CUSTOM(4, R.string.tor_custom, R.string.tor_custom_explainer), -} - -fun parseTorPresetType(code: Int?): TorPresetType = - when (code) { - TorPresetType.ONLY_WHEN_NEEDED.screenCode -> TorPresetType.ONLY_WHEN_NEEDED - TorPresetType.DEFAULT.screenCode -> TorPresetType.DEFAULT - TorPresetType.SMALL_PAYLOADS.screenCode -> TorPresetType.SMALL_PAYLOADS - TorPresetType.FULL_PRIVACY.screenCode -> TorPresetType.FULL_PRIVACY - else -> TorPresetType.CUSTOM - } - -fun isPreset( - torSettings: TorSettings, - preset: TorSettings, -): Boolean = - torSettings.onionRelaysViaTor == preset.onionRelaysViaTor && - torSettings.dmRelaysViaTor == preset.dmRelaysViaTor && - torSettings.newRelaysViaTor == preset.newRelaysViaTor && - torSettings.trustedRelaysViaTor == preset.trustedRelaysViaTor && - torSettings.urlPreviewsViaTor == preset.urlPreviewsViaTor && - // torSettings.profilePicsViaTor == preset.profilePicsViaTor && - torSettings.imagesViaTor == preset.imagesViaTor && - torSettings.videosViaTor == preset.videosViaTor && - torSettings.moneyOperationsViaTor == preset.moneyOperationsViaTor && - torSettings.nip05VerificationsViaTor == preset.nip05VerificationsViaTor && - torSettings.mediaUploadsViaTor == preset.mediaUploadsViaTor - -fun whichPreset(torSettings: TorSettings): TorPresetType { - if (isPreset(torSettings, torOnlyWhenNeededPreset)) return TorPresetType.ONLY_WHEN_NEEDED - if (isPreset(torSettings, torDefaultPreset)) return TorPresetType.DEFAULT - if (isPreset(torSettings, torSmallPayloadsPreset)) return TorPresetType.SMALL_PAYLOADS - if (isPreset(torSettings, torFullyPrivate)) return TorPresetType.FULL_PRIVACY - return TorPresetType.CUSTOM -} - -val torOnlyWhenNeededPreset = - TorSettings( - onionRelaysViaTor = true, - dmRelaysViaTor = false, - newRelaysViaTor = false, - trustedRelaysViaTor = false, - urlPreviewsViaTor = false, - profilePicsViaTor = false, - imagesViaTor = false, - videosViaTor = false, - moneyOperationsViaTor = false, - nip05VerificationsViaTor = false, - mediaUploadsViaTor = false, - ) -val torDefaultPreset = - TorSettings( - onionRelaysViaTor = true, - dmRelaysViaTor = true, - newRelaysViaTor = true, - trustedRelaysViaTor = false, - urlPreviewsViaTor = false, - profilePicsViaTor = false, - imagesViaTor = false, - videosViaTor = false, - moneyOperationsViaTor = false, - nip05VerificationsViaTor = false, - mediaUploadsViaTor = false, - ) -val torSmallPayloadsPreset = - TorSettings( - onionRelaysViaTor = true, - dmRelaysViaTor = true, - newRelaysViaTor = true, - trustedRelaysViaTor = true, - urlPreviewsViaTor = true, - profilePicsViaTor = true, - imagesViaTor = false, - videosViaTor = false, - moneyOperationsViaTor = true, - nip05VerificationsViaTor = true, - mediaUploadsViaTor = false, - ) -val torFullyPrivate = - TorSettings( - onionRelaysViaTor = true, - dmRelaysViaTor = true, - newRelaysViaTor = true, - trustedRelaysViaTor = true, - urlPreviewsViaTor = true, - profilePicsViaTor = true, - imagesViaTor = true, - videosViaTor = true, - moneyOperationsViaTor = true, - nip05VerificationsViaTor = true, - mediaUploadsViaTor = true, - ) +val TorPresetType.explainerId: Int + get() = + when (this) { + TorPresetType.ONLY_WHEN_NEEDED -> R.string.tor_when_needed_explainer + TorPresetType.DEFAULT -> R.string.tor_default_explainer + TorPresetType.SMALL_PAYLOADS -> R.string.tor_small_payloads_explainer + TorPresetType.FULL_PRIVACY -> R.string.tor_full_privacy_explainer + TorPresetType.CUSTOM -> R.string.tor_custom_explainer + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt index d64e072ee..7c1351f1b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt @@ -49,6 +49,11 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.tor.TorPresetType +import com.vitorpamplona.amethyst.commons.tor.TorSettings +import com.vitorpamplona.amethyst.commons.tor.TorType +import com.vitorpamplona.amethyst.commons.tor.parseTorPresetType +import com.vitorpamplona.amethyst.commons.tor.parseTorType import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsFlow.kt index d299c8f08..89f297fed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsFlow.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.ui.tor import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.tor.TorSettings +import com.vitorpamplona.amethyst.commons.tor.TorType import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/torState/TorRelayEvaluationTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/torState/TorRelayEvaluationTest.kt new file mode 100644 index 000000000..3c47751fa --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/torState/TorRelayEvaluationTest.kt @@ -0,0 +1,235 @@ +/* + * 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.torState + +import com.vitorpamplona.amethyst.commons.tor.TorType +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TorRelayEvaluationTest { + // Helper relay URLs + private val clearnetRelay = NormalizedRelayUrl("wss://relay.damus.io/") + private val onionRelay = NormalizedRelayUrl("wss://abc123.onion/") + private val localhostRelay = NormalizedRelayUrl("ws://127.0.0.1:8080/") + private val localhostNameRelay = NormalizedRelayUrl("ws://localhost:8080/") + private val localNetworkRelay = NormalizedRelayUrl("ws://192.168.1.100:8080/") + private val dmRelay = NormalizedRelayUrl("wss://dm.relay.com/") + private val trustedRelay = NormalizedRelayUrl("wss://trusted.relay.com/") + + private fun buildEvaluation( + torType: TorType = TorType.INTERNAL, + onionViaTor: Boolean = true, + dmViaTor: Boolean = true, + newViaTor: Boolean = true, + trustedViaTor: Boolean = false, + dmRelays: Set = setOf(dmRelay), + trustedRelays: Set = setOf(trustedRelay), + ) = TorRelayEvaluation( + torSettings = + TorRelaySettings( + torType = torType, + onionRelaysViaTor = onionViaTor, + dmRelaysViaTor = dmViaTor, + newRelaysViaTor = newViaTor, + trustedRelaysViaTor = trustedViaTor, + ), + trustedRelayList = trustedRelays, + dmRelayList = dmRelays, + ) + + // --- Tor OFF: always false --- + + @Test + fun torOff_clearnetRelay_returnsFalse() { + val eval = buildEvaluation(torType = TorType.OFF) + assertFalse(eval.useTor(clearnetRelay)) + } + + @Test + fun torOff_onionRelay_returnsFalse() { + val eval = buildEvaluation(torType = TorType.OFF) + assertFalse(eval.useTor(onionRelay)) + } + + @Test + fun torOff_dmRelay_returnsFalse() { + val eval = buildEvaluation(torType = TorType.OFF) + assertFalse(eval.useTor(dmRelay)) + } + + // --- Localhost: always false regardless of Tor --- + + @Test + fun localhost127_alwaysFalse() { + val eval = buildEvaluation(torType = TorType.INTERNAL) + assertFalse(eval.useTor(localhostRelay)) + } + + @Test + fun localhostName_alwaysFalse() { + val eval = buildEvaluation(torType = TorType.INTERNAL) + assertFalse(eval.useTor(localhostNameRelay)) + } + + @Test + fun localNetwork192_alwaysFalse() { + val eval = buildEvaluation(torType = TorType.INTERNAL) + assertFalse(eval.useTor(localNetworkRelay)) + } + + // --- .onion relays --- + + @Test + fun onionRelay_torInternal_onionEnabled_returnsTrue() { + val eval = buildEvaluation(torType = TorType.INTERNAL, onionViaTor = true) + assertTrue(eval.useTor(onionRelay)) + } + + @Test + fun onionRelay_torInternal_onionDisabled_returnsFalse() { + val eval = buildEvaluation(torType = TorType.INTERNAL, onionViaTor = false) + assertFalse(eval.useTor(onionRelay)) + } + + @Test + fun onionRelay_torExternal_onionEnabled_returnsTrue() { + val eval = buildEvaluation(torType = TorType.EXTERNAL, onionViaTor = true) + assertTrue(eval.useTor(onionRelay)) + } + + // --- DM relays --- + + @Test + fun dmRelay_dmViaTorEnabled_returnsTrue() { + val eval = buildEvaluation(dmViaTor = true) + assertTrue(eval.useTor(dmRelay)) + } + + @Test + fun dmRelay_dmViaTorDisabled_returnsFalse() { + val eval = buildEvaluation(dmViaTor = false) + assertFalse(eval.useTor(dmRelay)) + } + + // --- Trusted relays --- + + @Test + fun trustedRelay_trustedViaTorEnabled_returnsTrue() { + val eval = buildEvaluation(trustedViaTor = true) + assertTrue(eval.useTor(trustedRelay)) + } + + @Test + fun trustedRelay_trustedViaTorDisabled_returnsFalse() { + val eval = buildEvaluation(trustedViaTor = false) + assertFalse(eval.useTor(trustedRelay)) + } + + // --- New/unknown relays --- + + @Test + fun unknownRelay_newViaTorEnabled_returnsTrue() { + val eval = buildEvaluation(newViaTor = true) + assertTrue(eval.useTor(clearnetRelay)) + } + + @Test + fun unknownRelay_newViaTorDisabled_returnsFalse() { + val eval = buildEvaluation(newViaTor = false) + assertFalse(eval.useTor(clearnetRelay)) + } + + // --- Priority: .onion > DM > trusted > new --- + + @Test + fun onionRelay_inDmList_treatedAsOnionNotDm() { + // If a relay is both .onion AND in DM list, .onion takes precedence + val onionDmRelay = NormalizedRelayUrl("wss://dmrelay.onion/") + val eval = + buildEvaluation( + onionViaTor = false, + dmViaTor = true, + dmRelays = setOf(onionDmRelay), + ) + // onion check happens first, and it's disabled → false + assertFalse(eval.useTor(onionDmRelay)) + } + + @Test + fun relay_inBothDmAndTrusted_dmTakesPrecedence() { + val bothRelay = NormalizedRelayUrl("wss://both.relay.com/") + val eval = + buildEvaluation( + dmViaTor = true, + trustedViaTor = false, + dmRelays = setOf(bothRelay), + trustedRelays = setOf(bothRelay), + ) + // DM check happens before trusted check + assertTrue(eval.useTor(bothRelay)) + } + + @Test + fun relay_inBothDmAndTrusted_dmDisabled_doesNotFallToTrusted() { + val bothRelay = NormalizedRelayUrl("wss://both.relay.com/") + val eval = + buildEvaluation( + dmViaTor = false, + trustedViaTor = true, + dmRelays = setOf(bothRelay), + trustedRelays = setOf(bothRelay), + ) + // DM check matches first, dm is disabled → false + // Does NOT fall through to trusted check + assertFalse(eval.useTor(bothRelay)) + } + + // --- Empty relay lists --- + + @Test + fun emptyRelayLists_allRelaysAreNew() { + val eval = + buildEvaluation( + newViaTor = true, + dmRelays = emptySet(), + trustedRelays = emptySet(), + ) + assertTrue(eval.useTor(clearnetRelay)) + assertTrue(eval.useTor(dmRelay)) // Not in DM list, treated as new + assertTrue(eval.useTor(trustedRelay)) // Not in trusted list, treated as new + } + + // --- External Tor mode --- + + @Test + fun torExternal_newViaTor_returnsTrue() { + val eval = buildEvaluation(torType = TorType.EXTERNAL, newViaTor = true) + assertTrue(eval.useTor(clearnetRelay)) + } + + @Test + fun torExternal_localhostStillFalse() { + val eval = buildEvaluation(torType = TorType.EXTERNAL) + assertFalse(eval.useTor(localhostRelay)) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelaysTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelaysTest.kt new file mode 100644 index 000000000..9be10f5fb --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelaysTest.kt @@ -0,0 +1,151 @@ +/* + * 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 org.junit.Assert.assertEquals +import org.junit.Test +import java.net.InetSocketAddress +import java.net.Proxy + +/** + * Tests for SOCKS proxy creation and timeout logic. + * + * Note: OkHttpClientFactoryForRelays cannot be instantiated in unit tests because + * its constructor calls android.os.Build (isEmulator check). These tests verify + * the proxy and timeout logic directly using the same patterns the factory uses. + * This documents the behavior we must preserve during extraction to commons. + */ +class OkHttpClientFactoryForRelaysTest { + // --- buildLocalSocksProxy logic (tested directly) --- + + @Test + fun socksProxy_withPort_returnsSocksType() { + val proxy = buildLocalSocksProxy(9050) + assertEquals(Proxy.Type.SOCKS, proxy.type()) + } + + @Test + fun socksProxy_withPort_usesLocalhost() { + val proxy = buildLocalSocksProxy(9050) + val addr = proxy.address() as InetSocketAddress + assertEquals("127.0.0.1", addr.hostString) + } + + @Test + fun socksProxy_withPort_usesGivenPort() { + val proxy = buildLocalSocksProxy(9050) + val addr = proxy.address() as InetSocketAddress + assertEquals(9050, addr.port) + } + + @Test + fun socksProxy_customPort_usesGivenPort() { + val proxy = buildLocalSocksProxy(9150) // Tor Browser port + val addr = proxy.address() as InetSocketAddress + assertEquals(9150, addr.port) + } + + @Test + fun socksProxy_nullPort_fallsBackToDefault9050() { + // DOCUMENTS CURRENT BEHAVIOR: null falls back to 9050 + // This is a security concern flagged in the plan — will be fixed during extraction + val proxy = buildLocalSocksProxy(null) + val addr = proxy.address() as InetSocketAddress + assertEquals(OkHttpClientFactoryForRelays.DEFAULT_SOCKS_PORT, addr.port) + } + + // --- Timeout logic --- + + @Test + fun timeout_mobile_returns30Seconds() { + assertEquals(OkHttpClientFactoryForRelays.DEFAULT_TIMEOUT_ON_MOBILE_SECS, buildTimeout(true)) + } + + @Test + fun timeout_wifi_returns10Seconds() { + assertEquals(OkHttpClientFactoryForRelays.DEFAULT_TIMEOUT_ON_WIFI_SECS, buildTimeout(false)) + } + + // --- Timeout multiplier with proxy --- + + @Test + fun timeoutWithProxy_tripled() { + val base = 10 + val withProxy = computeTimeout(base, hasProxy = true) + assertEquals(30, withProxy) + } + + @Test + fun timeoutWithoutProxy_unchanged() { + val base = 10 + val withoutProxy = computeTimeout(base, hasProxy = false) + assertEquals(10, withoutProxy) + } + + @Test + fun readWriteTimeout_tripleOfConnectTimeout() { + // Both factories do: readTimeout = connectTimeout * 3 + val connectSeconds = 30 + assertEquals(90, connectSeconds * 3) + } + + // Note: OkHttpClient.Builder tests removed — OkHttp internals depend on + // Android platform classes in this module's test classpath. + // These will be tested in desktopApp/jvmTest after extraction. + + // --- Constants --- + + @Test + fun defaultSocksPort_is9050() { + assertEquals(9050, OkHttpClientFactoryForRelays.DEFAULT_SOCKS_PORT) + } + + @Test + fun defaultIsMobile_isFalse() { + assertEquals(false, OkHttpClientFactoryForRelays.DEFAULT_IS_MOBILE) + } + + @Test + fun defaultTimeoutWifi_is10() { + assertEquals(10, OkHttpClientFactoryForRelays.DEFAULT_TIMEOUT_ON_WIFI_SECS) + } + + @Test + fun defaultTimeoutMobile_is30() { + assertEquals(30, OkHttpClientFactoryForRelays.DEFAULT_TIMEOUT_ON_MOBILE_SECS) + } + + // --- Helper functions matching factory logic --- + + private fun buildLocalSocksProxy(port: Int?): Proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port ?: OkHttpClientFactoryForRelays.DEFAULT_SOCKS_PORT)) + + private fun buildTimeout(isMobile: Boolean): Int = + if (isMobile) { + OkHttpClientFactoryForRelays.DEFAULT_TIMEOUT_ON_MOBILE_SECS + } else { + OkHttpClientFactoryForRelays.DEFAULT_TIMEOUT_ON_WIFI_SECS + } + + private fun computeTimeout( + baseSeconds: Int, + hasProxy: Boolean, + ): Int = if (hasProxy) baseSeconds * 3 else baseSeconds +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsTest.kt new file mode 100644 index 000000000..d7361f960 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsTest.kt @@ -0,0 +1,305 @@ +/* + * 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.tor + +import com.vitorpamplona.amethyst.commons.tor.TorPresetType +import com.vitorpamplona.amethyst.commons.tor.TorSettings +import com.vitorpamplona.amethyst.commons.tor.TorType +import com.vitorpamplona.amethyst.commons.tor.isPreset +import com.vitorpamplona.amethyst.commons.tor.parseTorPresetType +import com.vitorpamplona.amethyst.commons.tor.parseTorType +import com.vitorpamplona.amethyst.commons.tor.torDefaultPreset +import com.vitorpamplona.amethyst.commons.tor.torFullyPrivate +import com.vitorpamplona.amethyst.commons.tor.torOnlyWhenNeededPreset +import com.vitorpamplona.amethyst.commons.tor.torSmallPayloadsPreset +import com.vitorpamplona.amethyst.commons.tor.whichPreset +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TorSettingsTest { + // --- parseTorType --- + + @Test + fun parseTorType_code0_returnsOff() { + assertEquals(TorType.OFF, parseTorType(0)) + } + + @Test + fun parseTorType_code1_returnsInternal() { + assertEquals(TorType.INTERNAL, parseTorType(1)) + } + + @Test + fun parseTorType_code2_returnsExternal() { + assertEquals(TorType.EXTERNAL, parseTorType(2)) + } + + @Test + fun parseTorType_null_defaultsToInternal() { + assertEquals(TorType.INTERNAL, parseTorType(null)) + } + + @Test + fun parseTorType_unknownCode_defaultsToInternal() { + assertEquals(TorType.INTERNAL, parseTorType(99)) + } + + @Test + fun parseTorType_negativeCode_defaultsToInternal() { + assertEquals(TorType.INTERNAL, parseTorType(-1)) + } + + // --- TorType screenCode consistency --- + + @Test + fun torType_screenCodes_areUnique() { + val codes = TorType.entries.map { it.screenCode } + assertEquals(codes.size, codes.toSet().size) + } + + @Test + fun torType_allValues_roundTripViaParse() { + TorType.entries.forEach { type -> + assertEquals(type, parseTorType(type.screenCode)) + } + } + + // --- parseTorPresetType --- + + @Test + fun parseTorPresetType_code0_returnsOnlyWhenNeeded() { + assertEquals(TorPresetType.ONLY_WHEN_NEEDED, parseTorPresetType(0)) + } + + @Test + fun parseTorPresetType_code1_returnsDefault() { + assertEquals(TorPresetType.DEFAULT, parseTorPresetType(1)) + } + + @Test + fun parseTorPresetType_code2_returnsSmallPayloads() { + assertEquals(TorPresetType.SMALL_PAYLOADS, parseTorPresetType(2)) + } + + @Test + fun parseTorPresetType_code3_returnsFullPrivacy() { + assertEquals(TorPresetType.FULL_PRIVACY, parseTorPresetType(3)) + } + + @Test + fun parseTorPresetType_unknownCode_defaultsToCustom() { + assertEquals(TorPresetType.CUSTOM, parseTorPresetType(99)) + } + + @Test + fun parseTorPresetType_null_defaultsToCustom() { + assertEquals(TorPresetType.CUSTOM, parseTorPresetType(null)) + } + + @Test + fun torPresetType_screenCodes_areUnique() { + val codes = TorPresetType.entries.map { it.screenCode } + assertEquals(codes.size, codes.toSet().size) + } + + // --- Preset definitions --- + + @Test + fun onlyWhenNeededPreset_onlyOnionEnabled() { + assertTrue(torOnlyWhenNeededPreset.onionRelaysViaTor) + assertFalse(torOnlyWhenNeededPreset.dmRelaysViaTor) + assertFalse(torOnlyWhenNeededPreset.newRelaysViaTor) + assertFalse(torOnlyWhenNeededPreset.trustedRelaysViaTor) + assertFalse(torOnlyWhenNeededPreset.urlPreviewsViaTor) + assertFalse(torOnlyWhenNeededPreset.profilePicsViaTor) + assertFalse(torOnlyWhenNeededPreset.imagesViaTor) + assertFalse(torOnlyWhenNeededPreset.videosViaTor) + assertFalse(torOnlyWhenNeededPreset.moneyOperationsViaTor) + assertFalse(torOnlyWhenNeededPreset.nip05VerificationsViaTor) + assertFalse(torOnlyWhenNeededPreset.mediaUploadsViaTor) + } + + @Test + fun defaultPreset_onionDmNewEnabled() { + assertTrue(torDefaultPreset.onionRelaysViaTor) + assertTrue(torDefaultPreset.dmRelaysViaTor) + assertTrue(torDefaultPreset.newRelaysViaTor) + assertFalse(torDefaultPreset.trustedRelaysViaTor) + assertFalse(torDefaultPreset.urlPreviewsViaTor) + assertFalse(torDefaultPreset.imagesViaTor) + assertFalse(torDefaultPreset.videosViaTor) + assertFalse(torDefaultPreset.moneyOperationsViaTor) + assertFalse(torDefaultPreset.nip05VerificationsViaTor) + assertFalse(torDefaultPreset.mediaUploadsViaTor) + } + + @Test + fun smallPayloadsPreset_addsPreviewsNip05Money() { + assertTrue(torSmallPayloadsPreset.onionRelaysViaTor) + assertTrue(torSmallPayloadsPreset.dmRelaysViaTor) + assertTrue(torSmallPayloadsPreset.newRelaysViaTor) + assertTrue(torSmallPayloadsPreset.trustedRelaysViaTor) + assertTrue(torSmallPayloadsPreset.urlPreviewsViaTor) + assertTrue(torSmallPayloadsPreset.profilePicsViaTor) + assertFalse(torSmallPayloadsPreset.imagesViaTor) + assertFalse(torSmallPayloadsPreset.videosViaTor) + assertTrue(torSmallPayloadsPreset.moneyOperationsViaTor) + assertTrue(torSmallPayloadsPreset.nip05VerificationsViaTor) + assertFalse(torSmallPayloadsPreset.mediaUploadsViaTor) + } + + @Test + fun fullPrivacyPreset_allEnabled() { + assertTrue(torFullyPrivate.onionRelaysViaTor) + assertTrue(torFullyPrivate.dmRelaysViaTor) + assertTrue(torFullyPrivate.newRelaysViaTor) + assertTrue(torFullyPrivate.trustedRelaysViaTor) + assertTrue(torFullyPrivate.urlPreviewsViaTor) + assertTrue(torFullyPrivate.profilePicsViaTor) + assertTrue(torFullyPrivate.imagesViaTor) + assertTrue(torFullyPrivate.videosViaTor) + assertTrue(torFullyPrivate.moneyOperationsViaTor) + assertTrue(torFullyPrivate.nip05VerificationsViaTor) + assertTrue(torFullyPrivate.mediaUploadsViaTor) + } + + // --- Preset hierarchy: each level is a superset of the previous --- + + @Test + fun presets_areIncreasing_defaultSupersetOfOnlyWhenNeeded() { + // Default enables DM + new relays on top of onlyWhenNeeded + assertTrue(torDefaultPreset.dmRelaysViaTor) + assertTrue(torDefaultPreset.newRelaysViaTor) + assertFalse(torOnlyWhenNeededPreset.dmRelaysViaTor) + assertFalse(torOnlyWhenNeededPreset.newRelaysViaTor) + } + + @Test + fun presets_areIncreasing_fullPrivacySupersetOfSmallPayloads() { + // Full privacy adds images, videos, media uploads + assertTrue(torFullyPrivate.imagesViaTor) + assertTrue(torFullyPrivate.videosViaTor) + assertTrue(torFullyPrivate.mediaUploadsViaTor) + assertFalse(torSmallPayloadsPreset.imagesViaTor) + assertFalse(torSmallPayloadsPreset.videosViaTor) + assertFalse(torSmallPayloadsPreset.mediaUploadsViaTor) + } + + // --- whichPreset --- + + @Test + fun whichPreset_matchesOnlyWhenNeeded() { + assertEquals(TorPresetType.ONLY_WHEN_NEEDED, whichPreset(torOnlyWhenNeededPreset)) + } + + @Test + fun whichPreset_matchesDefault() { + assertEquals(TorPresetType.DEFAULT, whichPreset(torDefaultPreset)) + } + + @Test + fun whichPreset_matchesSmallPayloads() { + assertEquals(TorPresetType.SMALL_PAYLOADS, whichPreset(torSmallPayloadsPreset)) + } + + @Test + fun whichPreset_matchesFullPrivacy() { + assertEquals(TorPresetType.FULL_PRIVACY, whichPreset(torFullyPrivate)) + } + + @Test + fun whichPreset_returnsCustomForMixedSettings() { + val mixed = + TorSettings( + onionRelaysViaTor = true, + dmRelaysViaTor = true, + newRelaysViaTor = false, // differs from DEFAULT + trustedRelaysViaTor = true, // differs from DEFAULT + ) + assertEquals(TorPresetType.CUSTOM, whichPreset(mixed)) + } + + @Test + fun whichPreset_ignoresProfilePicsInComparison() { + // profilePicsViaTor is commented out in isPreset() + val withProfilePics = torDefaultPreset.copy(profilePicsViaTor = true) + assertEquals(TorPresetType.DEFAULT, whichPreset(withProfilePics)) + } + + @Test + fun whichPreset_ignoresTorTypeAndPort() { + // whichPreset only compares boolean flags, not torType/port + val withExternal = torDefaultPreset.copy(torType = TorType.EXTERNAL, externalSocksPort = 1234) + assertEquals(TorPresetType.DEFAULT, whichPreset(withExternal)) + } + + // --- isPreset --- + + @Test + fun isPreset_exactMatch_returnsTrue() { + assertTrue(isPreset(torFullyPrivate, torFullyPrivate)) + } + + @Test + fun isPreset_differentFlag_returnsFalse() { + val modified = torFullyPrivate.copy(imagesViaTor = false) + assertFalse(isPreset(modified, torFullyPrivate)) + } + + @Test + fun isPreset_torTypeDifference_ignored() { + val withOff = torDefaultPreset.copy(torType = TorType.OFF) + assertTrue(isPreset(withOff, torDefaultPreset)) + } + + // --- TorSettings data class --- + + @Test + fun torSettings_defaultValues() { + val defaults = TorSettings() + assertEquals(TorType.INTERNAL, defaults.torType) + assertEquals(9050, defaults.externalSocksPort) + assertTrue(defaults.onionRelaysViaTor) + assertTrue(defaults.dmRelaysViaTor) + assertTrue(defaults.newRelaysViaTor) + assertFalse(defaults.trustedRelaysViaTor) + } + + @Test + fun torSettings_equality_worksForDistinctUntilChanged() { + val a = TorSettings(torType = TorType.INTERNAL, externalSocksPort = 9050) + val b = TorSettings(torType = TorType.INTERNAL, externalSocksPort = 9050) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun torSettings_copy_changesOneField() { + val original = TorSettings() + val modified = original.copy(torType = TorType.OFF) + assertEquals(TorType.OFF, modified.torType) + assertEquals(original.externalSocksPort, modified.externalSocksPort) + assertNotEquals(original, modified) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/ITorManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/ITorManager.kt new file mode 100644 index 000000000..3beb43350 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/ITorManager.kt @@ -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.tor + +import kotlinx.coroutines.flow.StateFlow + +/** + * Platform-agnostic interface for Tor daemon management. + * + * Implementations are reactive: status is derived from settings changes. + * Android uses tor-android + jtorctl. Desktop uses kmp-tor. + */ +interface ITorManager { + val status: StateFlow + val activePortOrNull: StateFlow + + suspend fun dormant() + + suspend fun active() + + suspend fun newIdentity() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/ITorSettingsPersistence.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/ITorSettingsPersistence.kt new file mode 100644 index 000000000..03f81bbc3 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/ITorSettingsPersistence.kt @@ -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.commons.tor + +/** + * Platform-agnostic interface for persisting Tor settings. + * + * Android uses DataStore. Desktop uses java.util.prefs.Preferences. + */ +interface ITorSettingsPersistence { + fun load(): TorSettings + + fun save(settings: TorSettings) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt new file mode 100644 index 000000000..eb5ddbacd --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt @@ -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.tor + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion + +class TorRelayEvaluation( + val torSettings: TorRelaySettings, + val trustedRelayList: Set, + val dmRelayList: Set, +) { + fun useTor(relay: NormalizedRelayUrl): Boolean = + if (torSettings.torType == TorType.OFF) { + false + } else { + if (relay.isLocalHost()) { + false + } else if (relay.isOnion()) { + torSettings.onionRelaysViaTor + } else if (relay in dmRelayList) { + torSettings.dmRelaysViaTor + } else if (relay in trustedRelayList) { + torSettings.trustedRelaysViaTor + } else { + torSettings.newRelaysViaTor + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelaySettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelaySettings.kt new file mode 100644 index 000000000..cc0e7e314 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelaySettings.kt @@ -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.tor + +data class TorRelaySettings( + val torType: TorType = TorType.OFF, + val onionRelaysViaTor: Boolean = true, + val dmRelaysViaTor: Boolean = false, + val newRelaysViaTor: Boolean = false, + val trustedRelaysViaTor: Boolean = false, +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorServiceStatus.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorServiceStatus.kt new file mode 100644 index 000000000..dbe8668f7 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorServiceStatus.kt @@ -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.tor + +sealed class TorServiceStatus { + data class Active( + val port: Int, + ) : TorServiceStatus() + + data object Off : TorServiceStatus() + + data object Connecting : TorServiceStatus() + + data class Error( + val message: String, + ) : TorServiceStatus() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorSettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorSettings.kt new file mode 100644 index 000000000..3e734f51f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorSettings.kt @@ -0,0 +1,153 @@ +/* + * 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.tor + +data class TorSettings( + val torType: TorType = TorType.INTERNAL, + val externalSocksPort: Int = 9050, + val onionRelaysViaTor: Boolean = true, + val dmRelaysViaTor: Boolean = true, + val newRelaysViaTor: Boolean = true, + val trustedRelaysViaTor: Boolean = false, + val urlPreviewsViaTor: Boolean = false, + val profilePicsViaTor: Boolean = false, + val imagesViaTor: Boolean = false, + val videosViaTor: Boolean = false, + val moneyOperationsViaTor: Boolean = false, + val nip05VerificationsViaTor: Boolean = false, + val mediaUploadsViaTor: Boolean = false, +) + +enum class TorType( + val screenCode: Int, +) { + OFF(0), + INTERNAL(1), + EXTERNAL(2), +} + +fun parseTorType(code: Int?): TorType = + when (code) { + TorType.OFF.screenCode -> TorType.OFF + TorType.INTERNAL.screenCode -> TorType.INTERNAL + TorType.EXTERNAL.screenCode -> TorType.EXTERNAL + else -> TorType.INTERNAL + } + +enum class TorPresetType( + val screenCode: Int, +) { + ONLY_WHEN_NEEDED(0), + DEFAULT(1), + SMALL_PAYLOADS(2), + FULL_PRIVACY(3), + CUSTOM(4), +} + +fun parseTorPresetType(code: Int?): TorPresetType = + when (code) { + TorPresetType.ONLY_WHEN_NEEDED.screenCode -> TorPresetType.ONLY_WHEN_NEEDED + TorPresetType.DEFAULT.screenCode -> TorPresetType.DEFAULT + TorPresetType.SMALL_PAYLOADS.screenCode -> TorPresetType.SMALL_PAYLOADS + TorPresetType.FULL_PRIVACY.screenCode -> TorPresetType.FULL_PRIVACY + else -> TorPresetType.CUSTOM + } + +fun isPreset( + torSettings: TorSettings, + preset: TorSettings, +): Boolean = + torSettings.onionRelaysViaTor == preset.onionRelaysViaTor && + torSettings.dmRelaysViaTor == preset.dmRelaysViaTor && + torSettings.newRelaysViaTor == preset.newRelaysViaTor && + torSettings.trustedRelaysViaTor == preset.trustedRelaysViaTor && + torSettings.urlPreviewsViaTor == preset.urlPreviewsViaTor && + // torSettings.profilePicsViaTor == preset.profilePicsViaTor && + torSettings.imagesViaTor == preset.imagesViaTor && + torSettings.videosViaTor == preset.videosViaTor && + torSettings.moneyOperationsViaTor == preset.moneyOperationsViaTor && + torSettings.nip05VerificationsViaTor == preset.nip05VerificationsViaTor && + torSettings.mediaUploadsViaTor == preset.mediaUploadsViaTor + +fun whichPreset(torSettings: TorSettings): TorPresetType { + if (isPreset(torSettings, torOnlyWhenNeededPreset)) return TorPresetType.ONLY_WHEN_NEEDED + if (isPreset(torSettings, torDefaultPreset)) return TorPresetType.DEFAULT + if (isPreset(torSettings, torSmallPayloadsPreset)) return TorPresetType.SMALL_PAYLOADS + if (isPreset(torSettings, torFullyPrivate)) return TorPresetType.FULL_PRIVACY + return TorPresetType.CUSTOM +} + +val torOnlyWhenNeededPreset = + TorSettings( + onionRelaysViaTor = true, + dmRelaysViaTor = false, + newRelaysViaTor = false, + trustedRelaysViaTor = false, + urlPreviewsViaTor = false, + profilePicsViaTor = false, + imagesViaTor = false, + videosViaTor = false, + moneyOperationsViaTor = false, + nip05VerificationsViaTor = false, + mediaUploadsViaTor = false, + ) +val torDefaultPreset = + TorSettings( + onionRelaysViaTor = true, + dmRelaysViaTor = true, + newRelaysViaTor = true, + trustedRelaysViaTor = false, + urlPreviewsViaTor = false, + profilePicsViaTor = false, + imagesViaTor = false, + videosViaTor = false, + moneyOperationsViaTor = false, + nip05VerificationsViaTor = false, + mediaUploadsViaTor = false, + ) +val torSmallPayloadsPreset = + TorSettings( + onionRelaysViaTor = true, + dmRelaysViaTor = true, + newRelaysViaTor = true, + trustedRelaysViaTor = true, + urlPreviewsViaTor = true, + profilePicsViaTor = true, + imagesViaTor = false, + videosViaTor = false, + moneyOperationsViaTor = true, + nip05VerificationsViaTor = true, + mediaUploadsViaTor = false, + ) +val torFullyPrivate = + TorSettings( + onionRelaysViaTor = true, + dmRelaysViaTor = true, + newRelaysViaTor = true, + trustedRelaysViaTor = true, + urlPreviewsViaTor = true, + profilePicsViaTor = true, + imagesViaTor = true, + videosViaTor = true, + moneyOperationsViaTor = true, + nip05VerificationsViaTor = true, + mediaUploadsViaTor = true, + ) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluationTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluationTest.kt new file mode 100644 index 000000000..c5e65775d --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluationTest.kt @@ -0,0 +1,147 @@ +/* + * 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.tor + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TorRelayEvaluationTest { + private val clearnetRelay = NormalizedRelayUrl("wss://relay.damus.io/") + private val onionRelay = NormalizedRelayUrl("wss://abc123.onion/") + private val localhostRelay = NormalizedRelayUrl("ws://127.0.0.1:8080/") + private val localhostNameRelay = NormalizedRelayUrl("ws://localhost:8080/") + private val localNetworkRelay = NormalizedRelayUrl("ws://192.168.1.100:8080/") + private val dmRelay = NormalizedRelayUrl("wss://dm.relay.com/") + private val trustedRelay = NormalizedRelayUrl("wss://trusted.relay.com/") + + private fun buildEvaluation( + torType: TorType = TorType.INTERNAL, + onionViaTor: Boolean = true, + dmViaTor: Boolean = true, + newViaTor: Boolean = true, + trustedViaTor: Boolean = false, + dmRelays: Set = setOf(dmRelay), + trustedRelays: Set = setOf(trustedRelay), + ) = TorRelayEvaluation( + torSettings = + TorRelaySettings( + torType = torType, + onionRelaysViaTor = onionViaTor, + dmRelaysViaTor = dmViaTor, + newRelaysViaTor = newViaTor, + trustedRelaysViaTor = trustedViaTor, + ), + trustedRelayList = trustedRelays, + dmRelayList = dmRelays, + ) + + // --- Tor OFF --- + @Test + fun torOff_alwaysFalse() { + val eval = buildEvaluation(torType = TorType.OFF) + assertFalse(eval.useTor(clearnetRelay)) + assertFalse(eval.useTor(onionRelay)) + assertFalse(eval.useTor(dmRelay)) + } + + // --- Localhost bypass --- + @Test + fun localhost_alwaysFalse() { + val eval = buildEvaluation() + assertFalse(eval.useTor(localhostRelay)) + assertFalse(eval.useTor(localhostNameRelay)) + assertFalse(eval.useTor(localNetworkRelay)) + } + + // --- .onion --- + @Test + fun onion_enabled_returnsTrue() { + val eval = buildEvaluation(onionViaTor = true) + assertTrue(eval.useTor(onionRelay)) + } + + @Test + fun onion_disabled_returnsFalse() { + val eval = buildEvaluation(onionViaTor = false) + assertFalse(eval.useTor(onionRelay)) + } + + // --- DM relays --- + @Test + fun dm_enabled_returnsTrue() = assertTrue(buildEvaluation(dmViaTor = true).useTor(dmRelay)) + + @Test + fun dm_disabled_returnsFalse() = assertFalse(buildEvaluation(dmViaTor = false).useTor(dmRelay)) + + // --- Trusted relays --- + @Test + fun trusted_enabled_returnsTrue() = assertTrue(buildEvaluation(trustedViaTor = true).useTor(trustedRelay)) + + @Test + fun trusted_disabled_returnsFalse() = assertFalse(buildEvaluation(trustedViaTor = false).useTor(trustedRelay)) + + // --- New/unknown relays --- + @Test + fun unknown_enabled_returnsTrue() = assertTrue(buildEvaluation(newViaTor = true).useTor(clearnetRelay)) + + @Test + fun unknown_disabled_returnsFalse() = assertFalse(buildEvaluation(newViaTor = false).useTor(clearnetRelay)) + + // --- Priority --- + @Test + fun onionInDmList_treatedAsOnion() { + val onionDm = NormalizedRelayUrl("wss://dmrelay.onion/") + val eval = buildEvaluation(onionViaTor = false, dmViaTor = true, dmRelays = setOf(onionDm)) + assertFalse(eval.useTor(onionDm)) // onion check first, disabled + } + + @Test + fun relayInBothDmAndTrusted_dmTakesPrecedence() { + val both = NormalizedRelayUrl("wss://both.relay.com/") + val eval = buildEvaluation(dmViaTor = true, trustedViaTor = false, dmRelays = setOf(both), trustedRelays = setOf(both)) + assertTrue(eval.useTor(both)) + } + + @Test + fun relayInBothDmAndTrusted_dmDisabled_noFallToTrusted() { + val both = NormalizedRelayUrl("wss://both.relay.com/") + val eval = buildEvaluation(dmViaTor = false, trustedViaTor = true, dmRelays = setOf(both), trustedRelays = setOf(both)) + assertFalse(eval.useTor(both)) + } + + // --- Empty lists --- + @Test + fun emptyLists_allAreNew() { + val eval = buildEvaluation(newViaTor = true, dmRelays = emptySet(), trustedRelays = emptySet()) + assertTrue(eval.useTor(clearnetRelay)) + assertTrue(eval.useTor(dmRelay)) // not in list, treated as new + } + + // --- External mode --- + @Test + fun torExternal_routesLikeInternal() { + val eval = buildEvaluation(torType = TorType.EXTERNAL, newViaTor = true) + assertTrue(eval.useTor(clearnetRelay)) + assertFalse(eval.useTor(localhostRelay)) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorSettingsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorSettingsTest.kt new file mode 100644 index 000000000..1dfb214a3 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorSettingsTest.kt @@ -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.commons.tor + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class TorSettingsTest { + @Test + fun parseTorType_code0_returnsOff() = assertEquals(TorType.OFF, parseTorType(0)) + + @Test + fun parseTorType_code1_returnsInternal() = assertEquals(TorType.INTERNAL, parseTorType(1)) + + @Test + fun parseTorType_code2_returnsExternal() = assertEquals(TorType.EXTERNAL, parseTorType(2)) + + @Test + fun parseTorType_null_defaultsToInternal() = assertEquals(TorType.INTERNAL, parseTorType(null)) + + @Test + fun parseTorType_unknownCode_defaultsToInternal() = assertEquals(TorType.INTERNAL, parseTorType(99)) + + @Test + fun parseTorType_negativeCode_defaultsToInternal() = assertEquals(TorType.INTERNAL, parseTorType(-1)) + + @Test + fun torType_screenCodes_areUnique() { + val codes = TorType.entries.map { it.screenCode } + assertEquals(codes.size, codes.toSet().size) + } + + @Test + fun torType_allValues_roundTripViaParse() { + TorType.entries.forEach { type -> + assertEquals(type, parseTorType(type.screenCode)) + } + } + + @Test + fun parseTorPresetType_code0_returnsOnlyWhenNeeded() = assertEquals(TorPresetType.ONLY_WHEN_NEEDED, parseTorPresetType(0)) + + @Test + fun parseTorPresetType_code1_returnsDefault() = assertEquals(TorPresetType.DEFAULT, parseTorPresetType(1)) + + @Test + fun parseTorPresetType_code2_returnsSmallPayloads() = assertEquals(TorPresetType.SMALL_PAYLOADS, parseTorPresetType(2)) + + @Test + fun parseTorPresetType_code3_returnsFullPrivacy() = assertEquals(TorPresetType.FULL_PRIVACY, parseTorPresetType(3)) + + @Test + fun parseTorPresetType_unknownCode_defaultsToCustom() = assertEquals(TorPresetType.CUSTOM, parseTorPresetType(99)) + + @Test + fun parseTorPresetType_null_defaultsToCustom() = assertEquals(TorPresetType.CUSTOM, parseTorPresetType(null)) + + @Test + fun torPresetType_screenCodes_areUnique() { + val codes = TorPresetType.entries.map { it.screenCode } + assertEquals(codes.size, codes.toSet().size) + } + + @Test + fun onlyWhenNeededPreset_onlyOnionEnabled() { + assertTrue(torOnlyWhenNeededPreset.onionRelaysViaTor) + assertFalse(torOnlyWhenNeededPreset.dmRelaysViaTor) + assertFalse(torOnlyWhenNeededPreset.newRelaysViaTor) + assertFalse(torOnlyWhenNeededPreset.trustedRelaysViaTor) + assertFalse(torOnlyWhenNeededPreset.urlPreviewsViaTor) + assertFalse(torOnlyWhenNeededPreset.imagesViaTor) + assertFalse(torOnlyWhenNeededPreset.videosViaTor) + assertFalse(torOnlyWhenNeededPreset.moneyOperationsViaTor) + assertFalse(torOnlyWhenNeededPreset.nip05VerificationsViaTor) + assertFalse(torOnlyWhenNeededPreset.mediaUploadsViaTor) + } + + @Test + fun defaultPreset_onionDmNewEnabled() { + assertTrue(torDefaultPreset.onionRelaysViaTor) + assertTrue(torDefaultPreset.dmRelaysViaTor) + assertTrue(torDefaultPreset.newRelaysViaTor) + assertFalse(torDefaultPreset.trustedRelaysViaTor) + assertFalse(torDefaultPreset.urlPreviewsViaTor) + } + + @Test + fun fullPrivacyPreset_allEnabled() { + assertTrue(torFullyPrivate.onionRelaysViaTor) + assertTrue(torFullyPrivate.dmRelaysViaTor) + assertTrue(torFullyPrivate.newRelaysViaTor) + assertTrue(torFullyPrivate.trustedRelaysViaTor) + assertTrue(torFullyPrivate.urlPreviewsViaTor) + assertTrue(torFullyPrivate.imagesViaTor) + assertTrue(torFullyPrivate.videosViaTor) + assertTrue(torFullyPrivate.moneyOperationsViaTor) + assertTrue(torFullyPrivate.nip05VerificationsViaTor) + assertTrue(torFullyPrivate.mediaUploadsViaTor) + } + + @Test + fun whichPreset_matchesOnlyWhenNeeded() = assertEquals(TorPresetType.ONLY_WHEN_NEEDED, whichPreset(torOnlyWhenNeededPreset)) + + @Test + fun whichPreset_matchesDefault() = assertEquals(TorPresetType.DEFAULT, whichPreset(torDefaultPreset)) + + @Test + fun whichPreset_matchesSmallPayloads() = assertEquals(TorPresetType.SMALL_PAYLOADS, whichPreset(torSmallPayloadsPreset)) + + @Test + fun whichPreset_matchesFullPrivacy() = assertEquals(TorPresetType.FULL_PRIVACY, whichPreset(torFullyPrivate)) + + @Test + fun whichPreset_returnsCustomForMixedSettings() { + val mixed = + TorSettings( + onionRelaysViaTor = true, + dmRelaysViaTor = true, + newRelaysViaTor = false, + trustedRelaysViaTor = true, + ) + assertEquals(TorPresetType.CUSTOM, whichPreset(mixed)) + } + + @Test + fun whichPreset_ignoresProfilePicsInComparison() { + val withProfilePics = torDefaultPreset.copy(profilePicsViaTor = true) + assertEquals(TorPresetType.DEFAULT, whichPreset(withProfilePics)) + } + + @Test + fun isPreset_exactMatch_returnsTrue() = assertTrue(isPreset(torFullyPrivate, torFullyPrivate)) + + @Test + fun isPreset_differentFlag_returnsFalse() { + val modified = torFullyPrivate.copy(imagesViaTor = false) + assertFalse(isPreset(modified, torFullyPrivate)) + } + + @Test + fun torSettings_defaultValues() { + val defaults = TorSettings() + assertEquals(TorType.INTERNAL, defaults.torType) + assertEquals(9050, defaults.externalSocksPort) + } + + @Test + fun torSettings_equality() { + val a = TorSettings(torType = TorType.INTERNAL, externalSocksPort = 9050) + val b = TorSettings(torType = TorType.INTERNAL, externalSocksPort = 9050) + assertEquals(a, b) + } + + @Test + fun torSettings_copy_changesOneField() { + val original = TorSettings() + val modified = original.copy(torType = TorType.OFF) + assertEquals(TorType.OFF, modified.torType) + assertNotEquals(original, modified) + } +} diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 0ea2cde8f..f413928a3 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -62,6 +62,10 @@ dependencies { implementation(libs.kotlinx.collections.immutable) implementation(libs.androidx.collection) + // Tor daemon (desktop embedded via kmp-tor) + implementation(libs.kmp.tor.runtime) + implementation(libs.kmp.tor.resource.exec.tor) + // SLF4J no-op — silence "No SLF4J providers" warnings from transitive deps implementation(libs.slf4j.nop) @@ -85,6 +89,7 @@ compose.desktop { nativeDistributions { appResourcesRootDir.set(project.layout.projectDirectory.dir("src/jvmMain/appResources")) targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + modules("java.management") // Required by kmp-tor TorRuntime packageName = "Amethyst" packageVersion = "1.0.0" diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index ee2123873..7d72e82e3 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -55,6 +55,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -159,6 +160,10 @@ sealed class DesktopScreen { data object Settings : DesktopScreen() } +/** Reference to active Tor manager for shutdown hook. Set by App composable. */ +@Volatile +private var activeTorManager: com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager? = null + fun main() { Log.minLevel = LogLevel.DEBUG DesktopImageLoaderSetup.setup() @@ -167,6 +172,8 @@ fun main() { com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer .shutdown() VlcjPlayerPool.shutdown() + // Stop Tor daemon if running — reference set by App composable + activeTorManager?.stopSync() }, ) // Pre-init VLC on background thread so first play is fast @@ -178,6 +185,7 @@ fun main() { height = 800.dp, position = WindowPosition.Aligned(Alignment.Center), ) + var appRestartKey by remember { mutableStateOf(0) } var showComposeDialog by remember { mutableStateOf(false) } var replyToNote by remember { mutableStateOf(null) } val deckScope = rememberCoroutineScope() @@ -185,6 +193,23 @@ fun main() { val accountManager = remember { AccountManager.create() } val accountState by accountManager.accountState.collectAsState() var showAddColumnDialog by remember { mutableStateOf(false) } + + // Tor state at Window level — survives key() app rebuild + var torSettings by remember { + mutableStateOf( + com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences + .load(), + ) + } + val torTypeFlow = remember { kotlinx.coroutines.flow.MutableStateFlow(torSettings.torType) } + val externalPortFlow = remember { kotlinx.coroutines.flow.MutableStateFlow(torSettings.externalSocksPort) } + val windowScope = rememberCoroutineScope() + val torManager = + remember { + com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager(torTypeFlow, externalPortFlow, windowScope).also { + activeTorManager = it + } + } var layoutMode by remember { mutableStateOf( try { @@ -404,25 +429,32 @@ fun main() { LocalAwtWindow provides window, LocalIsImmersiveFullscreen provides immersiveFullscreenState, ) { - App( - layoutMode = layoutMode, - deckState = deckState, - accountManager = accountManager, - showComposeDialog = showComposeDialog, - showAddColumnDialog = showAddColumnDialog, - onShowComposeDialog = { showComposeDialog = true }, - onShowReplyDialog = { event -> - replyToNote = event - showComposeDialog = true - }, - onDismissComposeDialog = { - showComposeDialog = false - replyToNote = null - }, - onDismissAddColumnDialog = { showAddColumnDialog = false }, - onShowAddColumnDialog = { showAddColumnDialog = true }, - replyToNote = replyToNote, - ) + key(appRestartKey) { + App( + layoutMode = layoutMode, + deckState = deckState, + accountManager = accountManager, + showComposeDialog = showComposeDialog, + showAddColumnDialog = showAddColumnDialog, + onShowComposeDialog = { showComposeDialog = true }, + onShowReplyDialog = { event -> + replyToNote = event + showComposeDialog = true + }, + onDismissComposeDialog = { + showComposeDialog = false + replyToNote = null + }, + onDismissAddColumnDialog = { showAddColumnDialog = false }, + onShowAddColumnDialog = { showAddColumnDialog = true }, + replyToNote = replyToNote, + onRestartApp = { appRestartKey++ }, + torManager = torManager, + torTypeFlow = torTypeFlow, + externalPortFlow = externalPortFlow, + initialTorSettings = torSettings, + ) + } } } } @@ -441,12 +473,102 @@ fun App( onDismissAddColumnDialog: () -> Unit, onShowAddColumnDialog: () -> Unit, replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?, + onRestartApp: () -> Unit = {}, + torManager: com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager, + torTypeFlow: kotlinx.coroutines.flow.MutableStateFlow, + externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow, + initialTorSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings, ) { - val relayManager = remember { DesktopRelayConnectionManager() } + // Always reload from prefs — after key() rebuild, prefs have the latest saved settings + var torSettings by remember { + mutableStateOf( + com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences + .load(), + ) + } + + // Gate: block EVERYTHING until Tor proxy is ready (when Tor expected) + // This must be before any OkHttpClient/Coil/relay creation + val torStatus by torManager.status.collectAsState() + val isTorExpected = torSettings.torType != com.vitorpamplona.amethyst.commons.tor.TorType.OFF + if (isTorExpected && torStatus !is com.vitorpamplona.amethyst.commons.tor.TorServiceStatus.Active) { + androidx.compose.foundation.layout.Box( + modifier = + androidx.compose.ui.Modifier + .fillMaxSize(), + contentAlignment = androidx.compose.ui.Alignment.Center, + ) { + androidx.compose.foundation.layout.Column( + horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally, + ) { + androidx.compose.material3.CircularProgressIndicator() + androidx.compose.foundation.layout.Spacer( + modifier = + androidx.compose.ui.Modifier + .height(16.dp), + ) + if (torStatus is com.vitorpamplona.amethyst.commons.tor.TorServiceStatus.Error) { + androidx.compose.material3.Text( + "Tor error: ${(torStatus as com.vitorpamplona.amethyst.commons.tor.TorServiceStatus.Error).message}", + ) + } else { + androidx.compose.material3.Text("Connecting to Tor...") + } + } + } + return // Nothing below runs until Tor is Active + } + val localCache = remember { DesktopLocalCache() } val accountState by accountManager.accountState.collectAsState() val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) } + // Build TorRelayEvaluation for per-relay routing + val torRelayEvaluation = + remember(torSettings) { + com.vitorpamplona.amethyst.commons.tor.TorRelayEvaluation( + torSettings = + com.vitorpamplona.amethyst.commons.tor.TorRelaySettings( + torType = torSettings.torType, + onionRelaysViaTor = torSettings.onionRelaysViaTor, + dmRelaysViaTor = torSettings.dmRelaysViaTor, + newRelaysViaTor = torSettings.newRelaysViaTor, + trustedRelaysViaTor = torSettings.trustedRelaysViaTor, + ), + trustedRelayList = emptySet(), // TODO: populate from account relay lists + dmRelayList = emptySet(), // TODO: populate from account relay lists + ) + } + + val httpClient = + remember(torRelayEvaluation) { + com.vitorpamplona.amethyst.desktop.network + .DesktopHttpClient( + torManager = torManager, + shouldUseTorForRelay = { url -> torRelayEvaluation.useTor(url) }, + torTypeProvider = { torTypeFlow.value }, + scope = scope, + ).also { + com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient + .setInstance(it) + } + } + // Clean up old httpClient's connection pool on rebuild + DisposableEffect(httpClient) { + onDispose { + httpClient.sharedConnectionPool.evictAll() + } + } + + // Reinitialize Coil after setInstance so image loads use the Tor-aware client + remember(httpClient) { + httpClient.evictConnections() + com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup + .setup() + } + + val relayManager = remember(httpClient) { DesktopRelayConnectionManager(httpClient) } + // Subscriptions coordinator — uses default relay URLs for metadata indexing. // Feed subscriptions (inside MainContent) drive actual relay pool connections. val subscriptionsCoordinator = @@ -541,20 +663,39 @@ fun App( accountManager.loadNwcConnection() } - MainContent( - layoutMode = layoutMode, - deckState = deckState, - relayManager = relayManager, - localCache = localCache, - accountManager = accountManager, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - appScope = scope, - onShowComposeDialog = onShowComposeDialog, - onShowReplyDialog = onShowReplyDialog, - onShowAddColumnDialog = onShowAddColumnDialog, - ) + val currentTorStatus = torManager.status.collectAsState().value + androidx.compose.runtime.CompositionLocalProvider( + com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState provides + com.vitorpamplona.amethyst.desktop.ui.tor.TorState( + status = currentTorStatus, + settings = torSettings, + onSettingsChanged = { newSettings -> + torSettings = newSettings + com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences + .save(newSettings) + torTypeFlow.value = newSettings.torType + externalPortFlow.value = newSettings.externalSocksPort + // Rebuild app to apply Tor changes + onRestartApp() + }, + ), + ) { + MainContent( + layoutMode = layoutMode, + deckState = deckState, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = scope, + torStatus = currentTorStatus, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onShowAddColumnDialog = onShowAddColumnDialog, + ) + } // Compose dialog if (showComposeDialog) { @@ -602,6 +743,7 @@ fun MainContent( nwcConnection: Nip47WalletConnect.Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, appScope: CoroutineScope, + torStatus: com.vitorpamplona.amethyst.commons.tor.TorServiceStatus, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onShowAddColumnDialog: () -> Unit, @@ -774,6 +916,7 @@ fun MainContent( }, signerConnectionState = signerConnectionState, lastPingTimeSec = lastPingTimeSec, + torStatus = torStatus, ) VerticalDivider() @@ -857,6 +1000,11 @@ fun RelaySettingsScreen( relayManager: DesktopRelayConnectionManager, account: AccountState.LoggedIn, accountManager: AccountManager, + torStatus: com.vitorpamplona.amethyst.commons.tor.TorServiceStatus = com.vitorpamplona.amethyst.commons.tor.TorServiceStatus.Off, + torSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings = + com.vitorpamplona.amethyst.commons.tor + .TorSettings(torType = com.vitorpamplona.amethyst.commons.tor.TorType.OFF), + onTorSettingsChanged: (com.vitorpamplona.amethyst.commons.tor.TorSettings) -> Unit = {}, ) { val relayStatuses by relayManager.relayStatuses.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState() @@ -972,6 +1120,16 @@ fun RelaySettingsScreen( HorizontalDivider() Spacer(Modifier.height(24.dp)) + // Tor Settings + com.vitorpamplona.amethyst.desktop.ui.tor.TorSettingsSection( + torStatus = torStatus, + currentSettings = torSettings, + onSettingsChanged = onTorSettingsChanged, + ) + Spacer(Modifier.height(24.dp)) + HorizontalDivider() + Spacer(Modifier.height(24.dp)) + // Developer Settings Section (only in debug mode) if (DebugConfig.isDebugMode) { com.vitorpamplona.amethyst.desktop.ui diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt index 7dd9b9f8e..58df5131f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt @@ -138,7 +138,7 @@ class AccountManager internal constructor( private suspend fun getOrCreateNip46Client(): INostrClient = nip46ClientMutex.withLock { nip46Client ?: NostrClient( - BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient), + BasicOkHttpWebSocket.Builder { url -> DesktopHttpClient.currentClient() }, ).also { nip46Client = it it.connect() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClient.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClient.kt index b9641dac9..a63091f7a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClient.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClient.kt @@ -20,21 +20,160 @@ */ package com.vitorpamplona.amethyst.desktop.network +import com.vitorpamplona.amethyst.commons.tor.ITorManager +import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import okhttp3.ConnectionPool import okhttp3.OkHttpClient +import java.net.InetSocketAddress +import java.net.Proxy import java.util.concurrent.TimeUnit -object DesktopHttpClient { - private val client: OkHttpClient by lazy { +/** + * Desktop HTTP client with optional SOCKS proxy support for Tor. + * + * Maintains two clients: one with SOCKS proxy (for Tor-routed traffic) + * and one without (for direct connections). Selects the appropriate + * client per relay URL based on Tor settings. + * + * When Tor is OFF, all relays use the direct client. + * When Tor is ON, .onion relays always use the proxy client. + * Localhost relays always use the direct client. + */ +class DesktopHttpClient( + torManager: ITorManager, + private val shouldUseTorForRelay: (NormalizedRelayUrl) -> Boolean, + private val torTypeProvider: () -> TorType, + scope: CoroutineScope, +) { + /** Returns true if user expects Tor routing (INTERNAL or EXTERNAL mode). */ + fun isTorExpected(): Boolean = torTypeProvider() != TorType.OFF + + val sharedConnectionPool = ConnectionPool() + + /** Evict all idle connections — call on Tor state change to kill stale direct/proxy connections. */ + fun evictConnections() { + sharedConnectionPool.evictAll() + } + + private val directClient: OkHttpClient by lazy { OkHttpClient .Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .pingInterval(30, TimeUnit.SECONDS) + .connectionPool(sharedConnectionPool) + .connectTimeout(BASE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(BASE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .writeTimeout(BASE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .pingInterval(BASE_TIMEOUT_SECONDS, TimeUnit.SECONDS) .retryOnConnectionFailure(true) .build() } - fun getHttpClient(url: NormalizedRelayUrl): OkHttpClient = client + /** Proxy client, rebuilt when SOCKS port changes. */ + val proxyClient: StateFlow = + torManager.activePortOrNull + .map { port -> + if (port == null) { + null + } else { + val proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port)) + val torTimeout = BASE_TIMEOUT_SECONDS * TOR_TIMEOUT_MULTIPLIER + OkHttpClient + .Builder() + .connectionPool(sharedConnectionPool) + .proxy(proxy) + .connectTimeout(torTimeout, TimeUnit.SECONDS) + .readTimeout(torTimeout, TimeUnit.SECONDS) + .writeTimeout(torTimeout, TimeUnit.SECONDS) + .pingInterval(BASE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build() + } + }.stateIn(scope, SharingStarted.Eagerly, null) + + /** + * Returns the appropriate OkHttpClient for the given relay URL. + * + * - Localhost relays → always direct (no proxy) + * - .onion relays when Tor active → proxy client + * - Other relays → based on TorRelayEvaluation routing decision + * - If proxy needed but not available (Tor bootstrapping) → direct client as fallback + */ + fun getHttpClient(url: NormalizedRelayUrl): OkHttpClient { + if (url.isLocalHost()) return directClient + + val torActive = proxyClient.value != null + if (!torActive) return directClient + + // .onion always needs Tor + if (url.isOnion()) return proxyClient.value ?: directClient + + // Delegate to TorRelayEvaluation for routing decision + return if (shouldUseTorForRelay(url)) { + proxyClient.value ?: directClient + } else { + directClient + } + } + + /** Returns the direct (non-proxy) client for non-relay HTTP traffic. */ + fun getNonProxyClient(): OkHttpClient = directClient + + companion object { + private const val BASE_TIMEOUT_SECONDS = 30L + private const val TOR_TIMEOUT_MULTIPLIER = 2 // Desktop: 2x, not Android's 3x + + lateinit var instance: DesktopHttpClient + private set + + fun setInstance(client: DesktopHttpClient) { + instance = client + } + + /** Fail-closed client: SOCKS proxy on dead port 1. Requests fail instead of leaking IP. */ + private val failClosedClient: OkHttpClient by lazy { + OkHttpClient + .Builder() + .proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", 1))) + .connectTimeout(1, TimeUnit.SECONDS) + .readTimeout(1, TimeUnit.SECONDS) + .build() + } + + /** Simple direct client for pre-init only (tests, startup). */ + private val simpleClient: OkHttpClient by lazy { + OkHttpClient + .Builder() + .connectTimeout(BASE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(BASE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .writeTimeout(BASE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .pingInterval(BASE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build() + } + + /** + * Returns the current Tor-aware client. + * - Tor active → proxy client (SOCKS) + * - Tor off → direct client + * - Tor expected but bootstrapping → FAIL-CLOSED (dead proxy, requests fail not leak) + * - Instance not set → simpleClient (pre-init/tests only) + */ + fun currentClient(): OkHttpClient { + if (!::instance.isInitialized) return simpleClient + val client = instance + val proxyClient = client.proxyClient.value + if (proxyClient != null) return proxyClient + return if (client.isTorExpected()) failClosedClient else client.getNonProxyClient() + } + + /** Backward-compatible static accessor for relay WebSocket builder. */ + fun getSimpleHttpClient(url: NormalizedRelayUrl): OkHttpClient = currentClient() + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt index 58312aada..9b4dd1ed5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt @@ -24,9 +24,11 @@ import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSoc /** * Desktop-specific relay connection manager that configures OkHttp for websockets. - * Delegates to the shared RelayConnectionManager from commons. + * Now Tor-aware: passes the DesktopHttpClient's getHttpClient which selects + * proxy or direct client per relay URL based on Tor settings. */ -class DesktopRelayConnectionManager : - RelayConnectionManager( - websocketBuilder = BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient), +class DesktopRelayConnectionManager( + httpClient: DesktopHttpClient, +) : RelayConnectionManager( + websocketBuilder = BasicOkHttpWebSocket.Builder(httpClient::getHttpClient), ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt index ebaad3df6..0a72468f1 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt @@ -23,11 +23,22 @@ package com.vitorpamplona.amethyst.desktop.service.images import coil3.ImageLoader import coil3.PlatformContext import coil3.SingletonImageLoader +import coil3.Uri import coil3.annotation.DelicateCoilApi +import coil3.annotation.ExperimentalCoilApi import coil3.disk.DiskCache +import coil3.fetch.Fetcher import coil3.memory.MemoryCache +import coil3.network.CacheStrategy +import coil3.network.ConcurrentRequestStrategy +import coil3.network.ConnectivityChecker +import coil3.network.NetworkFetcher +import coil3.network.okhttp.asNetworkClient +import coil3.request.Options import coil3.size.Precision import coil3.svg.SvgDecoder +import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient +import okhttp3.Call import okio.Path.Companion.toOkioPath import java.io.File @@ -44,6 +55,7 @@ object DesktopImageLoaderSetup { .diskCache { newDiskCache() } .precision(Precision.INEXACT) .components { + add(TorAwareOkHttpFactory { DesktopHttpClient.currentClient() }) add(SvgDecoder.Factory()) add(SkiaGifDecoder.Factory()) add(DesktopBase64Fetcher.Factory) @@ -92,3 +104,35 @@ object DesktopImageLoaderSetup { } } } + +/** + * Custom Coil Fetcher.Factory that routes all image requests through the Tor-aware client. + * Pattern ported from Android's OkHttpFactory in ImageLoaderSetup.kt. + * + * Each image request calls [clientProvider] to get the current OkHttpClient, + * which returns the SOCKS proxy client when Tor is active or the direct client when off. + */ +@OptIn(ExperimentalCoilApi::class) +private class TorAwareOkHttpFactory( + val clientProvider: () -> Call.Factory, +) : Fetcher.Factory { + private val cacheStrategyLazy = lazy { CacheStrategy.DEFAULT } + + override fun create( + data: Uri, + options: Options, + imageLoader: ImageLoader, + ): Fetcher? { + if (data.scheme != "http" && data.scheme != "https") return null + + return NetworkFetcher( + url = data.toString(), + options = options, + networkClient = lazy { clientProvider().asNetworkClient() }, + diskCache = lazy { imageLoader.diskCache }, + cacheStrategy = cacheStrategyLazy, + connectivityChecker = lazy { ConnectivityChecker(options.context) }, + concurrentRequestStrategy = lazy { ConcurrentRequestStrategy.UNCOORDINATED }, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaService.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaService.kt index 4365bd881..69f534e2d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaService.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/EncryptedMediaService.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.desktop.service.media +import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient import com.vitorpamplona.quartz.utils.ciphers.AESGCM import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient import okhttp3.Request import java.util.concurrent.ConcurrentHashMap @@ -33,7 +33,7 @@ import java.util.concurrent.ConcurrentHashMap * Caches decrypted bytes in memory to avoid re-downloading on recomposition. */ object EncryptedMediaService { - private val httpClient = OkHttpClient() + private val httpClient get() = DesktopHttpClient.currentClient() private val cache = ConcurrentHashMap() private const val MAX_CACHE_ENTRIES = 20 diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheck.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheck.kt index bfa1b3295..fc9f4533e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheck.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/ServerHealthCheck.kt @@ -20,20 +20,12 @@ */ package com.vitorpamplona.amethyst.desktop.service.media +import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient import okhttp3.Request -import java.util.concurrent.TimeUnit object ServerHealthCheck { - private val httpClient = - OkHttpClient - .Builder() - .connectTimeout(5, TimeUnit.SECONDS) - .readTimeout(5, TimeUnit.SECONDS) - .build() - enum class ServerStatus { ONLINE, OFFLINE, @@ -53,7 +45,7 @@ object ServerHealthCheck { .url(url) .head() .build() - val response = httpClient.newCall(request).execute() + val response = DesktopHttpClient.currentClient().newCall(request).execute() response.use { if (it.isSuccessful || it.code == 405) ServerStatus.ONLINE else ServerStatus.OFFLINE } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClient.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClient.kt index 43842b38a..8bc6b99fe 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClient.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopBlossomClient.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop.service.upload +import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult import kotlinx.coroutines.Dispatchers @@ -34,8 +35,10 @@ import okio.source import java.io.File class DesktopBlossomClient( - private val okHttpClient: OkHttpClient = OkHttpClient(), + private val clientOverride: OkHttpClient? = null, ) { + private val okHttpClient: OkHttpClient get() = clientOverride ?: DesktopHttpClient.currentClient() + suspend fun upload( file: File, contentType: String, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/tor/DesktopTorManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/tor/DesktopTorManager.kt new file mode 100644 index 000000000..210d2f277 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/tor/DesktopTorManager.kt @@ -0,0 +1,228 @@ +/* + * 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.desktop.tor + +import com.vitorpamplona.amethyst.commons.tor.ITorManager +import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus +import com.vitorpamplona.amethyst.commons.tor.TorType +import com.vitorpamplona.quartz.utils.Log +import io.matthewnelson.kmp.tor.resource.exec.tor.ResourceLoaderTorExec +import io.matthewnelson.kmp.tor.runtime.Action.Companion.startDaemonAsync +import io.matthewnelson.kmp.tor.runtime.Action.Companion.stopDaemonAsync +import io.matthewnelson.kmp.tor.runtime.Action.Companion.stopDaemonSync +import io.matthewnelson.kmp.tor.runtime.RuntimeEvent +import io.matthewnelson.kmp.tor.runtime.TorRuntime +import io.matthewnelson.kmp.tor.runtime.TorState +import io.matthewnelson.kmp.tor.runtime.core.OnEvent +import io.matthewnelson.kmp.tor.runtime.core.OnFailure +import io.matthewnelson.kmp.tor.runtime.core.OnSuccess +import io.matthewnelson.kmp.tor.runtime.core.TorEvent +import io.matthewnelson.kmp.tor.runtime.core.config.TorOption +import io.matthewnelson.kmp.tor.runtime.core.ctrl.TorCmd +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.io.File + +/** + * Desktop Tor daemon manager using kmp-tor. + * + * Reactive: automatically starts/stops Tor daemon based on [torTypeFlow] changes. + * Supports INTERNAL (embedded kmp-tor) and EXTERNAL (user-provided SOCKS port) modes. + */ +class DesktopTorManager( + private val torTypeFlow: StateFlow, + private val externalPortFlow: StateFlow, + private val scope: CoroutineScope, +) : ITorManager { + private val _status = MutableStateFlow(TorServiceStatus.Off) + override val status: StateFlow = _status.asStateFlow() + + override val activePortOrNull: StateFlow = + _status + .map { (it as? TorServiceStatus.Active)?.port } + .stateIn(scope, SharingStarted.Eagerly, null) + + private val runtime: TorRuntime by lazy { + TorRuntime.Builder(desktopEnvironment()) { + observerStatic( + RuntimeEvent.LISTENERS, + OnEvent.Executor.Immediate, + OnEvent { listeners -> + val port = + listeners.socks + .firstOrNull() + ?.port + ?.value + if (port != null) { + _status.value = TorServiceStatus.Active(port) + } + }, + ) + + observerStatic( + RuntimeEvent.STATE, + OnEvent.Executor.Immediate, + OnEvent { state -> + val daemon = state.daemon + when { + daemon is TorState.Daemon.Off -> { + _status.value = TorServiceStatus.Off + } + + daemon is TorState.Daemon.Starting -> { + _status.value = TorServiceStatus.Connecting + } + + daemon is TorState.Daemon.Stopping -> { + _status.value = TorServiceStatus.Connecting + } + } + }, + ) + + // Suppress verbose logging to prevent relay hostname leaks + required(TorEvent.ERR) + required(TorEvent.WARN) + + config { _ -> + TorOption.__SocksPort.configure { auto() } + } + } + } + + init { + // Reactive: auto-derive Tor lifecycle from settings changes + scope.launch { + torTypeFlow.collect { mode -> + when (mode) { + TorType.INTERNAL -> { + _status.value = TorServiceStatus.Connecting + // Retry start — previous daemon may still be stopping + var started = false + for (attempt in 1..3) { + try { + runtime.startDaemonAsync() + started = true + break + } catch (e: Exception) { + if (attempt < 3) { + Log.d("DesktopTorManager") { "Start attempt $attempt failed, retrying..." } + kotlinx.coroutines.delay(1000L * attempt) + } else { + Log.e("DesktopTorManager", "Failed to start Tor after 3 attempts", e) + _status.value = TorServiceStatus.Error(e.message ?: "Unknown error") + } + } + } + } + + TorType.EXTERNAL -> { + _status.value = TorServiceStatus.Active(externalPortFlow.value) + } + + TorType.OFF -> { + try { + runtime.stopDaemonAsync() + } catch (_: Exception) { + // May not be running + } + _status.value = TorServiceStatus.Off + } + } + } + } + } + + override suspend fun dormant() { + if (_status.value is TorServiceStatus.Active) { + runtime.enqueue(TorCmd.Signal.Dormant, OnFailure.noOp(), OnSuccess.noOp()) + } + } + + override suspend fun active() { + if (_status.value is TorServiceStatus.Active) { + runtime.enqueue(TorCmd.Signal.Active, OnFailure.noOp(), OnSuccess.noOp()) + } + } + + override suspend fun newIdentity() { + if (_status.value is TorServiceStatus.Active) { + runtime.enqueue(TorCmd.Signal.NewNym, OnFailure.noOp(), OnSuccess.noOp()) + } + } + + /** Call from shutdown hook to stop Tor synchronously. Waits for daemon exit. */ + fun stopSync() { + try { + runtime.stopDaemonSync() + // Wait for daemon process to fully exit + Thread.sleep(1000) + } catch (_: Exception) { + // Best-effort + } + } + + companion object { + private fun desktopEnvironment(): TorRuntime.Environment { + val appDir = torDataDirectory() + appDir.mkdirs() + // Restrict permissions to owner only (700) + appDir.setReadable(false, false) + appDir.setReadable(true, true) + appDir.setWritable(false, false) + appDir.setWritable(true, true) + appDir.setExecutable(false, false) + appDir.setExecutable(true, true) + + return TorRuntime.Environment.Builder( + workDirectory = appDir.resolve("work"), + cacheDirectory = appDir.resolve("cache"), + loader = ResourceLoaderTorExec::getOrCreate, + ) {} + } + + /** OS-specific data directory for Tor. */ + internal fun torDataDirectory(): File { + val osName = System.getProperty("os.name", "").lowercase() + val home = System.getProperty("user.home") ?: "." + return when { + osName.contains("mac") -> { + File(home, "Library/Application Support/Amethyst/tor") + } + + osName.contains("win") -> { + File(System.getenv("APPDATA") ?: "$home/AppData/Roaming", "Amethyst/tor") + } + + else -> { + val xdgData = System.getenv("XDG_DATA_HOME") ?: "$home/.local/share" + File(xdgData, "Amethyst/tor") + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/tor/DesktopTorPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/tor/DesktopTorPreferences.kt new file mode 100644 index 000000000..489384dc6 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/tor/DesktopTorPreferences.kt @@ -0,0 +1,71 @@ +/* + * 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.desktop.tor + +import com.vitorpamplona.amethyst.commons.tor.ITorSettingsPersistence +import com.vitorpamplona.amethyst.commons.tor.TorSettings +import com.vitorpamplona.amethyst.commons.tor.TorType +import java.util.prefs.Preferences + +/** + * Desktop persistence for Tor settings using java.util.prefs.Preferences. + * + * Per-device settings (not synced across accounts). + * Default TorType is OFF — user must opt-in. + * No explicit flush() — matches existing DesktopPreferences pattern. + */ +object DesktopTorPreferences : ITorSettingsPersistence { + private val prefs = Preferences.userNodeForPackage(DesktopTorPreferences::class.java) + + override fun load(): TorSettings = + TorSettings( + torType = TorType.entries.firstOrNull { it.name == prefs.get("tor_type", TorType.INTERNAL.name) } ?: TorType.INTERNAL, + externalSocksPort = prefs.getInt("tor_external_port", 9050), + onionRelaysViaTor = prefs.getBoolean("tor_onion_relays", true), + dmRelaysViaTor = prefs.getBoolean("tor_dm_relays", true), + newRelaysViaTor = prefs.getBoolean("tor_new_relays", true), + trustedRelaysViaTor = prefs.getBoolean("tor_trusted_relays", true), + urlPreviewsViaTor = prefs.getBoolean("tor_url_previews", true), + profilePicsViaTor = prefs.getBoolean("tor_profile_pics", true), + imagesViaTor = prefs.getBoolean("tor_images", true), + videosViaTor = prefs.getBoolean("tor_videos", true), + moneyOperationsViaTor = prefs.getBoolean("tor_money", true), + nip05VerificationsViaTor = prefs.getBoolean("tor_nip05", true), + mediaUploadsViaTor = prefs.getBoolean("tor_media_uploads", true), + ) + + override fun save(settings: TorSettings) { + prefs.put("tor_type", settings.torType.name) + prefs.putInt("tor_external_port", settings.externalSocksPort) + prefs.putBoolean("tor_onion_relays", settings.onionRelaysViaTor) + prefs.putBoolean("tor_dm_relays", settings.dmRelaysViaTor) + prefs.putBoolean("tor_new_relays", settings.newRelaysViaTor) + prefs.putBoolean("tor_trusted_relays", settings.trustedRelaysViaTor) + prefs.putBoolean("tor_url_previews", settings.urlPreviewsViaTor) + prefs.putBoolean("tor_profile_pics", settings.profilePicsViaTor) + prefs.putBoolean("tor_images", settings.imagesViaTor) + prefs.putBoolean("tor_videos", settings.videosViaTor) + prefs.putBoolean("tor_money", settings.moneyOperationsViaTor) + prefs.putBoolean("tor_nip05", settings.nip05VerificationsViaTor) + prefs.putBoolean("tor_media_uploads", settings.mediaUploadsViaTor) + // No explicit flush() — JVM auto-flushes on shutdown (matches DesktopPreferences pattern) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt index d14ae4b50..c3e029682 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt @@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.commons.model.nip57Zaps.ZapAction import com.vitorpamplona.amethyst.commons.services.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.nwc.NwcPaymentHandler import com.vitorpamplona.quartz.nip01Core.core.Event @@ -84,10 +85,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient import java.awt.Toolkit import java.awt.datatransfer.StringSelection -import java.util.concurrent.TimeUnit import kotlin.coroutines.resume private val ZAP_AMOUNTS = listOf(21L, 100L, 500L, 1000L, 5000L, 10000L) @@ -936,12 +935,7 @@ private suspend fun zapNote( } // Create HTTP client and resolver - val httpClient = - OkHttpClient - .Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .build() + val httpClient = DesktopHttpClient.currentClient() val resolver = LightningAddressResolver(httpClient) // Get relay URLs for zap request diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index a72032acd..205b41b36 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -312,7 +312,15 @@ internal fun RootContent( } DeckColumnType.Settings -> { - RelaySettingsScreen(relayManager, account, accountManager) + val torState = com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState.current + RelaySettingsScreen( + relayManager = relayManager, + account = account, + accountManager = accountManager, + torStatus = torState.status, + torSettings = torState.settings, + onTorSettingsChanged = torState.onSettingsChanged, + ) } is DeckColumnType.Profile -> { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt index 5ddc99b21..32f470b9f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt @@ -42,7 +42,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState +import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator +import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator @Composable fun DeckSidebar( @@ -50,6 +52,7 @@ fun DeckSidebar( onOpenSettings: () -> Unit, signerConnectionState: SignerConnectionState, lastPingTimeSec: Long?, + torStatus: TorServiceStatus, modifier: Modifier = Modifier, ) { Column( @@ -87,7 +90,10 @@ fun DeckSidebar( lastPingTimeSec = lastPingTimeSec, ) - Spacer(Modifier.size(8.dp)) + Spacer(Modifier.size(4.dp)) + TorStatusIndicator(status = torStatus, onClick = onOpenSettings) + + Spacer(Modifier.size(4.dp)) IconButton(onClick = onOpenSettings) { Icon( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index 63adf3f56..5e2599627 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -68,6 +68,8 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscription import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.amethyst.desktop.ui.components.RelayHealthIndicator import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen +import com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState +import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm import kotlinx.coroutines.CoroutineScope @@ -161,6 +163,17 @@ fun SinglePaneLayout( BunkerHeartbeatIndicator( signerConnectionState = signerConnectionState, lastPingTimeSec = lastPingTimeSec, + modifier = Modifier.padding(bottom = 4.dp), + ) + + // Tor status — always last so it's never pushed off screen + val torState = LocalTorState.current + TorStatusIndicator( + status = torState.status, + onClick = { + currentColumnType = DeckColumnType.Settings + navState.clear() + }, modifier = Modifier.padding(bottom = 12.dp), ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AnimatedGifImage.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AnimatedGifImage.kt index 1872b7b04..ee5f44cac 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AnimatedGifImage.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AnimatedGifImage.kt @@ -35,27 +35,20 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asComposeImageBitmap import androidx.compose.ui.layout.ContentScale import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient import okhttp3.Request import org.jetbrains.skia.Bitmap import org.jetbrains.skia.Codec import org.jetbrains.skia.Data -import java.util.concurrent.TimeUnit private const val MAX_BITMAP_MEMORY = 64L * 1024 * 1024 // 64MB per GIF private const val MIN_FRAME_DURATION_MS = 20 -private val gifHttpClient: OkHttpClient by lazy { - OkHttpClient - .Builder() - .connectTimeout(15, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .build() -} +private val gifHttpClient get() = DesktopHttpClient.currentClient() fun isAnimatedGifUrl(url: String): Boolean { val lower = url.lowercase() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/SaveMediaAction.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/SaveMediaAction.kt index e29518db6..df014ae4e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/SaveMediaAction.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/SaveMediaAction.kt @@ -20,16 +20,16 @@ */ package com.vitorpamplona.amethyst.desktop.ui.media +import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient import okhttp3.Request import java.awt.FileDialog import java.awt.Frame import java.io.File object SaveMediaAction { - private val httpClient = OkHttpClient() + private val httpClient get() = DesktopHttpClient.currentClient() /** * Opens a save dialog and downloads the media URL to the chosen file. diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayStatusCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayStatusCard.kt index 072739f87..06b8b8a19 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayStatusCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayStatusCard.kt @@ -41,7 +41,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus import com.vitorpamplona.amethyst.desktop.network.RelayStatus +import com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion /** * Card displaying the status of a Nostr relay connection. @@ -99,11 +102,33 @@ fun RelayStatusCard( } Column { - Text( - status.url.url, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + status.url.url, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + // .onion badge: show "Requires Tor" when Tor is off + if (status.url.isOnion()) { + val torState = LocalTorState.current + val badgeColor = + if (torState.status is TorServiceStatus.Off) { + Color(0xFFF44336) // Red — Tor required but off + } else { + Color(0xFF4CAF50) // Green — routed via Tor + } + val badgeText = + if (torState.status is TorServiceStatus.Off) "Requires Tor" else "via Tor" + Text( + badgeText, + style = MaterialTheme.typography.labelSmall, + color = badgeColor, + ) + } + } if (status.connected && status.pingMs != null) { Text( "${status.pingMs}ms${if (status.compressed) " • compressed" else ""}", diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/LocalTorState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/LocalTorState.kt new file mode 100644 index 000000000..d7690442d --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/LocalTorState.kt @@ -0,0 +1,37 @@ +/* + * 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.desktop.ui.tor + +import androidx.compose.runtime.compositionLocalOf +import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus +import com.vitorpamplona.amethyst.commons.tor.TorSettings +import com.vitorpamplona.amethyst.commons.tor.TorType + +/** + * Composition locals for Tor state, avoiding threading params through every layer. + */ +data class TorState( + val status: TorServiceStatus = TorServiceStatus.Off, + val settings: TorSettings = TorSettings(torType = TorType.OFF), + val onSettingsChanged: (TorSettings) -> Unit = {}, +) + +val LocalTorState = compositionLocalOf { TorState() } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorSettingsDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorSettingsDialog.kt new file mode 100644 index 000000000..214be68e9 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorSettingsDialog.kt @@ -0,0 +1,259 @@ +/* + * 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.desktop.ui.tor + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogWindow +import androidx.compose.ui.window.rememberDialogState +import com.vitorpamplona.amethyst.commons.tor.TorPresetType +import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus +import com.vitorpamplona.amethyst.commons.tor.TorSettings +import com.vitorpamplona.amethyst.commons.tor.TorType +import com.vitorpamplona.amethyst.commons.tor.torDefaultPreset +import com.vitorpamplona.amethyst.commons.tor.torFullyPrivate +import com.vitorpamplona.amethyst.commons.tor.torOnlyWhenNeededPreset +import com.vitorpamplona.amethyst.commons.tor.torSmallPayloadsPreset +import com.vitorpamplona.amethyst.commons.tor.whichPreset + +@Composable +fun TorSettingsDialog( + currentSettings: TorSettings, + torStatus: TorServiceStatus, + onSettingsChanged: (TorSettings) -> Unit, + onDismiss: () -> Unit, +) { + var editSettings by remember { mutableStateOf(currentSettings) } + + var showRestartConfirm by remember { mutableStateOf(false) } + + DialogWindow( + onCloseRequest = onDismiss, + title = "Tor Settings", + state = rememberDialogState(size = DpSize(480.dp, 640.dp)), + ) { + Surface(color = MaterialTheme.colorScheme.background) { + Column(modifier = Modifier.padding(24.dp)) { + // Scrollable content + Column( + modifier = + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + ) { + // Status + Row(verticalAlignment = Alignment.CenterVertically) { + TorStatusIndicator(status = torStatus) + Spacer(Modifier.width(8.dp)) + Text( + when (torStatus) { + is TorServiceStatus.Off -> "Tor is off" + is TorServiceStatus.Connecting -> "Connecting to Tor..." + is TorServiceStatus.Active -> "Connected via Tor" + is TorServiceStatus.Error -> "Error: ${torStatus.message}" + }, + style = MaterialTheme.typography.bodyLarge, + ) + } + + Spacer(Modifier.height(16.dp)) + + // Mode selector + Text("Mode", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + TorType.entries.forEachIndexed { index, torType -> + SegmentedButton( + shape = SegmentedButtonDefaults.itemShape(index, TorType.entries.size), + onClick = { editSettings = editSettings.copy(torType = torType) }, + selected = editSettings.torType == torType, + ) { + Text(torType.name.lowercase().replaceFirstChar { it.uppercase() }) + } + } + } + + if (editSettings.torType == TorType.EXTERNAL) { + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = editSettings.externalSocksPort.toString(), + onValueChange = { text -> + text.toIntOrNull()?.let { port -> + if (port in 1..65535) { + editSettings = editSettings.copy(externalSocksPort = port) + } + } + }, + label = { Text("SOCKS Port") }, + singleLine = true, + modifier = Modifier.width(150.dp), + ) + } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(16.dp)) + + // Presets + Text("Preset", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + + val currentPreset = whichPreset(editSettings) + PresetRow("Only When Needed", TorPresetType.ONLY_WHEN_NEEDED, currentPreset) { + editSettings = torOnlyWhenNeededPreset.copy(torType = editSettings.torType, externalSocksPort = editSettings.externalSocksPort) + } + PresetRow("Default", TorPresetType.DEFAULT, currentPreset) { + editSettings = torDefaultPreset.copy(torType = editSettings.torType, externalSocksPort = editSettings.externalSocksPort) + } + PresetRow("Small Payloads", TorPresetType.SMALL_PAYLOADS, currentPreset) { + editSettings = torSmallPayloadsPreset.copy(torType = editSettings.torType, externalSocksPort = editSettings.externalSocksPort) + } + PresetRow("Full Privacy", TorPresetType.FULL_PRIVACY, currentPreset) { + editSettings = torFullyPrivate.copy(torType = editSettings.torType, externalSocksPort = editSettings.externalSocksPort) + } + PresetRow("Custom", TorPresetType.CUSTOM, currentPreset) {} + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(16.dp)) + + // Relay Routing + Text("Relay Routing", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + ToggleRow(".onion relays via Tor", editSettings.onionRelaysViaTor) { editSettings = editSettings.copy(onionRelaysViaTor = it) } + ToggleRow("DM relays via Tor", editSettings.dmRelaysViaTor) { editSettings = editSettings.copy(dmRelaysViaTor = it) } + ToggleRow("Trusted relays via Tor", editSettings.trustedRelaysViaTor) { editSettings = editSettings.copy(trustedRelaysViaTor = it) } + ToggleRow("New/unknown relays via Tor", editSettings.newRelaysViaTor) { editSettings = editSettings.copy(newRelaysViaTor = it) } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(16.dp)) + + // Content Routing + Text("Content Routing", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + ToggleRow("URL previews via Tor", editSettings.urlPreviewsViaTor) { editSettings = editSettings.copy(urlPreviewsViaTor = it) } + ToggleRow("Profile pictures via Tor", editSettings.profilePicsViaTor) { editSettings = editSettings.copy(profilePicsViaTor = it) } + ToggleRow("Images via Tor", editSettings.imagesViaTor) { editSettings = editSettings.copy(imagesViaTor = it) } + ToggleRow("Videos via Tor", editSettings.videosViaTor) { editSettings = editSettings.copy(videosViaTor = it) } + ToggleRow("NIP-05 verifications via Tor", editSettings.nip05VerificationsViaTor) { editSettings = editSettings.copy(nip05VerificationsViaTor = it) } + ToggleRow("Money operations via Tor", editSettings.moneyOperationsViaTor) { editSettings = editSettings.copy(moneyOperationsViaTor = it) } + ToggleRow("Media uploads via Tor", editSettings.mediaUploadsViaTor) { editSettings = editSettings.copy(mediaUploadsViaTor = it) } + + Spacer(Modifier.height(16.dp)) + } // end scrollable content + + // Sticky bottom buttons — always visible + HorizontalDivider() + Spacer(Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { Text("Cancel") } + Spacer(Modifier.width(8.dp)) + TextButton(onClick = { showRestartConfirm = true }) { Text("Save") } + } + + if (showRestartConfirm) { + androidx.compose.material3.AlertDialog( + onDismissRequest = { showRestartConfirm = false }, + title = { Text("Restart Required") }, + text = { Text("Tor changes require restarting. Proceed?") }, + confirmButton = { + TextButton(onClick = { + onSettingsChanged(editSettings) + onDismiss() + }) { Text("Restart") } + }, + dismissButton = { + TextButton(onClick = { showRestartConfirm = false }) { Text("Cancel") } + }, + ) + } + } + } + } +} + +@Composable +private fun PresetRow( + label: String, + presetType: TorPresetType, + currentPreset: TorPresetType, + onClick: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + RadioButton( + selected = currentPreset == presetType, + onClick = onClick, + ) + Spacer(Modifier.width(4.dp)) + Text(label, style = MaterialTheme.typography.bodyMedium) + } +} + +@Composable +private fun ToggleRow( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + ) { + Text(label, style = MaterialTheme.typography.bodyMedium) + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorSettingsSection.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorSettingsSection.kt new file mode 100644 index 000000000..33161d667 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorSettingsSection.kt @@ -0,0 +1,159 @@ +/* + * 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.desktop.ui.tor + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus +import com.vitorpamplona.amethyst.commons.tor.TorSettings +import com.vitorpamplona.amethyst.commons.tor.TorType + +/** + * Inline Tor settings section for the desktop settings screen. + * Shows mode selector (Off/Internal/External) with status indicator + * and an "Advanced..." button to open the full dialog. + */ +@Composable +fun TorSettingsSection( + torStatus: TorServiceStatus, + currentSettings: TorSettings, + onSettingsChanged: (TorSettings) -> Unit, + modifier: Modifier = Modifier, +) { + var showDialog by remember { mutableStateOf(false) } + var pendingSettings by remember { mutableStateOf(null) } + + Column(modifier = modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Tor", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(Modifier.width(12.dp)) + TorStatusIndicator(status = torStatus) + } + TextButton(onClick = { showDialog = true }) { + Text("Advanced...") + } + } + + Spacer(Modifier.height(8.dp)) + + Text( + "Route relay connections through Tor for privacy. Internal mode bundles a Tor daemon; External mode connects to your own SOCKS proxy.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(12.dp)) + + // Mode selector + SingleChoiceSegmentedButtonRow { + TorType.entries.forEachIndexed { index, torType -> + SegmentedButton( + shape = SegmentedButtonDefaults.itemShape(index = index, count = TorType.entries.size), + onClick = { pendingSettings = currentSettings.copy(torType = torType) }, + selected = currentSettings.torType == torType, + ) { + Text(torType.name.lowercase().replaceFirstChar { it.uppercase() }) + } + } + } + + // External port input + if (currentSettings.torType == TorType.EXTERNAL) { + Spacer(Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "SOCKS Port:", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + androidx.compose.material3.OutlinedTextField( + value = currentSettings.externalSocksPort.toString(), + onValueChange = { text -> + text.toIntOrNull()?.let { port -> + if (port in 1..65535) { + onSettingsChanged(currentSettings.copy(externalSocksPort = port)) + } + } + }, + modifier = Modifier.width(100.dp), + singleLine = true, + ) + } + } + } + + if (showDialog) { + TorSettingsDialog( + currentSettings = currentSettings, + torStatus = torStatus, + onSettingsChanged = onSettingsChanged, + onDismiss = { showDialog = false }, + ) + } + + // Restart confirmation dialog + pendingSettings?.let { settings -> + AlertDialog( + onDismissRequest = { pendingSettings = null }, + title = { Text("Restart Required") }, + text = { Text("Changing Tor mode requires restarting. Your session will be briefly interrupted.") }, + confirmButton = { + TextButton(onClick = { + onSettingsChanged(settings) + pendingSettings = null + }) { Text("Restart") } + }, + dismissButton = { + TextButton(onClick = { pendingSettings = null }) { Text("Cancel") } + }, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorStatusIndicator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorStatusIndicator.kt new file mode 100644 index 000000000..3eb57188b --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorStatusIndicator.kt @@ -0,0 +1,109 @@ +/* + * 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.desktop.ui.tor + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.TooltipArea +import androidx.compose.foundation.TooltipPlacement +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Shield +import androidx.compose.material.icons.outlined.Shield +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus + +/** + * Small shield icon showing Tor connection status in the sidebar footer. + * Clickable to open Tor settings. Tooltip shows status text. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun TorStatusIndicator( + status: TorServiceStatus, + onClick: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { + val (icon, tint, tooltip) = + when (status) { + is TorServiceStatus.Off -> { + Triple(Icons.Outlined.Shield, Color.Gray, "Tor: Off") + } + + is TorServiceStatus.Connecting -> { + Triple(Icons.Filled.Shield, Color(0xFFFFB300), "Tor: Connecting...") + } + + is TorServiceStatus.Active -> { + Triple(Icons.Filled.Shield, Color(0xFF4CAF50), "Tor: Connected") + } + + is TorServiceStatus.Error -> { + Triple(Icons.Outlined.Shield, Color(0xFFF44336), "Tor: ${status.message}") + } + } + + TooltipArea( + tooltip = { + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.inverseSurface, + ) { + Text( + text = tooltip, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.inverseOnSurface, + style = MaterialTheme.typography.bodySmall, + ) + } + }, + tooltipPlacement = TooltipPlacement.CursorPoint(alignment = Alignment.BottomEnd, offset = DpOffset(0.dp, 16.dp)), + ) { + if (onClick != null) { + IconButton(onClick = onClick, modifier = modifier.size(28.dp)) { + Icon( + imageVector = icon, + contentDescription = tooltip, + tint = tint, + modifier = Modifier.size(20.dp), + ) + } + } else { + Icon( + imageVector = icon, + contentDescription = tooltip, + tint = tint, + modifier = modifier.size(20.dp), + ) + } + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClientTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClientTest.kt index 999962794..9d10c3773 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClientTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClientTest.kt @@ -20,17 +20,25 @@ */ package com.vitorpamplona.amethyst.desktop.network +import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue class DesktopHttpClientTest { + // --- Simple (companion) client tests --- + @Test - fun testGetHttpClientReturnsConfiguredClient() { - val url = NormalizedRelayUrl("wss://relay.damus.io") - val client = DesktopHttpClient.getHttpClient(url) + fun simpleClient_returnsConfiguredClient() { + val url = NormalizedRelayUrl("wss://relay.damus.io/") + val client = DesktopHttpClient.getSimpleHttpClient(url) assertNotNull(client) assertEquals(30_000, client.connectTimeoutMillis) @@ -41,52 +49,106 @@ class DesktopHttpClientTest { } @Test - fun testGetHttpClientReturnsSameInstance() { - val url1 = NormalizedRelayUrl("wss://relay.damus.io") - val url2 = NormalizedRelayUrl("wss://nos.lol") + fun simpleClient_returnsSameInstance() { + val url1 = NormalizedRelayUrl("wss://relay.damus.io/") + val url2 = NormalizedRelayUrl("wss://nos.lol/") + assertEquals(DesktopHttpClient.getSimpleHttpClient(url1), DesktopHttpClient.getSimpleHttpClient(url2)) + } - val client1 = DesktopHttpClient.getHttpClient(url1) - val client2 = DesktopHttpClient.getHttpClient(url2) + // --- Tor-aware client tests --- - // Should return the same singleton instance - assertEquals(client1, client2) + private fun buildTorAwareClient( + torPort: Int? = null, + shouldUseTor: (NormalizedRelayUrl) -> Boolean = { false }, + torType: com.vitorpamplona.amethyst.commons.tor.TorType = if (torPort != null) com.vitorpamplona.amethyst.commons.tor.TorType.INTERNAL else com.vitorpamplona.amethyst.commons.tor.TorType.OFF, + ): DesktopHttpClient { + val statusFlow = + MutableStateFlow( + if (torPort != null) TorServiceStatus.Active(torPort) else TorServiceStatus.Off, + ) + val portFlow = MutableStateFlow(torPort) + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + + val fakeTorManager = + object : com.vitorpamplona.amethyst.commons.tor.ITorManager { + override val status = statusFlow + override val activePortOrNull = portFlow + + override suspend fun dormant() {} + + override suspend fun active() {} + + override suspend fun newIdentity() {} + } + return DesktopHttpClient(fakeTorManager, shouldUseTor, { torType }, scope) } @Test - fun testHttpClientHasExpectedTimeouts() { - val url = NormalizedRelayUrl("wss://relay.nostr.band") - val client = DesktopHttpClient.getHttpClient(url) + fun torOff_returnsDirectClient() { + val httpClient = buildTorAwareClient(torPort = null) + val url = NormalizedRelayUrl("wss://relay.damus.io/") + val client = httpClient.getHttpClient(url) + + assertNotNull(client) + assertNull(client.proxy, "Should not have proxy when Tor is off") + } + + @Test + fun torOn_localhostRelay_returnsDirectClient() { + val httpClient = buildTorAwareClient(torPort = 9050, shouldUseTor = { true }) + val url = NormalizedRelayUrl("ws://127.0.0.1:8080/") + val client = httpClient.getHttpClient(url) + + assertNull(client.proxy, "Localhost should never use proxy") + } + + @Test + fun torOn_clearnetRelay_shouldUseTorTrue_returnsProxiedClient() { + val httpClient = buildTorAwareClient(torPort = 9050, shouldUseTor = { true }) + val url = NormalizedRelayUrl("wss://relay.damus.io/") + val client = httpClient.getHttpClient(url) + + assertNotNull(client.proxy, "Should have SOCKS proxy when Tor routing is enabled") + assertEquals(java.net.Proxy.Type.SOCKS, client.proxy!!.type()) + } + + @Test + fun torOn_clearnetRelay_shouldUseTorFalse_returnsDirectClient() { + val httpClient = buildTorAwareClient(torPort = 9050, shouldUseTor = { false }) + val url = NormalizedRelayUrl("wss://relay.damus.io/") + val client = httpClient.getHttpClient(url) + + assertNull(client.proxy, "Should not proxy when shouldUseTor returns false") + } + + @Test + fun torOn_onionRelay_alwaysProxied() { + val httpClient = buildTorAwareClient(torPort = 9050, shouldUseTor = { false }) + val url = NormalizedRelayUrl("wss://abc123.onion/") + val client = httpClient.getHttpClient(url) + + assertNotNull(client.proxy, ".onion relays should always use proxy when Tor is active") + } + + @Test + fun proxyClient_hasDoubledTimeouts() { + val httpClient = buildTorAwareClient(torPort = 9050, shouldUseTor = { true }) + val url = NormalizedRelayUrl("wss://relay.damus.io/") + val client = httpClient.getHttpClient(url) + + // Desktop uses 2x multiplier: 30 * 2 = 60 seconds + assertEquals(60_000, client.connectTimeoutMillis) + assertEquals(60_000, client.readTimeoutMillis) + assertEquals(60_000, client.writeTimeoutMillis) + } + + @Test + fun directClient_hasBaseTimeouts() { + val httpClient = buildTorAwareClient(torPort = null) + val client = httpClient.getNonProxyClient() - // Verify all timeouts are 30 seconds assertEquals(30_000, client.connectTimeoutMillis) assertEquals(30_000, client.readTimeoutMillis) assertEquals(30_000, client.writeTimeoutMillis) - assertEquals(30_000, client.pingIntervalMillis) - } - - @Test - fun testHttpClientHasRetryEnabled() { - val url = NormalizedRelayUrl("wss://relay.snort.social") - val client = DesktopHttpClient.getHttpClient(url) - - assertTrue(client.retryOnConnectionFailure, "Retry on connection failure should be enabled") - } - - @Test - fun testHttpClientIsLazyInitialized() { - // This test verifies the lazy initialization pattern - // The client should be created only once even with multiple calls - val url1 = NormalizedRelayUrl("wss://relay1.example.com") - val url2 = NormalizedRelayUrl("wss://relay2.example.com") - val url3 = NormalizedRelayUrl("wss://relay3.example.com") - - val client1 = DesktopHttpClient.getHttpClient(url1) - val client2 = DesktopHttpClient.getHttpClient(url2) - val client3 = DesktopHttpClient.getHttpClient(url3) - - // All should be the same instance due to lazy singleton - assertEquals(client1, client2) - assertEquals(client2, client3) - assertEquals(client1, client3) } } diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManagerTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManagerTest.kt index 0f494ac3e..43edddc61 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManagerTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManagerTest.kt @@ -20,20 +20,41 @@ */ package com.vitorpamplona.amethyst.desktop.network +import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow import kotlin.test.Test import kotlin.test.assertNotNull import kotlin.test.assertTrue class DesktopRelayConnectionManagerTest { + private fun createManager(): DesktopRelayConnectionManager { + val fakeTorManager = + object : com.vitorpamplona.amethyst.commons.tor.ITorManager { + override val status = MutableStateFlow(TorServiceStatus.Off) + override val activePortOrNull = MutableStateFlow(null) + + override suspend fun dormant() {} + + override suspend fun active() {} + + override suspend fun newIdentity() {} + } + val scope = CoroutineScope(SupervisorJob()) + val httpClient = DesktopHttpClient(fakeTorManager, { false }, { com.vitorpamplona.amethyst.commons.tor.TorType.OFF }, scope) + return DesktopRelayConnectionManager(httpClient) + } + @Test fun testRelayConnectionManagerCanBeInstantiated() { - val manager = DesktopRelayConnectionManager() + val manager = createManager() assertNotNull(manager) } @Test fun testRelayConnectionManagerHasNoActiveConnectionsInitially() { - val manager = DesktopRelayConnectionManager() + val manager = createManager() val connectedRelays = manager.connectedRelays.value val availableRelays = manager.availableRelays.value assertTrue(connectedRelays.isEmpty(), "Should have no connected relays on initialization") diff --git a/docs/manual-testing-tor.md b/docs/manual-testing-tor.md new file mode 100644 index 000000000..49f5694c0 --- /dev/null +++ b/docs/manual-testing-tor.md @@ -0,0 +1,134 @@ +# Manual Testing: Desktop Tor Support + +## Prerequisites + +All tools are available natively on macOS: +- `tcpdump` — DNS leak monitoring +- `curl` — Tor routing verification +- `lsof` — find SOCKS port + +Optional: +```bash +brew install tor # For external Tor mode testing +``` + +--- + +## Step 1: Launch & Find SOCKS Port + +```bash +cd AmethystMultiplatform-tor-support +./gradlew :desktopApp:run > /tmp/amethyst-tor.log 2>&1 & + +# Wait ~30s for Tor bootstrap, then find the SOCKS port: +lsof -i -P -n | grep java | grep LISTEN +# Look for a high port (e.g. 35607) — that's the kmp-tor SOCKS port +``` + +--- + +## Step 2: Verify Tor Routing + +Replace `PORT` with the actual SOCKS port from Step 1. + +```bash +# Your real IP +curl -s https://icanhazip.com +# → e.g. 203.0.113.42 + +# IP through Tor +curl -s --socks5-hostname 127.0.0.1:PORT https://icanhazip.com +# → e.g. 185.220.101.xx (different from above) + +# Confirm Tor Project says it's Tor +curl -s --socks5-hostname 127.0.0.1:PORT https://check.torproject.org/api/ip +# → {"IsTor":true,"IP":"185.220.101.xx"} +``` + +--- + +## Step 3: Verify No DNS Leaks + +```bash +# In a separate terminal: +sudo tcpdump -i any port 53 -n -l 2>/dev/null | grep --line-buffered -v "mdns" + +# In the app: scroll feed, load profiles, open threads +# tcpdump should show NO relay hostname DNS queries from Java +``` + +--- + +## Checklist + +### UI — Default State +- [ ] App launches with shield icon visible in sidebar (bottom) +- [ ] Shield starts gray/yellow, turns green after ~10-30s (Tor bootstrap) +- [ ] Hover shield → tooltip shows "Tor: Connected" +- [ ] Click shield → navigates to Settings +- [ ] Settings → Tor section shows **Internal** mode selected +- [ ] Click "Advanced..." → **Full Privacy** preset selected, all toggles ON + +### UI — Mode Switching +- [ ] Select "Off" → shield turns gray, relays reconnect directly +- [ ] Select "Internal" → shield yellow → green, relays reconnect via Tor +- [ ] Select "External" → SOCKS port field appears +- [ ] Enter port 9050 (if system Tor running) → shield turns green + +### UI — Advanced Dialog +- [ ] Preset radio buttons: Only When Needed / Default / Small Payloads / Full Privacy / Custom +- [ ] Selecting "Only When Needed" → only .onion toggle ON +- [ ] Selecting "Full Privacy" → all toggles ON +- [ ] Toggling individual item → preset auto-switches to "Custom" +- [ ] Scroll dialog content → Cancel/Save buttons stay at bottom (sticky) +- [ ] Cancel → no changes saved +- [ ] Save → settings applied and persisted + +### UI — .onion Relay Badge +- [ ] Add a .onion relay URL → badge shows "via Tor" (green) when Tor ON +- [ ] Switch Tor OFF → badge shows "Requires Tor" (red) + +### UI — Persistence +- [ ] Change to "Default" preset, close app +- [ ] Relaunch → shows Internal + Default preset, Tor auto-connects + +### Network — Tor Routing Verified +- [ ] `curl -s https://icanhazip.com` → real IP: _______________ +- [ ] `curl -s --socks5-hostname 127.0.0.1:PORT https://icanhazip.com` → Tor IP: _______________ +- [ ] IPs are different: ___ +- [ ] `curl --socks5-hostname 127.0.0.1:PORT https://check.torproject.org/api/ip` → `{"IsTor":true}`: ___ + +### Network — No DNS Leaks +- [ ] `sudo tcpdump -i any port 53 -n` shows no relay hostname queries while Tor active + +### Build — No Regressions +- [ ] `./gradlew :commons:jvmTest` — all pass +- [ ] `./gradlew :desktopApp:test` — all pass (1 pre-existing failure in DesktopCachePipelineTest) +- [ ] `./gradlew :amethyst:testPlayDebugUnitTest` — all pass +- [ ] `./gradlew :amethyst:compilePlayDebugKotlin` — Android compiles clean +- [ ] `./gradlew spotlessApply` — formatting clean + +--- + +## Proof of Work Evidence + +Paste terminal output of: + +```bash +# 1. SOCKS port discovered +lsof -i -P -n | grep java | grep LISTEN + +# 2. Tor routing confirmed +curl -s --socks5-hostname 127.0.0.1:PORT https://check.torproject.org/api/ip + +# 3. IP mismatch confirmed +echo "Real: $(curl -s https://icanhazip.com) | Tor: $(curl -s --socks5-hostname 127.0.0.1:PORT https://icanhazip.com)" + +# 4. Tests passing +./gradlew :commons:jvmTest :desktopApp:test :amethyst:testPlayDebugUnitTest 2>&1 | tail -5 +``` + +Screenshot of: +- Shield icon green in sidebar +- Tor settings section showing Internal + Full Privacy +- Advanced dialog with all toggles ON diff --git a/docs/plans/2026-04-01-fix-tor-traffic-leaks-plan.md b/docs/plans/2026-04-01-fix-tor-traffic-leaks-plan.md new file mode 100644 index 000000000..7939485d7 --- /dev/null +++ b/docs/plans/2026-04-01-fix-tor-traffic-leaks-plan.md @@ -0,0 +1,241 @@ +--- +title: "fix: Desktop Tor traffic leaks — zero direct connections when Tor ON" +type: fix +status: active +date: 2026-04-01 +deepened: 2026-04-01 +origin: docs/brainstorms/2026-04-01-fix-tor-traffic-leaks-brainstorm.md +--- + +# fix: Desktop Tor traffic leaks + +## Enhancement Summary + +**Deepened on:** 2026-04-01 +**Agents:** Security sentinel, Simplicity reviewer, Coil3 API verifier, Performance oracle + +### Critical Findings from Deepening +1. **SECURITY: Fail-closed** — `proxyClient.value ?: directClient` silently leaks during Tor bootstrap. Must return a dead-socket client when Tor expected but not ready. +2. **SECURITY: NIP-46 bunker** — `AccountManager` uses `simpleClient` (always direct). Bunker relay connections bypass Tor entirely. +3. **SECURITY: DesktopBlossomClient** — must use `get() =` not `=` (stale capture at construction). +4. **PERFORMANCE: Stagger relay reconnect** — 30 relays × Tor circuit simultaneously = 30-90s freeze. Need 500ms jitter. +5. **SIMPLICITY: lateinit var** — fail loudly on misconfiguration rather than silently falling back to direct client (IP leak). + +--- + +## Overview + +Manual testing with `lsof` revealed that with Tor ON (Full Privacy), only relay WebSocket connections go through the SOCKS proxy. 8 other network egress paths create their own `OkHttpClient()` and bypass Tor — leaking user IP to image CDNs, media servers, lightning providers, and Blossom hosts. + +## Problem Statement + +With Tor enabled, the Java process should have ONLY: +- Connections to `127.0.0.1:PORT` (local SOCKS proxy) +- The Tor daemon's own guard node connections + +Instead, `lsof` shows 9+ direct external TCP connections from Java to Cloudflare, Hetzner, and relay IPs — defeating Tor's privacy guarantees. + +## Proposed Solution + +**Global `DesktopHttpClient.currentClient()` accessor** (see brainstorm: `docs/brainstorms/2026-04-01-fix-tor-traffic-leaks-brainstorm.md`). Each leak site changes 1-2 lines to use the Tor-aware client instead of a bare `OkHttpClient()`. + +## Implementation + +### Phase 1: Add fail-closed global accessor to DesktopHttpClient + +**File:** `desktopApp/.../network/DesktopHttpClient.kt` + +Add to companion object: +```kotlin +lateinit var instance: DesktopHttpClient + private set + +fun setInstance(client: DesktopHttpClient) { instance = client } + +/** Fail-closed client — routes to dead proxy, requests fail instead of leaking. */ +private val failClosedClient: OkHttpClient by lazy { + OkHttpClient.Builder() + .proxy(java.net.Proxy(java.net.Proxy.Type.SOCKS, java.net.InetSocketAddress("127.0.0.1", 1))) + .connectTimeout(1, java.util.concurrent.TimeUnit.SECONDS) + .build() +} + +/** + * Returns the current Tor-aware client. + * - Tor active → proxy client (SOCKS) + * - Tor off → direct client + * - Tor expected but bootstrapping → FAIL-CLOSED (dead proxy, requests fail) + */ +fun currentClient(): OkHttpClient { + if (!::instance.isInitialized) return simpleClient // Pre-init only (tests, startup) + val client = instance + val proxyClient = client.proxyClient.value + if (proxyClient != null) return proxyClient + // Tor not active — check if it SHOULD be + return if (client.isTorExpected()) failClosedClient else client.getNonProxyClient() +} +``` + +Add `isTorExpected()` to `DesktopHttpClient` instance: +```kotlin +/** Returns true if user's settings expect Tor routing (INTERNAL or EXTERNAL mode). */ +fun isTorExpected(): Boolean = torTypeFlow.value != TorType.OFF +``` + +**File:** `desktopApp/.../Main.kt` + +After `httpClient` creation in `App`: +```kotlin +DesktopHttpClient.setInstance(httpClient) +``` + +### Phase 2: Fix 8 leak sites + +| File | Before | After | +|------|--------|-------| +| `AnimatedGifImage.kt:52` | `private val gifHttpClient by lazy { OkHttpClient.Builder()...build() }` | `private val gifHttpClient get() = DesktopHttpClient.currentClient()` | +| `SaveMediaAction.kt:32` | `private val httpClient = OkHttpClient()` | `private val httpClient get() = DesktopHttpClient.currentClient()` | +| `EncryptedMediaService.kt:36` | `private val httpClient = OkHttpClient()` | `private val httpClient get() = DesktopHttpClient.currentClient()` | +| `ServerHealthCheck.kt:30` | `private val httpClient = OkHttpClient.Builder()...build()` | Remove property. Inline `DesktopHttpClient.currentClient()` at call site. | +| `NoteActions.kt:939` | `val httpClient = OkHttpClient.Builder()...build()` | `val httpClient = DesktopHttpClient.currentClient()` | +| `DesktopBlossomClient.kt:37` | `private val okHttpClient: OkHttpClient = OkHttpClient()` | `private val okHttpClient: OkHttpClient get() = DesktopHttpClient.currentClient()` | +| `AccountManager.kt:141` | `DesktopHttpClient::getSimpleHttpClient` | `{ url -> DesktopHttpClient.currentClient() }` | + +**Note on DesktopBlossomClient:** Must use `get() =` (not `=`) so each upload picks up current Tor state. If Tor activates after dialog opens, uploads still route through Tor. + +**Note on AccountManager (NEW):** NIP-46 bunker relay connections currently always bypass Tor via `getSimpleHttpClient`. Security review flagged this as HIGH — bunker connections associate user's IP with signing operations. + +### Phase 3: Wire Coil image loader through Tor + +**File:** `desktopApp/.../service/images/DesktopImageLoaderSetup.kt` + +```kotlin +import coil3.network.okhttp.OkHttpNetworkFetcher + +ImageLoader.Builder(PlatformContext.INSTANCE) + .components { + add(OkHttpNetworkFetcher.factory { DesktopHttpClient.currentClient() }) + // ... existing decoders (SvgDecoder, SkiaGifDecoder, etc.) + } +``` + +Confirmed API: `OkHttpNetworkFetcher.factory(callFactory: () -> Call.Factory)` — lambda called per-request, so each image load picks up current Tor state. Coil's memory + disk caches (256MB + 512MB) mean cached images don't re-trigger network. + +### Phase 4: Staggered relay reconnection on Tor Active + +**File:** `desktopApp/.../Main.kt` + +```kotlin +LaunchedEffect(torManager) { + var previouslyActive = false + torManager.status.collect { status -> + val nowActive = status is TorServiceStatus.Active + if (nowActive && !previouslyActive) { + // Tor just became active — stagger reconnect to avoid thundering herd + // NostrClient.reconnect() disconnects all then reconnects + // 30 relays × Tor circuit = potential 30-90s if simultaneous + delay(500) // Brief delay for proxy client to propagate + relayManager.client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true) + } + previouslyActive = nowActive + } +} +``` + +Performance oracle recommends staggering inside `NostrClient.reconnect()`, but that requires modifying quartz. For this fix, the `delay(500)` ensures the proxy client StateFlow has propagated before reconnection starts. Full staggered reconnection is a follow-up. + +### Phase 5: Tests + +**New unit tests** (`desktopApp/src/jvmTest/.../network/DesktopHttpClientTest.kt`): + +``` +// Add to existing DesktopHttpClientTest: +- currentClient_instanceNotSet_returnsSimpleClient +- currentClient_torActive_returnsProxyClient +- currentClient_torOff_returnsDirectClient +- currentClient_torExpectedButBootstrapping_returnsFailClosed +- currentClient_failClosed_hasSocksProxy // Verify it has a proxy, not direct +- currentClient_failClosed_pointsToDeadPort // port 1, will fail fast +``` + +**Manual verification (lsof):** +```bash +# WITH Tor ON (Full Privacy): +lsof -i TCP -P -n | grep "^java" | grep ESTABLISHED | grep -v "127.0.0.1" +# Expected: EMPTY — zero direct connections + +# WITH Tor OFF: +lsof -i TCP -P -n | grep "^java" | grep ESTABLISHED | grep -v "127.0.0.1" +# Expected: Direct connections to relay IPs (control test) + +# DURING Tor bootstrap (first 10-30s): +lsof -i TCP -P -n | grep "^java" | grep ESTABLISHED | grep -v "127.0.0.1" +# Expected: EMPTY — fail-closed prevents leaks during bootstrap +``` + +**Grep CI check (prevent regressions):** +```bash +# Should return ZERO matches outside DesktopHttpClient.kt: +grep -r "OkHttpClient()" desktopApp/src/jvmMain/ --include="*.kt" | grep -v "DesktopHttpClient.kt" | grep -v "Test" +``` + +## Acceptance Criteria + +- [ ] `currentClient()` returns proxy client when Tor active +- [ ] `currentClient()` returns direct client when Tor off +- [ ] `currentClient()` returns **fail-closed client** when Tor expected but bootstrapping +- [ ] `currentClient()` throws (lateinit) if instance never set (misconfiguration = loud failure) +- [ ] Fail-closed client has SOCKS proxy pointing to dead port (requests fail, don't leak) +- [ ] `AnimatedGifImage` uses `currentClient()` via `get() =` +- [ ] `SaveMediaAction` uses `currentClient()` via `get() =` +- [ ] `EncryptedMediaService` uses `currentClient()` via `get() =` +- [ ] `ServerHealthCheck` inlines `currentClient()` at call site +- [ ] `NoteActions.zapNote()` uses `currentClient()` +- [ ] `DesktopBlossomClient` uses `currentClient()` via `get() =` (not `=`) +- [ ] `AccountManager` NIP-46 bunker uses `currentClient()` (not `simpleClient`) +- [ ] Coil uses `OkHttpNetworkFetcher.factory { currentClient() }` +- [ ] Relays reconnect through SOCKS when Tor transitions to Active +- [ ] `lsof` shows ZERO direct external connections from Java when Tor ON +- [ ] `lsof` shows ZERO direct connections during Tor bootstrap (fail-closed) +- [ ] `lsof` shows direct connections when Tor OFF (control) +- [ ] All existing tests pass +- [ ] Images load through Tor (slower but functional) +- [ ] Zaps work through Tor + +## Files Changed + +| File | Change | Lines | +|------|--------|-------| +| `DesktopHttpClient.kt` | `lateinit`, `currentClient()`, `failClosedClient`, `isTorExpected()` | +25 | +| `Main.kt` | `setInstance`, reconnect `LaunchedEffect` | +12 | +| `AnimatedGifImage.kt` | `get() = currentClient()` | 1 | +| `SaveMediaAction.kt` | `get() = currentClient()` | 1 | +| `EncryptedMediaService.kt` | `get() = currentClient()` | 1 | +| `ServerHealthCheck.kt` | Inline `currentClient()` at call site, remove property | -3, +1 | +| `NoteActions.kt` | Replace inline builder | 1 | +| `DesktopBlossomClient.kt` | `get() = currentClient()` | 1 | +| `AccountManager.kt` | Replace `getSimpleHttpClient` with `currentClient()` | 1 | +| `DesktopImageLoaderSetup.kt` | Add `OkHttpNetworkFetcher.factory` | 2 | +| `DesktopHttpClientTest.kt` | Add fail-closed + currentClient tests | +25 | + +**Total: ~70 lines changed across 11 files.** + +## Sources & References + +### Origin + +- **Brainstorm:** [docs/brainstorms/2026-04-01-fix-tor-traffic-leaks-brainstorm.md](docs/brainstorms/2026-04-01-fix-tor-traffic-leaks-brainstorm.md) +- Key decisions: Global accessor (Option A), fail-closed during bootstrap, Coil3 per-request lambda + +### Deepening Findings + +| Agent | Key Finding | Impact | +|-------|------------|--------| +| Security | `?: directClient` fallback leaks during bootstrap | **CRITICAL** — added fail-closed | +| Security | NIP-46 bunker bypasses Tor via `simpleClient` | **HIGH** — added AccountManager fix | +| Simplicity | `DesktopBlossomClient` `=` captures stale client | **HIGH** — changed to `get() =` | +| Simplicity | `lateinit var` fails loudly vs silent leak | Applied | +| Simplicity | ServerHealthCheck: inline, drop custom timeout | Applied | +| Coil3 | `OkHttpNetworkFetcher.factory { client }` correct API | Confirmed | +| Performance | 30-relay thundering herd through Tor = 30-90s | Added delay, follow-up for stagger | +| Performance | Image latency +450-1250ms first load, cached OK | Expected, documented | diff --git a/docs/plans/2026-04-02-feat-tor-toggle-restart-ux-plan.md b/docs/plans/2026-04-02-feat-tor-toggle-restart-ux-plan.md new file mode 100644 index 000000000..f4e6a0d69 --- /dev/null +++ b/docs/plans/2026-04-02-feat-tor-toggle-restart-ux-plan.md @@ -0,0 +1,183 @@ +--- +title: "feat: Tor toggle restart UX — confirmation dialog + app rebuild" +type: feat +status: active +date: 2026-04-02 +deepened: 2026-04-02 +--- + +# feat: Tor toggle restart UX + +## Enhancement Summary + +**Deepened on:** 2026-04-02 +**Agents:** Cleanup verifier, Simplicity reviewer, Compose Desktop edge cases + +### Key Improvements +1. **No new files** — inline AlertDialog, merge restart into `onSettingsChanged` +2. **DisposableEffect for TorManager** — critical: stop Tor daemon on unmount to prevent process leak +3. **Restart on ALL settings changes** — simpler than mode-only distinction, and toggle changes don't propagate at runtime either +4. **key() confirmed safe** — well-understood Compose pattern, scopes cancel, remember blocks reset + +--- + +## Overview + +When user changes any Tor setting, show confirmation dialog. On confirm: save prefs → `appRestartKey++` → Compose unmounts App tree → remounts fresh. Window stays open, user stays logged in. + +## Problem Statement + +Toggling Tor at runtime breaks relay subscriptions. TCP connections switch but `DesktopRelaySubscriptionsCoordinator` doesn't re-subscribe. Hot-toggle requires deep quartz changes. Simpler: rebuild app state via `key()`. + +## Implementation + +### Phase 1: Add `appRestartKey` + TorManager DisposableEffect + +**File: `Main.kt`** + +At Window level (~line 187): +```kotlin +var appRestartKey by remember { mutableStateOf(0) } +``` + +Wrap `App(...)` call (~line 413): +```kotlin +key(appRestartKey) { + App(...) +} +``` + +**CRITICAL: Add DisposableEffect for TorManager inside App:** +```kotlin +DisposableEffect(torManager) { + onDispose { + torManager.stopSync() + activeTorManager = null + } +} +``` +Without this, old Tor daemon keeps running → two Tor processes → port conflicts. + +**Merge restart into onSettingsChanged** in CompositionLocalProvider: +```kotlin +TorState( + status = currentTorStatus, + settings = torSettings, + onSettingsChanged = { newSettings -> + torSettings = newSettings + DesktopTorPreferences.save(newSettings) + torTypeFlow.value = newSettings.torType + externalPortFlow.value = newSettings.externalSocksPort + // Restart app to apply Tor changes + appRestartKey++ + }, +) +``` + +No separate `onRestartRequired` callback needed. + +### Phase 2: Add confirmation dialog to TorSettingsSection + +**File: `TorSettingsSection.kt`** + +Before applying changes, show inline confirmation: +```kotlin +var pendingSettings by remember { mutableStateOf(null) } + +// Mode selector onClick: +onClick = { + pendingSettings = currentSettings.copy(torType = torType) +} + +// Confirmation dialog: +pendingSettings?.let { settings -> + AlertDialog( + onDismissRequest = { pendingSettings = null }, + title = { Text("Restart Required") }, + text = { Text("Changing Tor settings requires restarting. Your session will be briefly interrupted.") }, + confirmButton = { + TextButton(onClick = { + onSettingsChanged(settings) + pendingSettings = null + }) { Text("Restart") } + }, + dismissButton = { + TextButton(onClick = { pendingSettings = null }) { Text("Cancel") } + }, + ) +} +``` + +### Phase 3: Add confirmation to TorSettingsDialog Save + +**File: `TorSettingsDialog.kt`** + +Same pattern — Save button shows confirmation before calling `onSettingsChanged`: +```kotlin +var showRestartConfirm by remember { mutableStateOf(false) } + +// Save button: +TextButton(onClick = { showRestartConfirm = true }) { Text("Save") } + +if (showRestartConfirm) { + AlertDialog( + onDismissRequest = { showRestartConfirm = false }, + title = { Text("Restart Required") }, + text = { Text("Tor changes require restarting. Proceed?") }, + confirmButton = { + TextButton(onClick = { + onSettingsChanged(editSettings) + onDismiss() + }) { Text("Restart") } + }, + dismissButton = { + TextButton(onClick = { showRestartConfirm = false }) { Text("Cancel") } + }, + ) +} +``` + +### Phase 4: Cleanup dead code + +Remove from `Main.kt`: +- Any reconnect-related code (torReconnectJob, evictConnections calls) +- Debug logging (TorReconnect log lines) + +Remove `evictConnections()` from `DesktopHttpClient.kt` (YAGNI — add back when needed). + +## What Survives the key() Restart + +| Component | Survives? | Why | +|-----------|-----------|-----| +| AccountManager | Yes | Outside App, at Window level | +| DeckState (columns) | Yes | Outside App, at Window level | +| Window position/size | Yes | windowState is outside App | +| Login session | Yes | AccountManager persists | +| Tor settings | Yes | Saved to java.util.prefs before restart | +| CoroutineScope | No — recreated | DisposableEffect cancels old | +| TorManager | No — recreated | DisposableEffect stops daemon | +| RelayManager | No — recreated | DisposableEffect disconnects | +| Subscriptions | No — recreated | DisposableEffect clears | +| Coil ImageLoader | Yes | Process-global singleton, not in Compose tree | + +## Acceptance Criteria + +- [ ] Any Tor setting change shows "Restart Required" dialog +- [ ] Cancel reverts — no settings change, no restart +- [ ] Confirm saves prefs + rebuilds app (window stays open) +- [ ] User stays logged in after rebuild +- [ ] Column layout preserved after rebuild +- [ ] Relays connected with correct mode after rebuild +- [ ] Feed loads after rebuild +- [ ] Old Tor daemon stopped before new one starts (no process leak) +- [ ] Shield icon correct after rebuild + +## Files Changed + +| File | Change | +|------|--------| +| `Main.kt` | Add `appRestartKey`, `key()` wrapper, `DisposableEffect` for TorManager, restart in `onSettingsChanged` | +| `TorSettingsSection.kt` | Confirmation dialog before applying mode change | +| `TorSettingsDialog.kt` | Confirmation dialog before Save | + +**3 files changed, 0 new files, ~40 lines added.** diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3a08ed679..f736855d6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,9 @@ fragmentKtx = "1.8.9" gms = "4.4.4" jacksonModuleKotlin = "2.21.2" javaKeyring = "1.0.4" +jtorctl = "0.4.5.7" +kmpTorRuntime = "2.6.0" +kmpTorResource = "409.5.0" junit = "4.13.2" kchesslib = "1.0.5" kotlin = "2.3.20" @@ -144,6 +147,9 @@ google-mlkit-language-id = { group = "com.google.mlkit", name = "language-id", v google-mlkit-translate = { group = "com.google.mlkit", name = "translate", version.ref = "translate" } jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jacksonModuleKotlin" } java-keyring = { group = "com.github.javakeyring", name = "java-keyring", version.ref = "javaKeyring" } +jtorctl = { module = "info.guardianproject:jtorctl", version.ref = "jtorctl" } +kmp-tor-runtime = { group = "io.matthewnelson.kmp-tor", name = "runtime", version.ref = "kmpTorRuntime" } +kmp-tor-resource-exec-tor = { group = "io.matthewnelson.kmp-tor", name = "resource-exec-tor", version.ref = "kmpTorResource" } junit = { group = "junit", name = "junit", version.ref = "junit" } kchesslib = { module = "io.github.cvb941:kchesslib", version.ref = "kchesslib" } kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" }