Merge pull request #2381 from nrobi144/feat/tor-support

feat: Desktop Tor support — embedded kmp-tor, full privacy routing, settings UI
This commit is contained in:
Vitor Pamplona
2026-04-13 11:52:42 -04:00
committed by GitHub
53 changed files with 3438 additions and 303 deletions
@@ -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
@@ -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
@@ -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
@@ -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<NormalizedRelayUrl>,
val dmRelayList: Set<NormalizedRelayUrl>,
) {
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
@@ -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
@@ -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
@@ -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
@@ -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)
@@ -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)
@@ -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() {
@@ -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
@@ -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
}
@@ -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
@@ -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
@@ -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<NormalizedRelayUrl> = setOf(dmRelay),
trustedRelays: Set<NormalizedRelayUrl> = 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))
}
}
@@ -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
}
@@ -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)
}
}
@@ -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<TorServiceStatus>
val activePortOrNull: StateFlow<Int?>
suspend fun dormant()
suspend fun active()
suspend fun newIdentity()
}
@@ -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)
}
@@ -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<NormalizedRelayUrl>,
val dmRelayList: Set<NormalizedRelayUrl>,
) {
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
}
}
}
@@ -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,
)
@@ -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()
}
@@ -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,
)
@@ -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<NormalizedRelayUrl> = setOf(dmRelay),
trustedRelays: Set<NormalizedRelayUrl> = 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))
}
}
@@ -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)
}
}
+5
View File
@@ -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"
@@ -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<com.vitorpamplona.quartz.nip01Core.core.Event?>(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<com.vitorpamplona.amethyst.commons.tor.TorType>,
externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow<Int>,
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
@@ -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()
@@ -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<OkHttpClient?> =
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()
}
}
@@ -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),
)
@@ -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<Uri> {
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 },
)
}
}
@@ -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<String, ByteArray>()
private const val MAX_CACHE_ENTRIES = 20
@@ -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
}
@@ -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,
@@ -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<TorType>,
private val externalPortFlow: StateFlow<Int>,
private val scope: CoroutineScope,
) : ITorManager {
private val _status = MutableStateFlow<TorServiceStatus>(TorServiceStatus.Off)
override val status: StateFlow<TorServiceStatus> = _status.asStateFlow()
override val activePortOrNull: StateFlow<Int?> =
_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")
}
}
}
}
}
@@ -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)
}
}
@@ -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
@@ -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 -> {
@@ -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(
@@ -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),
)
}
@@ -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()
@@ -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.
@@ -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 ""}",
@@ -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() }
@@ -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)
}
}
@@ -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<TorSettings?>(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") }
},
)
}
}
@@ -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),
)
}
}
}
@@ -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<TorServiceStatus>(
if (torPort != null) TorServiceStatus.Active(torPort) else TorServiceStatus.Off,
)
val portFlow = MutableStateFlow<Int?>(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)
}
}
@@ -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>(TorServiceStatus.Off)
override val activePortOrNull = MutableStateFlow<Int?>(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")
+134
View File
@@ -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
@@ -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 |
@@ -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<TorSettings?>(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.**
+6
View File
@@ -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" }