# Conflicts:
#	gradle/libs.versions.toml
This commit is contained in:
Vitor Pamplona
2026-04-13 17:28:28 -04:00
171 changed files with 12777 additions and 2854 deletions
+37
View File
@@ -174,6 +174,43 @@ if [ -n "${CLAUDE_ENV_FILE:-}" ]; then
echo "export PATH=\$PATH:$ANDROID_SDK_DIR/platform-tools" >> "$CLAUDE_ENV_FILE"
fi
# --- Kotlin/Native: pre-install native dependencies (GCC sysroot, LLDB) ---
# The K/N compiler downloads these on first use, but that fails through the proxy.
# Pre-download them so K/N benchmarks and linuxX64 compilation work out of the box.
KONAN_DIR="/root/.konan"
KONAN_DEPS_URL="https://download.jetbrains.com/kotlin/native"
KONAN_DEPS_DIR="$KONAN_DIR/dependencies"
install_konan_dep() {
local dep_name="$1"
local url="$2" # full download URL
# Already extracted?
if [ -d "$KONAN_DEPS_DIR/$dep_name" ]; then
return 0
fi
local archive="$dep_name.tar.gz"
local cache_dir="$KONAN_DEPS_DIR/cache"
mkdir -p "$cache_dir"
if [ ! -f "$cache_dir/$archive" ]; then
echo "Downloading K/N dependency $dep_name..."
curl -fsSL "$url" -o "$cache_dir/$archive" || {
echo "Failed to download $dep_name" >&2; return 1
}
fi
echo "Extracting $dep_name..."
tar -xzf "$cache_dir/$archive" -C "$KONAN_DEPS_DIR"
}
# Dependencies for linuxX64 target (from konan.properties for Kotlin 2.3.20)
install_konan_dep "x86_64-unknown-linux-gnu-gcc-8.3.0-glibc-2.19-kernel-4.9-2" \
"$KONAN_DEPS_URL/x86_64-unknown-linux-gnu-gcc-8.3.0-glibc-2.19-kernel-4.9-2.tar.gz"
install_konan_dep "lldb-4-linux" \
"$KONAN_DEPS_URL/lldb-4-linux.tar.gz"
install_konan_dep "llvm-19-x86_64-linux-essentials-109" \
"$KONAN_DEPS_URL/resources/llvm/19-x86_64-linux/llvm-19-x86_64-linux-essentials-109.tar.gz"
install_konan_dep "libffi-3.2.1-2-linux-x86-64" \
"$KONAN_DEPS_URL/libffi-3.2.1-2-linux-x86-64.tar.gz"
cd "$CLAUDE_PROJECT_DIR"
./gradlew --version > /dev/null 2>&1
+1
View File
@@ -167,3 +167,4 @@ desktopApp/src/jvmMain/appResources/windows/
# Git worktrees
.worktrees/
.claude/worktrees/
benchmark/src/main/jniLibs/
@@ -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
@@ -1865,7 +1865,7 @@ class Account(
* Check if a KeyPackage has been published, either locally generated
* in this session or found in the local cache from a previous session.
*/
fun hasPublishedKeyPackage(): Boolean {
suspend fun hasPublishedKeyPackage(): Boolean {
// Check in-memory bundles first (current session)
val manager = marmotManager
if (manager != null && manager.hasActiveKeyPackages()) return true
@@ -200,6 +200,9 @@ class AccountSettings(
val viewedPollResultNoteIds: MutableStateFlow<Map<String, Long>> = MutableStateFlow(mapOf()),
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow(mapOf()),
var backupNipA3PaymentTargets: PaymentTargetsEvent? = null,
var callTurnServers: List<CallTurnServer> = emptyList(),
var callVideoResolution: CallVideoResolution = CallVideoResolution.HD_720,
var callMaxBitrateBps: Int = 1_500_000,
) : EphemeralChatRepository,
PublicChatListRepository {
val saveable = MutableStateFlow(AccountSettingsUpdater(null))
@@ -920,4 +923,42 @@ class AccountSettings(
} else {
false
}
// ---
// Call settings
// ---
fun changeCallTurnServers(servers: List<CallTurnServer>) {
callTurnServers = servers
saveAccountSettings()
}
fun changeCallVideoResolution(resolution: CallVideoResolution) {
callVideoResolution = resolution
saveAccountSettings()
}
fun changeCallMaxBitrateBps(bitrate: Int) {
callMaxBitrateBps = bitrate
saveAccountSettings()
}
}
@Serializable
data class CallTurnServer(
val url: String,
val username: String,
val credential: String,
)
@Serializable
enum class CallVideoResolution(
val width: Int,
val height: Int,
val fps: Int,
val label: String,
) {
SD_480(640, 480, 30, "480p"),
HD_720(1280, 720, 30, "720p (default)"),
FHD_1080(1920, 1080, 30, "1080p"),
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model.marmot
import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
@@ -42,7 +43,17 @@ class AndroidMlsGroupStateStore(
private val rootDir: File,
private val encryption: KeyStoreEncryption = KeyStoreEncryption(),
) : MlsGroupStateStore {
private fun groupDir(nostrGroupId: String): File = File(rootDir, "mls_groups/$nostrGroupId")
private fun groupDir(nostrGroupId: String): File {
// Validate nostrGroupId is a hex string to prevent path traversal
require(nostrGroupId.matches(HEX_PATTERN)) {
"Invalid nostrGroupId: must be a hex string"
}
return File(rootDir, "mls_groups/$nostrGroupId")
}
companion object {
private val HEX_PATTERN = Regex("^[0-9a-fA-F]+$")
}
private fun stateFile(nostrGroupId: String): File = File(groupDir(nostrGroupId), "state")
@@ -158,7 +169,9 @@ class AndroidMlsGroupStateStore(
if (!tempFile.renameTo(target)) {
// Fallback: if rename fails (e.g., cross-filesystem), copy and delete
tempFile.copyTo(target, overwrite = true)
tempFile.delete()
if (!tempFile.delete()) {
Log.w("AndroidMlsGroupStateStore") { "Failed to delete temp file after copy fallback: ${tempFile.absolutePath}" }
}
}
}
}
@@ -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
@@ -97,8 +97,13 @@ class CallAudioManager(
}
fun startRinging() {
startRingtone()
startVibration()
val ringerMode = audioManager.ringerMode
if (ringerMode != AudioManager.RINGER_MODE_SILENT) {
if (ringerMode == AudioManager.RINGER_MODE_NORMAL) {
startRingtone()
}
startVibration()
}
}
fun stopRinging() {
@@ -184,7 +189,10 @@ class CallAudioManager(
PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK,
"amethyst:call_proximity",
)
proximityWakeLock?.acquire(60 * 60 * 1000L)
// No timeout — the wake lock is held for the entire call duration
// and released in releaseProximityWakeLock() during cleanup.
@Suppress("WakelockTimeout")
proximityWakeLock?.acquire()
registerProximitySensor()
}
@@ -320,10 +328,11 @@ class CallAudioManager(
}
}
}
@Suppress("DEPRECATION")
context.registerReceiver(
ContextCompat.registerReceiver(
context,
scoReceiver,
IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
}
@@ -22,6 +22,10 @@ package com.vitorpamplona.amethyst.service.call
import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import com.vitorpamplona.amethyst.commons.call.AnswerRouteAction
import com.vitorpamplona.amethyst.commons.call.CallManager
import com.vitorpamplona.amethyst.commons.call.CallState
@@ -49,9 +53,11 @@ import org.webrtc.EglBase
import org.webrtc.IceCandidate
import org.webrtc.VideoTrack
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
private const val TAG = "CallController"
private const val VIDEO_MAX_BITRATE_BPS = 1_500_000
private const val VIDEO_MAX_BITRATE_BPS_DEFAULT = 1_500_000
class CallController(
private val context: Context,
@@ -60,6 +66,7 @@ class CallController(
private val publishWrap: suspend (EphemeralGiftWrapEvent) -> Unit,
private val signerProvider: suspend () -> com.vitorpamplona.quartz.nip01Core.signers.NostrSigner,
localPubKey: HexKey,
private val settingsProvider: () -> com.vitorpamplona.amethyst.model.AccountSettings,
) {
private var peerSessionMgr = PeerSessionManager(localPubKey)
@@ -86,11 +93,35 @@ class CallController(
private val _isAudioMuted = MutableStateFlow(false)
val isAudioMuted: StateFlow<Boolean> = _isAudioMuted.asStateFlow()
val isVideoEnabled: StateFlow<Boolean> = mediaManager.isVideoEnabled
val isFrontCamera: StateFlow<Boolean> = mediaManager.isFrontCamera
val audioRoute: StateFlow<AudioRoute> = audioManager.audioRoute
val isBluetoothAvailable: StateFlow<Boolean> = audioManager.isBluetoothAvailable
private var videoPausedByProximity = false
private var foregroundServiceStarted = false
@Volatile private var videoPausedByProximity = false
@Volatile private var foregroundServiceStarted = false
private val cleanedUp = AtomicBoolean(false)
private val videoSenders = ConcurrentHashMap<HexKey, org.webrtc.RtpSender>()
private val pendingRenegotiation = ConcurrentHashMap<HexKey, Boolean>()
private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private var networkCallbackRegistered = false
private val networkCallback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
Log.d(TAG) { "Network available — triggering ICE restart on all peers" }
restartIceOnAllPeers()
}
override fun onCapabilitiesChanged(
network: Network,
capabilities: NetworkCapabilities,
) {
Log.d(TAG) { "Network capabilities changed — triggering ICE restart on all peers" }
restartIceOnAllPeers()
}
}
// ---- Initialization ----
@@ -101,6 +132,7 @@ class CallController(
callManager.state.collect { state ->
when (state) {
is CallState.IncomingCall -> {
ensureForegroundService()
withContext(Dispatchers.IO) { audioManager.startRinging() }
scope.launch {
NotificationUtils.showIncomingCallNotification(state.callerPubKey, context)
@@ -109,6 +141,7 @@ class CallController(
is CallState.Offering -> {
audioManager.startRingbackTone()
ensureForegroundService()
}
is CallState.Connecting -> {
@@ -117,6 +150,8 @@ class CallController(
withContext(Dispatchers.IO) { audioManager.switchToCallAudioMode() }
audioManager.acquireProximityWakeLock()
NotificationUtils.cancelCallNotification(context)
updateForegroundServiceNotification()
registerNetworkCallback()
}
is CallState.Connected -> {
@@ -125,6 +160,8 @@ class CallController(
withContext(Dispatchers.IO) { audioManager.switchToCallAudioMode() }
audioManager.acquireProximityWakeLock()
NotificationUtils.cancelCallNotification(context)
updateForegroundServiceNotification()
registerNetworkCallback()
}
is CallState.Ended -> {
@@ -132,7 +169,9 @@ class CallController(
}
is CallState.Idle -> {
cleanup()
// No cleanup here — Ended already handled it.
// Ended auto-resets to Idle after 2 seconds;
// running cleanup twice is wasteful and logs errors.
}
}
}
@@ -154,6 +193,12 @@ class CallController(
}
}
private fun applyCallSettings() {
val settings = settingsProvider()
val resolution = settings.callVideoResolution
mediaManager.setCaptureResolution(resolution.width, resolution.height, resolution.fps)
}
// ---- Call initiation (caller side) ----
fun initiateGroupCall(
@@ -165,16 +210,19 @@ class CallController(
val callId = UUID.randomUUID().toString()
_errorMessage.value = null
applyCallSettings()
try {
withContext(Dispatchers.IO) { mediaManager.initialize(callType) }
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize WebRTC", e)
_errorMessage.value = "Failed to start call: ${e.message}"
callManager.hangup()
return@launch
}
callManager.beginOffering(callId, peerPubKeys, callType)
var successCount = 0
for (peerPubKey in peerPubKeys) {
try {
val webRtcSession = withContext(Dispatchers.IO) { createWebRtcSession(peerPubKey) }
@@ -185,10 +233,16 @@ class CallController(
callManager.publishOfferToPeer(peerPubKey, peerPubKeys, callType, callId, sdp.description)
}
}
successCount++
} catch (e: Exception) {
Log.e(TAG, "Failed to create PeerConnection for ${peerPubKey.take(8)}", e)
}
}
if (successCount == 0) {
Log.e(TAG, "All PeerConnection creations failed, hanging up")
_errorMessage.value = "Failed to start call: could not create any connections"
callManager.hangup()
}
}
}
@@ -202,11 +256,13 @@ class CallController(
scope.launch {
_errorMessage.value = null
applyCallSettings()
try {
withContext(Dispatchers.IO) { mediaManager.initialize(state.callType) }
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize WebRTC", e)
_errorMessage.value = "Failed to accept call: ${e.message}"
callManager.rejectCall()
return@launch
}
@@ -216,6 +272,7 @@ class CallController(
} catch (e: Exception) {
Log.e(TAG, "Failed to create PeerConnection", e)
_errorMessage.value = "Failed to accept call: ${e.message}"
callManager.rejectCall()
return@launch
}
@@ -354,14 +411,57 @@ class CallController(
}
private fun performRenegotiation(peerPubKey: HexKey) {
val webRtcSession = webRtcSession(peerPubKey) ?: return
if (pendingRenegotiation.putIfAbsent(peerPubKey, true) != null) return
val webRtcSession =
webRtcSession(peerPubKey) ?: run {
pendingRenegotiation.remove(peerPubKey)
return
}
val state = callManager.state.value
if (state !is CallState.Connected && state !is CallState.Connecting) return
if (state !is CallState.Connected && state !is CallState.Connecting) {
pendingRenegotiation.remove(peerPubKey)
return
}
webRtcSession.createOffer { sdp ->
pendingRenegotiation.remove(peerPubKey)
scope.launch { callManager.sendRenegotiation(sdp.description, peerPubKey) }
}
}
// ---- Network change handling ----
private fun restartIceOnAllPeers() {
val state = callManager.state.value
if (state !is CallState.Connected && state !is CallState.Connecting) return
peerSessionMgr.allSessionKeys().forEach { key ->
webRtcSession(key)?.triggerIceRestart()
}
}
private fun registerNetworkCallback() {
if (networkCallbackRegistered) return
try {
val request =
NetworkRequest
.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, networkCallback)
networkCallbackRegistered = true
} catch (e: Exception) {
Log.e(TAG, "Failed to register network callback", e)
}
}
private fun unregisterNetworkCallback() {
if (!networkCallbackRegistered) return
try {
connectivityManager.unregisterNetworkCallback(networkCallback)
} catch (_: Exception) {
}
networkCallbackRegistered = false
}
// ---- UI toggle controls ----
fun toggleAudioMute() {
@@ -375,19 +475,37 @@ class CallController(
if (enabling) {
if (mediaManager.localVideoTrack == null) {
mediaManager.createVideoResources()
peerSessionMgr.allSessionKeys().forEach { key ->
webRtcSession(key)?.let { session ->
mediaManager.localVideoTrack?.let { track -> session.addTrack(track, VIDEO_MAX_BITRATE_BPS) }
}
}
} else {
mediaManager.enableVideo()
}
// Add video track to all peer connections
peerSessionMgr.allSessionKeys().forEach { key ->
if (videoSenders[key] == null) {
webRtcSession(key)?.let { session ->
mediaManager.localVideoTrack?.let { track ->
val sender = session.addTrack(track, settingsProvider().callMaxBitrateBps)
if (sender != null) {
videoSenders[key] = sender
}
}
}
}
}
} else {
// Remove video track senders so remote peers see track removal
peerSessionMgr.allSessionKeys().forEach { key ->
videoSenders.remove(key)?.let { sender ->
webRtcSession(key)?.removeTrack(sender)
}
}
mediaManager.disableVideo()
}
}
fun switchCamera() {
mediaManager.switchCamera()
}
fun cycleAudioRoute() {
audioManager.cycleAudioRoute()
}
@@ -395,7 +513,21 @@ class CallController(
fun getEglBase(): EglBase? = mediaManager.sharedEglBase
fun invitePeer(peerPubKey: String) {
scope.launch { createAndOfferToPeer(peerPubKey) }
scope.launch {
if (mediaManager.peerConnectionFactory == null) return@launch
val webRtcSession =
try {
withContext(Dispatchers.IO) { createWebRtcSession(peerPubKey) }
} catch (e: Exception) {
Log.e(TAG, "Failed to create PeerConnection for invite ${peerPubKey.take(8)}", e)
return@launch
}
val adapter = WebRtcPeerSessionAdapter(webRtcSession)
peerSessionMgr.registerSession(peerPubKey, adapter)
webRtcSession.createOffer { sdp ->
scope.launch { callManager.invitePeer(peerPubKey, sdp.description) }
}
}
}
fun hangup() {
@@ -413,20 +545,24 @@ class CallController(
val session =
WebRtcCallSession(
peerConnectionFactory = factory,
iceServers = IceServerConfig.buildIceServers(),
iceServers = IceServerConfig.buildIceServers(settingsProvider().callTurnServers),
onIceCandidate = { candidate -> onLocalIceCandidate(peerPubKey, candidate) },
onPeerConnected = {
Log.d(TAG) { "Peer ${peerPubKey.take(8)} connected!" }
scope.launch { callManager.onPeerConnected() }
if (!foregroundServiceStarted) {
foregroundServiceStarted = true
startForegroundService()
scope.launch {
callManager.onPeerConnected()
if (callManager.state.value is CallState.Connected) {
ensureForegroundService()
}
}
},
onRemoteVideoTrack = { track -> videoMonitor.onRemoteVideoTrack(peerPubKey, track) },
onDisconnected = { onPeerDisconnected(peerPubKey) },
onDisconnected = { scope.launch { onPeerDisconnected(peerPubKey) } },
onError = { error -> _errorMessage.value = error },
onRenegotiationNeeded = { performRenegotiation(peerPubKey) },
onIceRestartOffer = { sdp ->
scope.launch { callManager.sendRenegotiation(sdp.description, peerPubKey) }
},
)
try {
session.createPeerConnection()
@@ -435,7 +571,12 @@ class CallController(
throw e
}
mediaManager.localAudioTrack?.let { session.addTrack(it) }
mediaManager.localVideoTrack?.let { session.addTrack(it, VIDEO_MAX_BITRATE_BPS) }
mediaManager.localVideoTrack?.let { track ->
val sender = session.addTrack(track, settingsProvider().callMaxBitrateBps)
if (sender != null) {
videoSenders[peerPubKey] = sender
}
}
return session
}
@@ -455,6 +596,7 @@ class CallController(
// ---- Per-peer cleanup ----
fun disposePeerSession(peerPubKey: HexKey) {
videoSenders.remove(peerPubKey)
val entry = peerSessionMgr.removeSession(peerPubKey)
if (entry != null) {
try {
@@ -469,6 +611,8 @@ class CallController(
// ---- Cleanup ----
fun cleanup() {
if (!cleanedUp.compareAndSet(false, true)) return
unregisterNetworkCallback()
try {
audioManager.release()
} catch (e: Exception) {
@@ -495,10 +639,19 @@ class CallController(
_isAudioMuted.value = false
videoPausedByProximity = false
videoSenders.clear()
pendingRenegotiation.clear()
cleanedUp.set(false)
}
// ---- Foreground service ----
private fun ensureForegroundService() {
if (foregroundServiceStarted) return
foregroundServiceStarted = true
startForegroundService()
}
private fun startForegroundService() {
try {
val peerName = callManager.currentPeerPubKey() ?: ""
@@ -515,6 +668,21 @@ class CallController(
}
}
private fun updateForegroundServiceNotification() {
if (!foregroundServiceStarted) return
try {
val peerName = callManager.currentPeerPubKey() ?: ""
val intent =
Intent(context, CallForegroundService::class.java).apply {
action = CallForegroundService.ACTION_UPDATE
putExtra(CallForegroundService.EXTRA_PEER_NAME, peerName)
}
context.startService(intent)
} catch (e: Exception) {
Log.e(TAG, "Failed to update foreground service notification", e)
}
}
private fun stopForegroundService() {
try {
val intent =
@@ -24,6 +24,7 @@ import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.content.pm.PackageManager
@@ -34,6 +35,7 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.call.CallActivity
import com.vitorpamplona.quartz.utils.Log
private const val TAG = "CallForegroundService"
@@ -44,8 +46,11 @@ class CallForegroundService : Service() {
const val NOTIFICATION_ID = 9001
const val ACTION_START = "com.vitorpamplona.amethyst.CALL_START"
const val ACTION_STOP = "com.vitorpamplona.amethyst.CALL_STOP"
const val ACTION_UPDATE = "com.vitorpamplona.amethyst.CALL_UPDATE"
const val EXTRA_PEER_NAME = "peer_name"
const val EXTRA_IS_VIDEO = "is_video"
const val EXTRA_STATUS_TEXT = "status_text"
private const val HANGUP_REQUEST_CODE = 0x70001
}
override fun onBind(intent: Intent?): IBinder? = null
@@ -64,7 +69,8 @@ class CallForegroundService : Service() {
ACTION_START -> {
val peerName = intent.getStringExtra(EXTRA_PEER_NAME) ?: "Unknown"
val isVideo = intent.getBooleanExtra(EXTRA_IS_VIDEO, false)
val notification = buildNotification(peerName)
val statusText = intent.getStringExtra(EXTRA_STATUS_TEXT)
val notification = buildNotification(peerName, statusText)
val hasAudioPermission =
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
@@ -97,6 +103,14 @@ class CallForegroundService : Service() {
}
}
ACTION_UPDATE -> {
val peerName = intent.getStringExtra(EXTRA_PEER_NAME) ?: "Unknown"
val statusText = intent.getStringExtra(EXTRA_STATUS_TEXT)
val notification = buildNotification(peerName, statusText)
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.notify(NOTIFICATION_ID, notification)
}
ACTION_STOP -> {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
@@ -118,12 +132,40 @@ class CallForegroundService : Service() {
notificationManager.createNotificationChannel(channel)
}
private fun buildNotification(peerName: String): Notification =
NotificationCompat
private fun buildNotification(
peerName: String,
statusText: String? = null,
): Notification {
val openCallIntent =
PendingIntent.getActivity(
this,
0,
Intent(this, CallActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val hangupIntent =
PendingIntent.getBroadcast(
this,
HANGUP_REQUEST_CODE,
Intent(this, CallNotificationReceiver::class.java).apply {
action = CallNotificationReceiver.ACTION_HANGUP_CALL
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val contentText = statusText ?: getString(R.string.call_with, peerName)
return NotificationCompat
.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.call_with, peerName))
.setContentText(contentText)
.setSmallIcon(R.drawable.amethyst)
.setOngoing(true)
.setContentIntent(openCallIntent)
.addAction(R.drawable.ic_call_end, getString(R.string.call_hangup), hangupIntent)
.build()
}
}
@@ -67,6 +67,7 @@ class CallMediaManager(
private var cameraCapturer: CameraVideoCapturer? = null
private var surfaceTextureHelper: SurfaceTextureHelper? = null
private var usingFrontCamera: Boolean = true
private val _localVideoTrackFlow = MutableStateFlow<VideoTrack?>(null)
val localVideoTrackFlow: StateFlow<VideoTrack?> = _localVideoTrackFlow.asStateFlow()
@@ -74,24 +75,39 @@ class CallMediaManager(
private val _isVideoEnabled = MutableStateFlow(false)
val isVideoEnabled: StateFlow<Boolean> = _isVideoEnabled.asStateFlow()
private val _isFrontCamera = MutableStateFlow(true)
val isFrontCamera: StateFlow<Boolean> = _isFrontCamera.asStateFlow()
fun initialize(callType: CallType) {
if (peerConnectionFactory != null) return
sharedEglBase = EglBase.create()
var egl: EglBase? = null
try {
egl = EglBase.create()
PeerConnectionFactory.initialize(
PeerConnectionFactory
.InitializationOptions
.builder(context)
.createInitializationOptions(),
)
PeerConnectionFactory.initialize(
PeerConnectionFactory
.InitializationOptions
.builder(context)
.createInitializationOptions(),
)
peerConnectionFactory =
PeerConnectionFactory
.builder()
.setVideoDecoderFactory(DefaultVideoDecoderFactory(sharedEglBase!!.eglBaseContext))
.setVideoEncoderFactory(DefaultVideoEncoderFactory(sharedEglBase!!.eglBaseContext, true, true))
.createPeerConnectionFactory()
peerConnectionFactory =
PeerConnectionFactory
.builder()
.setVideoDecoderFactory(DefaultVideoDecoderFactory(egl.eglBaseContext))
.setVideoEncoderFactory(DefaultVideoEncoderFactory(egl.eglBaseContext, true, true))
.createPeerConnectionFactory()
sharedEglBase = egl
} catch (e: Exception) {
// Clean up partially-created resources to avoid leaking EglBase
egl?.release()
peerConnectionFactory?.dispose()
peerConnectionFactory = null
sharedEglBase = null
throw e
}
localAudioSource = peerConnectionFactory?.createAudioSource(MediaConstraints())
localAudioTrack = peerConnectionFactory?.createAudioTrack("audio0", localAudioSource)
@@ -101,6 +117,7 @@ class CallMediaManager(
}
}
@Synchronized
fun createVideoResources() {
if (localVideoSource != null) return
val factory = peerConnectionFactory ?: return
@@ -112,24 +129,63 @@ class CallMediaManager(
startCamera()
}
var captureWidth: Int = 1280
private set
var captureHeight: Int = 720
private set
var captureFps: Int = 30
private set
fun setCaptureResolution(
width: Int,
height: Int,
fps: Int,
) {
captureWidth = width
captureHeight = height
captureFps = fps
}
fun startCamera() {
if (cameraCapturer != null) return
val source = localVideoSource ?: return
val egl = sharedEglBase ?: return
val enumerator = Camera2Enumerator(context)
val frontCamera = enumerator.deviceNames.firstOrNull { enumerator.isFrontFacing(it) }
val camera = frontCamera ?: enumerator.deviceNames.firstOrNull() ?: return
val preferred =
if (usingFrontCamera) {
enumerator.deviceNames.firstOrNull { enumerator.isFrontFacing(it) }
} else {
enumerator.deviceNames.firstOrNull { enumerator.isBackFacing(it) }
}
val camera = preferred ?: enumerator.deviceNames.firstOrNull() ?: return
val helper = SurfaceTextureHelper.create("CaptureThread", egl.eglBaseContext)
surfaceTextureHelper = helper
cameraCapturer =
enumerator.createCapturer(camera, null)?.also {
it.initialize(helper, context, source.capturerObserver)
it.startCapture(1280, 720, 30)
it.startCapture(captureWidth, captureHeight, captureFps)
}
}
fun switchCamera() {
val capturer = cameraCapturer ?: return
capturer.switchCamera(
object : CameraVideoCapturer.CameraSwitchHandler {
override fun onCameraSwitchDone(isFront: Boolean) {
usingFrontCamera = isFront
_isFrontCamera.value = isFront
Log.d(TAG) { "Camera switched: front=$isFront" }
}
override fun onCameraSwitchError(error: String?) {
Log.e(TAG, "Camera switch failed: $error")
}
},
)
}
fun stopCamera() {
try {
cameraCapturer?.stopCapture()
@@ -145,6 +201,7 @@ class CallMediaManager(
localVideoTrack?.setEnabled(true)
_isVideoEnabled.value = true
_localVideoTrackFlow.value = localVideoTrack
startCamera()
}
fun disableVideo() {
@@ -24,8 +24,9 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/**
@@ -36,24 +37,34 @@ import kotlinx.coroutines.launch
* notification trampoline restrictions.
*/
class CallNotificationReceiver : BroadcastReceiver() {
@OptIn(DelicateCoroutinesApi::class)
override fun onReceive(
context: Context,
intent: Intent,
) {
when (intent.action) {
ACTION_REJECT_CALL -> {
NotificationUtils.cancelCallNotification(context)
val callManager = CallSessionBridge.callManager ?: return
val pendingResult = goAsync()
val scope = CoroutineScope(SupervisorJob() + kotlinx.coroutines.Dispatchers.Main.immediate)
scope.launch {
try {
when (intent.action) {
ACTION_REJECT_CALL -> {
NotificationUtils.cancelCallNotification(context)
callManager.rejectCall()
}
val callManager = CallSessionBridge.callManager ?: return
GlobalScope.launch {
callManager.rejectCall()
ACTION_HANGUP_CALL -> {
callManager.hangup()
}
}
} finally {
pendingResult.finish()
scope.cancel()
}
}
}
companion object {
const val ACTION_REJECT_CALL = "com.vitorpamplona.amethyst.REJECT_CALL"
const val ACTION_HANGUP_CALL = "com.vitorpamplona.amethyst.HANGUP_CALL"
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.call
import com.vitorpamplona.amethyst.model.CallTurnServer
import org.webrtc.PeerConnection
object IceServerConfig {
@@ -49,23 +50,25 @@ object IceServerConfig {
.createIceServer(),
)
fun buildIceServers(userTurnServers: List<TurnServerConfig> = emptyList()): List<PeerConnection.IceServer> {
val servers = (defaultStunServers + defaultTurnServers).toMutableList()
userTurnServers.forEach { turn ->
servers.add(
PeerConnection.IceServer
.builder(turn.url)
.setUsername(turn.username)
.setPassword(turn.credential)
.createIceServer(),
)
}
return servers
/**
* Builds the ICE server list. If the user has configured custom TURN
* servers they replace the built-in OpenRelay defaults so that
* credentials can be rotated without an app update. STUN servers
* are always included.
*/
fun buildIceServers(userTurnServers: List<CallTurnServer> = emptyList()): List<PeerConnection.IceServer> {
val turnServers =
if (userTurnServers.isNotEmpty()) {
userTurnServers.map { turn ->
PeerConnection.IceServer
.builder(turn.url)
.setUsername(turn.username)
.setPassword(turn.credential)
.createIceServer()
}
} else {
defaultTurnServers
}
return defaultStunServers + turnServers
}
}
data class TurnServerConfig(
val url: String,
val username: String,
val credential: String,
)
@@ -85,45 +85,56 @@ class RemoteVideoMonitor(
private val perPeerLastFrameTimeMs = ConcurrentHashMap<HexKey, AtomicLong>()
private var groupVideoMonitorJob: Job? = null
/** Protects compound read-modify-write on [_remoteVideoTracks] and
* [_remoteVideoTrack] which can be called from WebRTC callback threads. */
private val trackLock = Any()
fun onRemoteVideoTrack(
peerPubKey: HexKey,
track: VideoTrack,
) {
Log.d(TAG) { "Remote video track from ${peerPubKey.take(8)}" }
_remoteVideoTracks.value = _remoteVideoTracks.value + (peerPubKey to track)
if (_remoteVideoTrack.value == null) {
_remoteVideoTrack.value = track
startPrimaryMonitor(track)
synchronized(trackLock) {
_remoteVideoTracks.value = _remoteVideoTracks.value + (peerPubKey to track)
if (_remoteVideoTrack.value == null) {
_remoteVideoTrack.value = track
startPrimaryMonitor(track)
}
}
startPeerMonitor(peerPubKey, track)
}
fun onPeerRemoved(peerPubKey: HexKey) {
stopPeerMonitor(peerPubKey)
val currentTracks = _remoteVideoTracks.value
if (peerPubKey in currentTracks) {
_remoteVideoTracks.value = currentTracks - peerPubKey
if (_remoteVideoTrack.value == currentTracks[peerPubKey]) {
stopPrimaryMonitor()
val nextTrack = _remoteVideoTracks.value.values.firstOrNull()
_remoteVideoTrack.value = nextTrack
if (nextTrack != null) {
startPrimaryMonitor(nextTrack)
synchronized(trackLock) {
val currentTracks = _remoteVideoTracks.value
if (peerPubKey in currentTracks) {
_remoteVideoTracks.value = currentTracks - peerPubKey
if (_remoteVideoTrack.value == currentTracks[peerPubKey]) {
stopPrimaryMonitor()
val nextTrack = _remoteVideoTracks.value.values.firstOrNull()
_remoteVideoTrack.value = nextTrack
if (nextTrack != null) {
startPrimaryMonitor(nextTrack)
}
}
}
}
}
fun dispose() {
stopPrimaryMonitor()
for (peerPubKey in perPeerFrameSinks.keys.toList()) {
stopPeerMonitor(peerPubKey)
synchronized(trackLock) {
stopPrimaryMonitor()
stopGroupMonitor()
for (peerPubKey in perPeerFrameSinks.keys.toList()) {
stopPeerMonitor(peerPubKey)
}
_remoteVideoTrack.value = null
_remoteVideoTracks.value = emptyMap()
_isRemoteVideoActive.value = false
_remoteVideoAspectRatio.value = null
_activePeerVideos.value = emptySet()
}
_remoteVideoTrack.value = null
_remoteVideoTracks.value = emptyMap()
_isRemoteVideoActive.value = false
_remoteVideoAspectRatio.value = null
_activePeerVideos.value = emptySet()
}
private fun startPrimaryMonitor(track: VideoTrack) {
@@ -143,14 +154,17 @@ class RemoteVideoMonitor(
private fun stopPrimaryMonitor() {
remoteVideoMonitorJob?.cancel()
remoteVideoMonitorJob = null
groupVideoMonitorJob?.cancel()
groupVideoMonitorJob = null
try {
_remoteVideoTrack.value?.removeSink(remoteFrameSink)
} catch (_: Exception) {
}
}
private fun stopGroupMonitor() {
groupVideoMonitorJob?.cancel()
groupVideoMonitorJob = null
}
private fun startPeerMonitor(
peerPubKey: HexKey,
track: VideoTrack,
@@ -53,8 +53,11 @@ class WebRtcCallSession(
private val onDisconnected: () -> Unit,
private val onError: (String) -> Unit = {},
private val onRenegotiationNeeded: () -> Unit = {},
private val onIceRestartOffer: (SessionDescription) -> Unit = {},
) {
private var peerConnection: PeerConnection? = null
@Volatile private var peerConnection: PeerConnection? = null
@Volatile private var iceRestartAttempted = false
fun createPeerConnection() {
val rtcConfig =
@@ -63,7 +66,7 @@ class WebRtcCallSession(
continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY
}
peerConnection =
val pc =
peerConnectionFactory.createPeerConnection(
rtcConfig,
object : PeerConnection.Observer {
@@ -90,6 +93,7 @@ class WebRtcCallSession(
PeerConnection.IceConnectionState.CONNECTED -> {
Log.d(TAG) { "ICE: CONNECTED - peer connection established!" }
iceRestartAttempted = false
onPeerConnected()
}
@@ -98,9 +102,15 @@ class WebRtcCallSession(
}
PeerConnection.IceConnectionState.FAILED -> {
Log.e(TAG, "ICE: FAILED - could not establish connection")
onError("Connection failed - check network")
onDisconnected()
if (!iceRestartAttempted) {
iceRestartAttempted = true
Log.d(TAG) { "ICE: FAILED - attempting ICE restart" }
restartIce()
} else {
Log.e(TAG, "ICE: FAILED after restart - giving up")
onError("Connection failed - check network")
onDisconnected()
}
}
PeerConnection.IceConnectionState.DISCONNECTED -> {
@@ -146,7 +156,8 @@ class WebRtcCallSession(
}
}
},
)
) ?: throw IllegalStateException("PeerConnectionFactory.createPeerConnection returned null")
peerConnection = pc
}
/**
@@ -169,6 +180,13 @@ class WebRtcCallSession(
return sender
}
/**
* Removes the sender for the given track from this PeerConnection.
* This signals to the remote peer that the track has been removed
* (e.g. camera turned off) rather than just sending a black frame.
*/
fun removeTrack(sender: RtpSender): Boolean = peerConnection?.removeTrack(sender) ?: false
fun createOffer(onSdpCreated: (SessionDescription) -> Unit) {
val constraints =
MediaConstraints().apply {
@@ -255,6 +273,51 @@ class WebRtcCallSession(
Log.d(TAG) { "addIceCandidate result=$added sdpMid=${candidate.sdpMid} sdp=${candidate.sdp.take(60)}" }
}
/**
* Triggers an ICE restart by creating a new offer with iceRestart=true.
* This re-gathers candidates and attempts to establish connectivity
* without tearing down the PeerConnection.
*/
private fun restartIce() {
val constraints =
MediaConstraints().apply {
mandatory.add(MediaConstraints.KeyValuePair("IceRestart", "true"))
mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true"))
mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true"))
}
peerConnection?.createOffer(
object : SdpObserver {
override fun onCreateSuccess(sdp: SessionDescription?) {
sdp?.let {
Log.d(TAG) { "ICE restart offer created, setting local description and sending to peer" }
peerConnection?.setLocalDescription(loggingSdpObserver("setLocalDescription(ICE_RESTART)"), it)
onIceRestartOffer(it)
}
}
override fun onCreateFailure(error: String?) {
Log.e(TAG, "ICE restart offer creation failed: $error")
onError("Connection failed - check network")
onDisconnected()
}
override fun onSetSuccess() {}
override fun onSetFailure(error: String?) {}
},
constraints,
)
}
/**
* Triggers an ICE restart proactively (e.g. on network change).
* Resets the attempt counter so the restart is always tried.
*/
fun triggerIceRestart() {
iceRestartAttempted = false
restartIce()
}
fun getSignalingState(): PeerConnection.SignalingState? = peerConnection?.signalingState()
/**
@@ -284,9 +347,10 @@ class WebRtcCallSession(
}
fun dispose() {
peerConnection?.close()
peerConnection?.dispose()
val pc = peerConnection ?: return
peerConnection = null
pc.close()
pc.dispose()
}
private fun loggingSdpObserver(label: String) =
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.images
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.sha256.sha256
import java.io.File
import java.util.concurrent.ConcurrentHashMap
@@ -65,7 +66,10 @@ class ThumbnailDiskCache(
return try {
BitmapFactory.decodeFile(file.absolutePath)
} catch (e: Exception) {
file.delete()
Log.w("ThumbnailDiskCache", "Failed to decode cached thumbnail, deleting: ${file.absolutePath}", e)
if (!file.delete()) {
Log.w("ThumbnailDiskCache") { "Failed to delete corrupt cache file: ${file.absolutePath}" }
}
null
}
}
@@ -116,11 +120,18 @@ class ThumbnailDiskCache(
scaled.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out)
}
scaled.recycle()
tempFile.renameTo(finalFile)
if (!tempFile.renameTo(finalFile)) {
Log.w("ThumbnailDiskCache") { "Failed to rename temp thumbnail to final: ${tempFile.absolutePath}" }
if (!tempFile.delete()) {
Log.w("ThumbnailDiskCache") { "Failed to delete temp thumbnail: ${tempFile.absolutePath}" }
}
return false
}
evictIfNeeded()
return true
} catch (e: Exception) {
Log.w("ThumbnailDiskCache", "Failed to generate thumbnail for $url", e)
return false
} finally {
inFlight.remove(url)
@@ -46,10 +46,16 @@ class OkHttpClientFactory(
.addNetworkInterceptor(keyDecryptor)
.build()
private var lastProxy: Proxy? = null
fun buildHttpClient(
proxy: Proxy?,
timeoutSeconds: Int,
): OkHttpClient {
if (proxy != lastProxy) {
rootClient.connectionPool.evictAll()
lastProxy = proxy
}
val seconds = if (proxy != null) timeoutSeconds * 3 else timeoutSeconds
return rootClient
.newBuilder()
@@ -37,6 +37,7 @@ class OkHttpClientFactoryForRelays(
const val DEFAULT_IS_MOBILE: Boolean = false
const val DEFAULT_TIMEOUT_ON_WIFI_SECS: Int = 10
const val DEFAULT_TIMEOUT_ON_MOBILE_SECS: Int = 30
const val WEBSOCKET_PING_INTERVAL_SECS: Long = 120
private fun isEmulator(): Boolean =
Build.FINGERPRINT.startsWith("generic") ||
@@ -76,10 +77,16 @@ class OkHttpClientFactoryForRelays(
.addInterceptor(DefaultContentTypeInterceptor(userAgent))
.build()
private var lastProxy: Proxy? = null
fun buildHttpClient(
proxy: Proxy?,
timeoutSeconds: Int,
): OkHttpClient {
if (proxy != lastProxy) {
rootClient.connectionPool.evictAll()
lastProxy = proxy
}
val seconds = if (proxy != null) timeoutSeconds * 3 else timeoutSeconds
return rootClient
.newBuilder()
@@ -87,6 +94,7 @@ class OkHttpClientFactoryForRelays(
.connectTimeout(Duration.ofSeconds(seconds.toLong()))
.readTimeout(Duration.ofSeconds(seconds.toLong() * 3))
.writeTimeout(Duration.ofSeconds(seconds.toLong() * 3))
.pingInterval(Duration.ofSeconds(WEBSOCKET_PING_INTERVAL_SECS))
.build()
}
@@ -55,12 +55,12 @@ class MarmotGroupEventsEoseManager(
val fallbackRelays = key.account.homeRelays.flow.value
// Per-group kind:445 filters — route each to the group's own relays
val groupStates = manager.subscriptionManager.activeGroupIds()
val groupStates = manager.subscriptionManager.activeGroupIdsSnapshot()
for (groupId in groupStates) {
val filter =
manager.subscriptionManager.let { sub ->
// Build the filter for this specific group
val groupFilters = sub.activeGroupFilters()
val groupFilters = sub.activeGroupFiltersSnapshot()
// activeGroupFilters() returns one filter per group; match by tag content
groupFilters.find { f ->
f.tags?.any { it.value?.contains(groupId) == true } == true
@@ -1,629 +0,0 @@
/*
* 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.uploads
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Movie
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaFormat
import android.media.MediaMuxer
import android.net.Uri
import android.opengl.EGL14
import android.opengl.EGLConfig
import android.opengl.EGLContext
import android.opengl.EGLDisplay
import android.opengl.EGLExt
import android.opengl.EGLSurface
import android.opengl.GLES20
import android.opengl.GLUtils
import android.view.Surface
import androidx.annotation.VisibleForTesting
import androidx.core.net.toUri
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.FloatBuffer
import java.util.UUID
object GifToMp4Converter {
private const val LOG_TAG = "GifToMp4Converter"
private const val I_FRAME_INTERVAL = 1
private const val TIMEOUT_US = 10_000L
private const val DEFAULT_BITRATE_BPS = 2_000_000
private const val DEFAULT_FRAME_DELAY_MS = 100
private const val US_PER_MS = 1000L
private const val NS_PER_US = 1000L
private const val MAX_GIF_SIZE_BYTES = 20 * 1024 * 1024
private const val DRAIN_EOS_MAX_ITERATIONS = 500
// Fullscreen quad: 4 vertices x (2 position + 2 texcoord) floats
// Texcoord Y is flipped because bitmap origin is top-left, GL is bottom-left
private val QUAD_COORDS =
floatArrayOf(
-1f,
-1f,
0f,
1f,
1f,
-1f,
1f,
1f,
-1f,
1f,
0f,
0f,
1f,
1f,
1f,
0f,
)
private const val VERTEX_SHADER =
"attribute vec4 aPosition;\n" +
"attribute vec2 aTexCoord;\n" +
"varying vec2 vTexCoord;\n" +
"void main() {\n" +
" gl_Position = aPosition;\n" +
" vTexCoord = aTexCoord;\n" +
"}\n"
private const val FRAGMENT_SHADER =
"precision mediump float;\n" +
"varying vec2 vTexCoord;\n" +
"uniform sampler2D uTexture;\n" +
"void main() {\n" +
" gl_FragColor = texture2D(uTexture, vTexCoord);\n" +
"}\n"
suspend fun convert(
uri: Uri,
context: Context,
): MediaCompressorResult? =
// Dispatchers.Default: the bulk of this work is CPU/GPU bound
// (Movie decode, GL rendering, MediaCodec encode). Running on IO
// would occupy a thread from the large IO pool for several seconds
// with no kernel wait, risking starvation of real IO coroutines.
// The brief file read at the start and muxer writes are acceptable
// on Default — they're short relative to the encoding loop.
withContext(Dispatchers.Default) {
try {
convertInternal(uri, context)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(LOG_TAG, "GIF to MP4 conversion failed", e)
null
}
}
@Suppress("deprecation") // android.graphics.Movie is deprecated but still the simplest GIF decoder available
private fun convertInternal(
uri: Uri,
context: Context,
): MediaCompressorResult? {
val gifBytes =
context.contentResolver.openInputStream(uri)?.use { input ->
val buffer = ByteArray(MAX_GIF_SIZE_BYTES + 1)
var total = 0
while (total <= MAX_GIF_SIZE_BYTES) {
val read = input.read(buffer, total, buffer.size - total)
if (read == -1) break
total += read
}
if (total > MAX_GIF_SIZE_BYTES) {
Log.w(LOG_TAG) { "GIF exceeds max size of $MAX_GIF_SIZE_BYTES bytes" }
return null
}
buffer.copyOf(total)
} ?: run {
Log.w(LOG_TAG) { "Failed to read GIF bytes" }
return null
}
val movie =
Movie.decodeByteArray(gifBytes, 0, gifBytes.size)
?: run {
Log.w(LOG_TAG) { "Failed to decode GIF" }
return null
}
val gifWidth = movie.width()
val gifHeight = movie.height()
val durationMs = movie.duration()
if (gifWidth <= 0 || gifHeight <= 0 || durationMs <= 0) {
Log.w(LOG_TAG) { "Invalid GIF dimensions ($gifWidth x $gifHeight) or duration ($durationMs ms)" }
return null
}
val frameDelays = parseGifFrameDelays(gifBytes)
if (frameDelays.isEmpty()) {
Log.w(LOG_TAG) { "No frames found in GIF" }
return null
}
val totalDelayMs = frameDelays.sum()
val avgFps =
if (totalDelayMs > 0) {
(frameDelays.size * 1000.0 / totalDelayMs).toInt().coerceIn(1, 50)
} else {
10
}
val width = gifWidth.roundToEven()
val height = gifHeight.roundToEven()
Log.d(LOG_TAG) {
"Converting GIF: ${gifWidth}x$gifHeight, duration=${durationMs}ms, " +
"frames=${frameDelays.size}, avgFps=$avgFps -> MP4 ${width}x$height"
}
val outputFile = File(context.cacheDir, "${UUID.randomUUID()}.mp4")
var codec: MediaCodec? = null
var muxer: MediaMuxer? = null
var egl: EglHelper? = null
var codecSurface: Surface? = null
try {
val mimeType = MediaFormat.MIMETYPE_VIDEO_AVC
val format =
MediaFormat.createVideoFormat(mimeType, width, height).apply {
setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
setInteger(MediaFormat.KEY_BIT_RATE, calculateBitrate(width, height))
setInteger(MediaFormat.KEY_FRAME_RATE, avgFps)
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, I_FRAME_INTERVAL)
}
codec = MediaCodec.createEncoderByType(mimeType)
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
codecSurface = codec.createInputSurface()
codec.start()
// Wrap the codec surface with EGL for presentation timestamp control
egl = EglHelper(codecSurface)
egl.makeCurrent()
// Set up GL program and texture for drawing bitmaps
val program = createGlProgram()
val textureId = createGlTexture()
val vertexBuffer = createVertexBuffer()
muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
var trackIndex = -1
var muxerStarted = false
val bufferInfo = MediaCodec.BufferInfo()
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val bitmapCanvas = Canvas(bitmap)
var presentationTimeUs = 0L
var gifTimeMs = 0
for (i in frameDelays.indices) {
movie.setTime(gifTimeMs)
bitmapCanvas.drawColor(Color.WHITE)
movie.draw(bitmapCanvas, 0f, 0f)
// Draw bitmap to EGL surface via GL texture
drawBitmapFrame(bitmap, textureId, program, vertexBuffer, width, height)
// Set the precise presentation timestamp and submit
egl.setPresentationTime(presentationTimeUs * NS_PER_US)
egl.swapBuffers()
// Drain encoder output
val drainResult = drainEncoder(codec, muxer, bufferInfo, trackIndex, muxerStarted, false)
trackIndex = drainResult.first
muxerStarted = drainResult.second
presentationTimeUs += frameDelays[i] * US_PER_MS
gifTimeMs += frameDelays[i]
}
// Signal end of stream and drain
codec.signalEndOfInputStream()
drainEncoder(codec, muxer, bufferInfo, trackIndex, muxerStarted, true)
// Cleanup GL resources
GLES20.glDeleteTextures(1, intArrayOf(textureId), 0)
GLES20.glDeleteProgram(program)
bitmap.recycle()
Log.d(LOG_TAG) { "GIF to MP4 conversion complete: ${outputFile.length()} bytes" }
return MediaCompressorResult(outputFile.toUri(), "video/mp4", outputFile.length())
} catch (e: Exception) {
Log.e(LOG_TAG, "Encoding failed", e)
if (outputFile.exists()) outputFile.delete()
return null
} finally {
try {
egl?.release()
} catch (_: Exception) {
}
try {
codec?.stop()
} catch (_: Exception) {
}
try {
codec?.release()
} catch (_: Exception) {
}
try {
codecSurface?.release()
} catch (_: Exception) {
}
try {
muxer?.stop()
} catch (_: Exception) {
}
try {
muxer?.release()
} catch (_: Exception) {
}
}
}
// region EGL
private class EglHelper(
surface: Surface,
) {
val display: EGLDisplay
val context: EGLContext
val eglSurface: EGLSurface
init {
display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
check(display != EGL14.EGL_NO_DISPLAY) { "eglGetDisplay failed" }
val version = IntArray(2)
check(EGL14.eglInitialize(display, version, 0, version, 1)) { "eglInitialize failed" }
val configAttribs =
intArrayOf(
EGL14.EGL_RED_SIZE,
8,
EGL14.EGL_GREEN_SIZE,
8,
EGL14.EGL_BLUE_SIZE,
8,
EGL14.EGL_ALPHA_SIZE,
8,
EGL14.EGL_RENDERABLE_TYPE,
EGL14.EGL_OPENGL_ES2_BIT,
EGL14.EGL_SURFACE_TYPE,
EGL14.EGL_WINDOW_BIT,
EGL14.EGL_NONE,
)
val configs = arrayOfNulls<EGLConfig>(1)
val numConfigs = IntArray(1)
check(EGL14.eglChooseConfig(display, configAttribs, 0, configs, 0, 1, numConfigs, 0)) {
"eglChooseConfig failed"
}
check(numConfigs[0] > 0) { "eglChooseConfig returned no matching configs" }
val config = requireNotNull(configs[0]) { "eglChooseConfig returned null config" }
val contextAttribs = intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE)
context = EGL14.eglCreateContext(display, config, EGL14.EGL_NO_CONTEXT, contextAttribs, 0)
check(context != EGL14.EGL_NO_CONTEXT) { "eglCreateContext failed" }
val surfaceAttribs = intArrayOf(EGL14.EGL_NONE)
eglSurface = EGL14.eglCreateWindowSurface(display, config, surface, surfaceAttribs, 0)
check(eglSurface != EGL14.EGL_NO_SURFACE) { "eglCreateWindowSurface failed" }
}
fun makeCurrent() {
check(EGL14.eglMakeCurrent(display, eglSurface, eglSurface, context)) { "eglMakeCurrent failed" }
}
fun setPresentationTime(nsecs: Long) {
EGLExt.eglPresentationTimeANDROID(display, eglSurface, nsecs)
}
fun swapBuffers() {
EGL14.eglSwapBuffers(display, eglSurface)
}
fun release() {
EGL14.eglMakeCurrent(display, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT)
EGL14.eglDestroySurface(display, eglSurface)
EGL14.eglDestroyContext(display, context)
EGL14.eglTerminate(display)
}
}
// endregion
// region GL helpers
private fun createGlProgram(): Int {
val vs = compileShader(GLES20.GL_VERTEX_SHADER, VERTEX_SHADER)
val fs = compileShader(GLES20.GL_FRAGMENT_SHADER, FRAGMENT_SHADER)
val program = GLES20.glCreateProgram()
check(program != 0) { "glCreateProgram failed" }
GLES20.glAttachShader(program, vs)
GLES20.glAttachShader(program, fs)
GLES20.glLinkProgram(program)
val linkStatus = IntArray(1)
GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0)
if (linkStatus[0] != GLES20.GL_TRUE) {
val info = GLES20.glGetProgramInfoLog(program)
GLES20.glDeleteProgram(program)
throw RuntimeException("glLinkProgram failed: $info")
}
GLES20.glDeleteShader(vs)
GLES20.glDeleteShader(fs)
return program
}
private fun compileShader(
type: Int,
source: String,
): Int {
val shader = GLES20.glCreateShader(type)
check(shader != 0) { "glCreateShader failed for type $type" }
GLES20.glShaderSource(shader, source)
GLES20.glCompileShader(shader)
val compileStatus = IntArray(1)
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compileStatus, 0)
if (compileStatus[0] != GLES20.GL_TRUE) {
val info = GLES20.glGetShaderInfoLog(shader)
GLES20.glDeleteShader(shader)
throw RuntimeException("glCompileShader failed for type $type: $info")
}
return shader
}
private fun createGlTexture(): Int {
val texIds = IntArray(1)
GLES20.glGenTextures(1, texIds, 0)
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texIds[0])
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
return texIds[0]
}
private fun createVertexBuffer(): FloatBuffer =
ByteBuffer
.allocateDirect(QUAD_COORDS.size * 4)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.apply {
put(QUAD_COORDS)
position(0)
}
private fun drawBitmapFrame(
bitmap: Bitmap,
textureId: Int,
program: Int,
vertexBuffer: FloatBuffer,
width: Int,
height: Int,
) {
GLES20.glViewport(0, 0, width, height)
GLES20.glClearColor(1f, 1f, 1f, 1f)
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
GLES20.glUseProgram(program)
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId)
GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0)
val posHandle = GLES20.glGetAttribLocation(program, "aPosition")
val texHandle = GLES20.glGetAttribLocation(program, "aTexCoord")
GLES20.glUniform1i(GLES20.glGetUniformLocation(program, "uTexture"), 0)
// stride = 4 floats per vertex (2 pos + 2 tex) * 4 bytes
val stride = 4 * 4
vertexBuffer.position(0)
GLES20.glEnableVertexAttribArray(posHandle)
GLES20.glVertexAttribPointer(posHandle, 2, GLES20.GL_FLOAT, false, stride, vertexBuffer)
vertexBuffer.position(2)
GLES20.glEnableVertexAttribArray(texHandle)
GLES20.glVertexAttribPointer(texHandle, 2, GLES20.GL_FLOAT, false, stride, vertexBuffer)
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
GLES20.glDisableVertexAttribArray(posHandle)
GLES20.glDisableVertexAttribArray(texHandle)
}
// endregion
// region GIF parsing
/**
* Parses per-frame delays from the GIF binary by reading Graphic Control Extension blocks.
* Returns a list of delays in milliseconds, one per frame.
*
* GIF delay values are in centiseconds (1/100s). Per browser convention,
* delays of 0 or 1 centisecond are treated as 100ms (10fps).
*/
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun parseGifFrameDelays(bytes: ByteArray): List<Int> {
if (bytes.size < 13) return emptyList()
val delays = mutableListOf<Int>()
var pos = 13
// Skip Global Color Table if present
val packed = bytes[10].toInt() and 0xFF
if (packed and 0x80 != 0) {
pos += 3 * (1 shl ((packed and 0x07) + 1))
}
while (pos < bytes.size) {
when (bytes[pos].toInt() and 0xFF) {
0x21 -> {
pos++
if (pos >= bytes.size) break
val label = bytes[pos].toInt() and 0xFF
pos++
if (label == 0xF9 && pos + 5 <= bytes.size) {
val blockSize = bytes[pos].toInt() and 0xFF
if (blockSize == 4) {
val delayLow = bytes[pos + 2].toInt() and 0xFF
val delayHigh = bytes[pos + 3].toInt() and 0xFF
val delayCentiseconds = delayLow or (delayHigh shl 8)
val delayMs =
if (delayCentiseconds <= 1) DEFAULT_FRAME_DELAY_MS else delayCentiseconds * 10
delays.add(delayMs)
}
pos = skipSubBlocks(bytes, pos)
} else {
pos = skipSubBlocks(bytes, pos)
}
}
0x2C -> {
// Image Descriptor: 1 (separator, current pos) + 8 (L/T/W/H) + 1 (packed) = 10 bytes
if (pos + 10 > bytes.size) break
val imgPacked = bytes[pos + 9].toInt() and 0xFF
pos += 10
if (imgPacked and 0x80 != 0) {
pos += 3 * (1 shl ((imgPacked and 0x07) + 1))
if (pos >= bytes.size) break
}
pos++
if (pos >= bytes.size) break
pos = skipSubBlocks(bytes, pos)
}
0x3B -> {
break
}
else -> {
pos++
}
}
}
return delays
}
private fun skipSubBlocks(
bytes: ByteArray,
startPos: Int,
): Int {
var pos = startPos
while (pos < bytes.size) {
val blockSize = bytes[pos].toInt() and 0xFF
pos++
if (blockSize == 0) break
pos = minOf(pos + blockSize, bytes.size)
}
return pos
}
// endregion
// region Encoder helpers
private fun drainEncoder(
codec: MediaCodec,
muxer: MediaMuxer,
bufferInfo: MediaCodec.BufferInfo,
trackIndex: Int,
muxerStarted: Boolean,
endOfStream: Boolean,
): Pair<Int, Boolean> {
var currentTrackIndex = trackIndex
var currentMuxerStarted = muxerStarted
var eosDrainIterations = 0
while (true) {
val outputIndex = codec.dequeueOutputBuffer(bufferInfo, TIMEOUT_US)
when {
outputIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> {
if (!endOfStream) return Pair(currentTrackIndex, currentMuxerStarted)
if (++eosDrainIterations >= DRAIN_EOS_MAX_ITERATIONS) {
throw RuntimeException("Encoder failed to drain after EOS within ${DRAIN_EOS_MAX_ITERATIONS} iterations")
}
}
outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
currentTrackIndex = muxer.addTrack(codec.outputFormat)
muxer.start()
currentMuxerStarted = true
}
outputIndex >= 0 -> {
val outputBuffer =
codec.getOutputBuffer(outputIndex)
?: throw RuntimeException("Encoder output buffer $outputIndex was null")
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
bufferInfo.size = 0
}
if (bufferInfo.size > 0 && currentMuxerStarted) {
outputBuffer.position(bufferInfo.offset)
outputBuffer.limit(bufferInfo.offset + bufferInfo.size)
muxer.writeSampleData(currentTrackIndex, outputBuffer, bufferInfo)
}
codec.releaseOutputBuffer(outputIndex, false)
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
return Pair(currentTrackIndex, currentMuxerStarted)
}
}
}
}
}
private fun calculateBitrate(
width: Int,
height: Int,
): Int {
val pixels = width * height
return when {
pixels >= 1920 * 1080 -> 4_000_000
pixels >= 1280 * 720 -> DEFAULT_BITRATE_BPS
pixels >= 640 * 480 -> 1_000_000
else -> 500_000
}
}
private fun Int.roundToEven(): Int = if (this % 2 != 0) this + 1 else this
// endregion
}
@@ -25,6 +25,7 @@ import android.graphics.Bitmap
import android.net.Uri
import androidx.core.net.toUri
import androidx.media3.common.MimeTypes
import com.abedelazizshe.lightcompressorlibrary.video.GifToMp4Converter
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.components.util.MediaCompressorFileUtils
import com.vitorpamplona.quartz.utils.Log
@@ -57,7 +58,7 @@ class MediaCompressor {
Log.d("MediaCompressor") { "Converting GIF to MP4" }
val converted = GifToMp4Converter.convert(uri, applicationContext)
if (converted != null) {
return converted
return MediaCompressorResult(converted.file.toUri(), converted.mimeType, converted.size)
}
Log.w("MediaCompressor") { "GIF to MP4 conversion failed, uploading as original GIF" }
return MediaCompressorResult(uri, contentType, null)
@@ -46,8 +46,9 @@ import com.vitorpamplona.amethyst.ui.StringResSetup
import com.vitorpamplona.amethyst.ui.screen.ManageRelayServices
import com.vitorpamplona.amethyst.ui.screen.ManageWebOkHttp
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class CallActivity : AppCompatActivity() {
@@ -66,17 +67,17 @@ class CallActivity : AppCompatActivity() {
}
private var pendingAcceptIsVideo = false
private var hangupInitiated = false
private val pipActionReceiver =
object : BroadcastReceiver() {
@OptIn(DelicateCoroutinesApi::class)
override fun onReceive(
context: Context,
intent: Intent,
) {
when (intent.action) {
ACTION_PIP_HANGUP -> {
GlobalScope.launch { CallSessionBridge.callManager?.hangup() }
lifecycleScope.launch { CallSessionBridge.callManager?.hangup() }
}
ACTION_PIP_TOGGLE_MUTE -> {
@@ -191,7 +192,6 @@ class CallActivity : AppCompatActivity() {
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun onStop() {
super.onStop()
// Only hang up if the user dismissed PiP (swiped it away).
@@ -200,34 +200,46 @@ class CallActivity : AppCompatActivity() {
// We must NOT hang up when the user simply presses Home from the full-screen
// call UI (that enters PiP via onUserLeaveHint instead).
if (wasInPipMode && !isInPictureInPictureMode) {
val state = CallSessionBridge.callManager?.state?.value
hangupInitiated = true
val manager = CallSessionBridge.callManager
val state = manager?.state?.value
if (state is CallState.Connected || state is CallState.Connecting || state is CallState.Offering) {
GlobalScope.launch { CallSessionBridge.callManager?.hangup() }
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).launch {
manager.hangup()
finishAndRemoveTask()
}
} else {
finishAndRemoveTask()
}
finishAndRemoveTask()
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun onDestroy() {
unregisterPipReceiver()
// Safety net: if the Activity is destroyed while a call is still
// ringing/offering, ensure the call is hung up so audio stops.
val manager = CallSessionBridge.callManager
when (manager?.state?.value) {
is CallState.IncomingCall -> {
GlobalScope.launch { manager.rejectCall() }
}
// Skip if onStop already initiated the hangup to avoid double signaling.
if (!hangupInitiated) {
val manager = CallSessionBridge.callManager
when (manager?.state?.value) {
is CallState.IncomingCall -> {
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).launch {
manager.rejectCall()
}
}
is CallState.Offering,
is CallState.Connecting,
is CallState.Connected,
-> {
GlobalScope.launch { manager.hangup() }
}
is CallState.Offering,
is CallState.Connecting,
is CallState.Connected,
-> {
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).launch {
manager.hangup()
}
}
else -> {}
else -> {}
}
}
super.onDestroy()
@@ -195,11 +195,14 @@ fun CallScreen(
Snackbar(
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
action = {
Text(
stringRes(R.string.call_dismiss),
modifier = Modifier.padding(8.dp),
color = MaterialTheme.colorScheme.inversePrimary,
)
androidx.compose.material3.TextButton(
onClick = { callController?.clearError() },
) {
Text(
stringRes(R.string.call_dismiss),
color = MaterialTheme.colorScheme.inversePrimary,
)
}
},
) {
Text(error)
@@ -36,6 +36,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -63,6 +64,10 @@ fun VideoRenderer(
modifier: Modifier = Modifier,
mirror: Boolean = false,
) {
// Track the current track so the update block can swap sinks when the
// track reference changes (e.g. after renegotiation).
val currentTrack = remember { mutableStateOf(videoTrack) }
AndroidView(
modifier = modifier,
factory = { ctx ->
@@ -73,8 +78,21 @@ fun VideoRenderer(
videoTrack.addSink(this)
}
},
update = { renderer ->
if (currentTrack.value !== videoTrack) {
try {
currentTrack.value.removeSink(renderer)
} catch (_: Exception) {
}
videoTrack.addSink(renderer)
currentTrack.value = videoTrack
}
},
onRelease = { renderer ->
videoTrack.removeSink(renderer)
try {
currentTrack.value.removeSink(renderer)
} catch (_: Exception) {
}
renderer.release()
},
)
@@ -41,6 +41,8 @@ import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.BluetoothAudio
import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material.icons.filled.CameraFront
import androidx.compose.material.icons.filled.CameraRear
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.filled.PersonAdd
@@ -108,6 +110,7 @@ fun ConnectedCallUI(
val defaultTrue = remember { kotlinx.coroutines.flow.MutableStateFlow(true) }
val isAudioMuted by (callController?.isAudioMuted ?: defaultFalse).collectAsState()
val isVideoEnabled by (callController?.isVideoEnabled ?: defaultTrue).collectAsState()
val isFrontCamera by (callController?.isFrontCamera ?: defaultTrue).collectAsState()
val currentAudioRoute by (callController?.audioRoute ?: remember { kotlinx.coroutines.flow.MutableStateFlow(AudioRoute.EARPIECE) }).collectAsState()
val hasActiveVideo =
state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO ||
@@ -160,7 +163,7 @@ fun ConnectedCallUI(
.align(Alignment.TopEnd)
.windowInsetsPadding(WindowInsets.statusBars)
.padding(16.dp),
mirror = true,
mirror = isFrontCamera,
)
}
}
@@ -226,9 +229,11 @@ fun ConnectedCallUI(
CallControls(
isAudioMuted = isAudioMuted,
isVideoEnabled = isVideoEnabled,
isFrontCamera = isFrontCamera,
currentAudioRoute = currentAudioRoute,
onToggleMute = onToggleMute,
onToggleVideo = onToggleVideo,
onSwitchCamera = { callController?.switchCamera() },
onCycleAudioRoute = onCycleAudioRoute,
onAddParticipant = { showAddParticipant = true },
onHangup = onHangup,
@@ -245,9 +250,11 @@ fun ConnectedCallUI(
private fun CallControls(
isAudioMuted: Boolean,
isVideoEnabled: Boolean,
isFrontCamera: Boolean,
currentAudioRoute: AudioRoute,
onToggleMute: () -> Unit,
onToggleVideo: () -> Unit,
onSwitchCamera: () -> Unit,
onCycleAudioRoute: () -> Unit,
onAddParticipant: () -> Unit,
onHangup: () -> Unit,
@@ -277,6 +284,21 @@ private fun CallControls(
modifier = Modifier.size(28.dp),
)
}
if (isVideoEnabled) {
IconButton(onClick = onSwitchCamera, modifier = Modifier.size(56.dp)) {
Icon(
imageVector =
if (isFrontCamera) {
Icons.Default.CameraRear
} else {
Icons.Default.CameraFront
},
contentDescription = stringRes(R.string.call_switch_camera),
tint = Color.White,
modifier = Modifier.size(28.dp),
)
}
}
IconButton(onClick = onCycleAudioRoute, modifier = Modifier.size(56.dp)) {
Icon(
imageVector =
@@ -101,7 +101,6 @@ fun PipConnectedCallUI(
val remoteVideoTracks by (callController?.remoteVideoTracks ?: emptyTracksFlow).collectAsState()
val activePeerVideos by (callController?.activePeerVideos ?: emptySetFlow).collectAsState()
val defaultFalse = remember { kotlinx.coroutines.flow.MutableStateFlow(false) }
val isRemoteVideoActive by (callController?.isRemoteVideoActive ?: defaultFalse).collectAsState()
val isVideoEnabled by (callController?.isVideoEnabled ?: defaultFalse).collectAsState()
val hasActiveVideo =
state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO ||
@@ -142,7 +141,17 @@ fun PipConnectedCallUI(
)
}
}
} else if (!isRemoteVideoActive) {
// Show timer overlay on top of video
Text(
text = formatDuration(elapsed),
color = Color.White.copy(alpha = 0.7f),
fontSize = 10.sp,
modifier =
Modifier
.align(Alignment.TopCenter)
.padding(top = 4.dp),
)
} else {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -161,15 +170,5 @@ fun PipConnectedCallUI(
)
}
}
Text(
text = formatDuration(elapsed),
color = Color.White.copy(alpha = 0.7f),
fontSize = 10.sp,
modifier =
Modifier
.align(Alignment.TopCenter)
.padding(top = 4.dp),
)
}
}
@@ -133,6 +133,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.RequestToVani
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.VanishEventsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.CallSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NamecoinSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.OtsSettingsScreen
@@ -262,6 +263,7 @@ fun BuildNavigation(
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.ReactionsSettings> { ReactionsSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.CallSettings> { CallSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.ImportFollowsSelectUser> { ImportFollowListSelectUserScreen(accountViewModel, nav) }
composableFromEndArgs<Route.ImportFollowsPickFollows> {
ImportFollowListPickFollowsScreen(it.userHex, accountViewModel, nav)
@@ -129,6 +129,8 @@ sealed class Route {
@Serializable object ReactionsSettings : Route()
@Serializable object CallSettings : Route()
@Serializable object Lists : Route()
@Serializable data class MyPeopleListView(
@@ -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
@@ -219,6 +219,7 @@ class AccountViewModel(
publishWrap = { wrap -> account.publishCallSignaling(wrap) },
signerProvider = { account.signer },
localPubKey = account.signer.pubKey,
settingsProvider = { account.settings },
)
// Set callbacks before exposing controller to avoid timing races
@@ -1464,7 +1465,7 @@ class AccountViewModel(
account.publishMarmotKeyPackage()
}
fun hasPublishedKeyPackage(): Boolean = account.hasPublishedKeyPackage()
suspend fun hasPublishedKeyPackage(): Boolean = account.hasPublishedKeyPackage()
suspend fun leaveMarmotGroup(nostrGroupId: String) {
val relays = marmotGroupRelays(nostrGroupId)
@@ -1522,6 +1523,8 @@ class AccountViewModel(
override fun onCleared() {
Log.d("AccountViewModel", "onCleared")
callController?.cleanup()
com.vitorpamplona.amethyst.service.call.CallSessionBridge
.clear()
feedStates.destroy()
super.onCleared()
}
@@ -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)
@@ -36,6 +36,7 @@ import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material.icons.outlined.GroupAdd
import androidx.compose.material.icons.outlined.History
import androidx.compose.material.icons.outlined.Key
import androidx.compose.material.icons.outlined.Phone
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Security
import androidx.compose.material.icons.outlined.Settings
@@ -142,6 +143,13 @@ fun AllSettingsScreen(
onClick = { nav.nav(Route.SecurityFilters) },
)
HorizontalDivider()
SettingsNavigationRow(
title = R.string.call_settings,
icon = Icons.Outlined.Phone,
tint = tint,
onClick = { nav.nav(Route.CallSettings) },
)
HorizontalDivider()
SettingsNavigationRow(
title = R.string.translations,
icon = Icons.Outlined.Translate,
@@ -0,0 +1,380 @@
/*
* 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.screen.loggedIn.settings
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.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.CallTurnServer
import com.vitorpamplona.amethyst.model.CallVideoResolution
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun CallSettingsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
topBar = {
TopBarWithBackButton(stringRes(id = R.string.call_settings), nav::popBack)
},
) { padding ->
Column(
Modifier
.padding(padding)
.verticalScroll(rememberScrollState()),
) {
CallSettingsContent(accountViewModel)
}
}
}
@Composable
private fun CallSettingsContent(accountViewModel: AccountViewModel) {
val settings = accountViewModel.account.settings
SectionHeader(stringRes(R.string.call_settings_video_quality))
VideoResolutionSection(
currentResolution = settings.callVideoResolution,
onResolutionChanged = { settings.changeCallVideoResolution(it) },
)
Spacer(modifier = Modifier.height(8.dp))
BitrateSection(
currentBitrate = settings.callMaxBitrateBps,
onBitrateChanged = { settings.changeCallMaxBitrateBps(it) },
)
HorizontalDivider(thickness = 4.dp, modifier = Modifier.padding(vertical = 8.dp))
SectionHeader(stringRes(R.string.call_settings_turn_servers))
Text(
text = stringRes(R.string.call_settings_turn_description),
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp),
)
DefaultTurnServersSection()
Spacer(modifier = Modifier.height(8.dp))
CustomTurnServersSection(
servers = settings.callTurnServers,
onServersChanged = { settings.changeCallTurnServers(it) },
)
Spacer(modifier = Modifier.height(16.dp))
}
@Composable
private fun SectionHeader(title: String) {
Text(
text = title,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
)
}
@Composable
private fun VideoResolutionSection(
currentResolution: CallVideoResolution,
onResolutionChanged: (CallVideoResolution) -> Unit,
) {
Column(modifier = Modifier.padding(horizontal = 24.dp)) {
CallVideoResolution.entries.forEach { resolution ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
RadioButton(
selected = currentResolution == resolution,
onClick = { onResolutionChanged(resolution) },
)
Text(
text = resolution.label,
fontSize = 16.sp,
modifier = Modifier.padding(start = 8.dp),
)
Text(
text = "${resolution.width}x${resolution.height} @ ${resolution.fps}fps",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp),
)
}
}
}
}
@Composable
private fun BitrateSection(
currentBitrate: Int,
onBitrateChanged: (Int) -> Unit,
) {
val bitrateOptions =
listOf(
750_000 to "750 kbps",
1_500_000 to "1.5 Mbps (default)",
3_000_000 to "3 Mbps",
)
Column(modifier = Modifier.padding(horizontal = 24.dp)) {
Text(
text = stringRes(R.string.call_settings_max_bitrate),
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(bottom = 4.dp),
)
bitrateOptions.forEach { (bitrate, label) ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
RadioButton(
selected = currentBitrate == bitrate,
onClick = { onBitrateChanged(bitrate) },
)
Text(
text = label,
fontSize = 16.sp,
modifier = Modifier.padding(start = 8.dp),
)
}
}
}
}
@Composable
private fun DefaultTurnServersSection() {
val defaults =
listOf(
"stun:stun.l.google.com:19302",
"stun:stun1.l.google.com:19302",
"stun:stun.cloudflare.com:3478",
"turn:openrelay.metered.ca:80",
"turn:openrelay.metered.ca:443",
"turn:openrelay.metered.ca:443?transport=tcp",
)
Column(modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp)) {
Text(
text = stringRes(R.string.call_settings_default_servers),
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(bottom = 4.dp),
)
defaults.forEach { server ->
Text(
text = server,
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 2.dp),
)
}
}
}
@Composable
private fun CustomTurnServersSection(
servers: List<CallTurnServer>,
onServersChanged: (List<CallTurnServer>) -> Unit,
) {
val editableServers = remember(servers) { mutableStateListOf(*servers.toTypedArray()) }
var showAddForm by remember { mutableStateOf(false) }
Column(modifier = Modifier.padding(horizontal = 24.dp)) {
Text(
text = stringRes(R.string.call_settings_custom_turn),
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(bottom = 8.dp),
)
editableServers.forEachIndexed { index, server ->
TurnServerRow(
server = server,
onDelete = {
editableServers.removeAt(index)
onServersChanged(editableServers.toList())
},
)
if (index < editableServers.lastIndex) {
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
}
}
if (editableServers.isEmpty() && !showAddForm) {
Text(
text = stringRes(R.string.call_settings_no_custom_turn),
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp),
)
}
Spacer(modifier = Modifier.height(8.dp))
if (showAddForm) {
AddTurnServerForm(
onAdd = { server ->
editableServers.add(server)
onServersChanged(editableServers.toList())
showAddForm = false
},
onCancel = { showAddForm = false },
)
} else {
Button(
onClick = { showAddForm = true },
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Outlined.Add, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(stringRes(R.string.call_settings_add_turn))
}
}
}
}
@Composable
private fun TurnServerRow(
server: CallTurnServer,
onDelete: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = server.url, fontSize = 14.sp)
Text(
text = "${server.username} / ****",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = onDelete) {
Icon(
Icons.Outlined.Delete,
contentDescription = stringRes(R.string.call_settings_remove_turn),
tint = MaterialTheme.colorScheme.error,
)
}
}
}
@Composable
private fun AddTurnServerForm(
onAdd: (CallTurnServer) -> Unit,
onCancel: () -> Unit,
) {
var url by remember { mutableStateOf("") }
var username by remember { mutableStateOf("") }
var credential by remember { mutableStateOf("") }
Column {
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text(stringRes(R.string.call_settings_turn_url)) },
placeholder = { Text("turn:your-server.com:443") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(4.dp))
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text(stringRes(R.string.call_settings_turn_username)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(4.dp))
OutlinedTextField(
value = credential,
onValueChange = { credential = it },
label = { Text(stringRes(R.string.call_settings_turn_credential)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
Button(
onClick = onCancel,
colors = ButtonDefaults.textButtonColors(),
) {
Text(stringRes(R.string.cancel))
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
if (url.isNotBlank()) {
onAdd(CallTurnServer(url.trim(), username.trim(), credential.trim()))
}
},
enabled = url.isNotBlank(),
) {
Text(stringRes(R.string.call_settings_add_turn))
}
}
}
}
@@ -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
@@ -54,6 +54,34 @@ class TorService(
private val _status = MutableStateFlow<TorServiceStatus>(TorServiceStatus.Off)
val status: StateFlow<TorServiceStatus> = _status.asStateFlow()
private fun artiDataDir() = File(context.filesDir, "arti")
/**
* Clears the Arti cache directory (consensus, relay descriptors) while
* preserving the state directory (guard selection). This forces a fresh
* consensus download on the next bootstrap, preventing stale cached data
* from causing circuit failures.
*/
private fun clearArtiCache() {
val cacheDir = File(artiDataDir(), "cache")
if (cacheDir.exists()) {
cacheDir.deleteRecursively()
Log.d("TorService") { "Cleared Arti cache directory" }
}
}
/**
* Clears all Arti persistent data (state + cache). Used as a last resort
* when initialization fails, to recover from corrupted state.
*/
private fun clearAllArtiData() {
val dataDir = artiDataDir()
if (dataDir.exists()) {
dataDir.deleteRecursively()
Log.d("TorService") { "Cleared all Arti data" }
}
}
/**
* Initialize the TorClient (once) and start the SOCKS proxy.
* Must be called from a coroutine on [Dispatchers.IO].
@@ -90,12 +118,21 @@ class TorService(
}
}
val dataDir = File(context.filesDir, "arti").absolutePath
// Clear cached consensus/descriptors so Arti bootstraps with
// fresh network data, preventing stale guards/circuits.
clearArtiCache()
val dataDir = artiDataDir().absolutePath
Log.d("TorService") { "Initializing Arti with data dir: $dataDir" }
val initResult = ArtiNative.initialize(dataDir)
var initResult = ArtiNative.initialize(dataDir)
if (initResult != 0) {
Log.e("TorService") { "Failed to initialize Arti: error $initResult" }
Log.e("TorService") { "Failed to initialize Arti: error $initResult, clearing data and retrying" }
clearAllArtiData()
initResult = ArtiNative.initialize(dataDir)
}
if (initResult != 0) {
Log.e("TorService") { "Failed to initialize Arti on retry: error $initResult" }
initialized.set(false)
_status.value = TorServiceStatus.Off
return@withContext
@@ -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
@@ -699,6 +699,7 @@
<string name="call_unmute">Zrušit ztlumení</string>
<string name="call_camera_on">Kamera zapnuta</string>
<string name="call_camera_off">Kamera vypnuta</string>
<string name="call_switch_camera">Přepnout kameru</string>
<string name="call_speaker">Reproduktor</string>
<string name="call_earpiece">Sluchátko</string>
<string name="call_bluetooth">Bluetooth</string>
@@ -710,6 +711,19 @@
<string name="call_failed_start">Nepodařilo se zahájit hovor</string>
<string name="call_failed_accept">Nepodařilo se přijmout hovor</string>
<string name="call_failed_session">Nepodařilo se vytvořit relaci hovoru</string>
<string name="call_settings">Nastavení hovorů</string>
<string name="call_settings_video_quality">Kvalita videa</string>
<string name="call_settings_max_bitrate">Maximální datový tok videa</string>
<string name="call_settings_turn_servers">TURN / STUN servery</string>
<string name="call_settings_turn_description">Výchozí STUN a TURN servery jsou vždy zahrnuty. Pro restriktivní sítě přidejte vlastní TURN servery.</string>
<string name="call_settings_default_servers">Výchozí servery (vždy aktivní)</string>
<string name="call_settings_custom_turn">Vlastní TURN servery</string>
<string name="call_settings_no_custom_turn">Nejsou nakonfigurovány žádné vlastní TURN servery.</string>
<string name="call_settings_add_turn">Přidat TURN server</string>
<string name="call_settings_remove_turn">Odebrat</string>
<string name="call_settings_turn_url">URL serveru</string>
<string name="call_settings_turn_username">Uživatelské jméno</string>
<string name="call_settings_turn_credential">Pověření</string>
<string name="reply_notify">Upozornit: </string>
<string name="channel_list_join_conversation">Připojit se ke konverzaci</string>
<string name="channel_list_user_or_group_id">ID uživatele nebo skupiny</string>
@@ -1138,6 +1152,8 @@
<string name="media_compression_quality_uncompressed">Bez komprese</string>
<string name="video_codec_h265_label">Použít kodek H.265/HEVC</string>
<string name="video_codec_h265_description">Lepší kvalita při menší velikosti souboru, ale ne všechna zařízení podporují přehrávání H.265.</string>
<string name="convert_gif_to_mp4_label">Převést GIF na MP4</string>
<string name="convert_gif_to_mp4_description">Převede animovaný GIF na video MP4 pro menší velikost souboru a lepší kompatibilitu přehrávání.</string>
<string name="strip_metadata_label">Odstranit soukromá metadata</string>
<string name="strip_metadata_description">Pokusí se odstranit soukromá metadata z podporovaných mediálních souborů před nahráním</string>
<string name="metadata_strip_failed_title">Metadata nelze odstranit</string>
@@ -1183,6 +1199,27 @@
<string name="wallet_filter_all">Vše</string>
<string name="wallet_filter_zaps">Zapy</string>
<string name="wallet_filter_non_zaps">Ostatní</string>
<string name="wallet_add">Přidat peněženku</string>
<string name="wallet_default">Výchozí</string>
<string name="wallet_set_default">Nastavit jako výchozí</string>
<string name="wallet_remove">Odebrat</string>
<string name="wallet_remove_confirm">Odebrat tuto peněženku?</string>
<string name="wallet_remove_confirm_description">Tímto bude připojení peněženky odebráno. Později ji můžete vždy přidat zpět.</string>
<string name="wallet_name">Název peněženky</string>
<string name="wallet_name_hint">Moje peněženka</string>
<string name="wallet_your_wallets">Vaše peněženky</string>
<string name="wallet_manage">Spravovat peněženky</string>
<string name="wallet_no_wallets">Žádné peněženky nejsou připojeny</string>
<string name="wallet_no_wallets_description">Připojte jednu nebo více NWC peněženek pro odesílání a přijímání plateb.</string>
<string name="wallet_add_connection">Přidat NWC připojení</string>
<string name="wallet_paste_uri">Vložit URI nostr+walletconnect://</string>
<string name="wallet_save">Uložit</string>
<string name="wallet_rename">Přejmenovat</string>
<string name="wallet_rename_title">Přejmenovat peněženku</string>
<string name="wallet_edit">Upravit</string>
<string name="wallet_invalid_uri">Neplatné URI NWC připojení</string>
<string name="wallet_move_up">Posunout nahoru</string>
<string name="wallet_move_down">Posunout dolů</string>
<string name="route_security_filters">Bezpečnostní filtry</string>
<string name="route_import_follows">Importovat sledované</string>
<string name="new_post">Nový příspěvek</string>
@@ -1938,4 +1975,18 @@
<string name="relay_members_removed">%1$d členů odebráno z relé</string>
<string name="relay_join_request">Žádost o připojení k relé</string>
<string name="relay_leave_request">Žádost o opuštění relé</string>
<string name="ai_writing_help">Asistent psaní s AI</string>
<string name="ai_writing_setting_title">Navrhovat vylepšení textu</string>
<string name="ai_writing_setting_description">Používá AI model běžící v zařízení k navrhování oprav textu a změn tónu.</string>
<string name="ai_writing_use_this">Použít toto</string>
<string name="ai_writing_dismiss">Zavřít</string>
<string name="ai_tone_correct">Opravit</string>
<string name="ai_tone_rephrase">Přeformulovat</string>
<string name="ai_tone_shorter">Kratší</string>
<string name="ai_tone_elaborate">Rozvést</string>
<string name="ai_tone_friendly">Přátelský</string>
<string name="ai_tone_professional">Profesionální</string>
<string name="ai_tone_more_direct">Přímější</string>
<string name="ai_tone_punchy">Úderný</string>
<string name="ai_tone_emojify">+ Emoji</string>
</resources>
@@ -704,6 +704,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="call_unmute">Stummschaltung aufheben</string>
<string name="call_camera_on">Kamera an</string>
<string name="call_camera_off">Kamera aus</string>
<string name="call_switch_camera">Kamera wechseln</string>
<string name="call_speaker">Lautsprecher</string>
<string name="call_earpiece">Ohrhörer</string>
<string name="call_bluetooth">Bluetooth</string>
@@ -715,6 +716,19 @@ anz der Bedingungen ist erforderlich</string>
<string name="call_failed_start">Anruf konnte nicht gestartet werden</string>
<string name="call_failed_accept">Anruf konnte nicht angenommen werden</string>
<string name="call_failed_session">Anrufsitzung konnte nicht erstellt werden</string>
<string name="call_settings">Anrufeinstellungen</string>
<string name="call_settings_video_quality">Videoqualität</string>
<string name="call_settings_max_bitrate">Maximale Video-Bitrate</string>
<string name="call_settings_turn_servers">TURN- / STUN-Server</string>
<string name="call_settings_turn_description">Standard-STUN- und TURN-Server werden immer eingebunden. Fügen Sie für restriktive Netzwerke benutzerdefinierte TURN-Server hinzu.</string>
<string name="call_settings_default_servers">Standard-Server (immer aktiv)</string>
<string name="call_settings_custom_turn">Benutzerdefinierte TURN-Server</string>
<string name="call_settings_no_custom_turn">Keine benutzerdefinierten TURN-Server konfiguriert.</string>
<string name="call_settings_add_turn">TURN-Server hinzufügen</string>
<string name="call_settings_remove_turn">Entfernen</string>
<string name="call_settings_turn_url">Server-URL</string>
<string name="call_settings_turn_username">Benutzername</string>
<string name="call_settings_turn_credential">Anmeldeinformationen</string>
<string name="reply_notify">Benachrichtigen: </string>
<string name="channel_list_join_conversation">Unterhaltung beitreten</string>
<string name="channel_list_user_or_group_id">Benutzer- oder Gruppen-ID</string>
@@ -1143,6 +1157,8 @@ anz der Bedingungen ist erforderlich</string>
<string name="media_compression_quality_uncompressed">Ohne Kompression</string>
<string name="video_codec_h265_label">H.265/HEVC-Codec verwenden</string>
<string name="video_codec_h265_description">Bessere Qualität bei kleinerer Dateigröße, aber nicht alle Geräte unterstützen die H.265-Wiedergabe.</string>
<string name="convert_gif_to_mp4_label">GIF in MP4 umwandeln</string>
<string name="convert_gif_to_mp4_description">Konvertiert animierte GIFs in MP4-Videos für kleinere Dateigrößen und bessere Wiedergabekompatibilität.</string>
<string name="strip_metadata_label">Private Metadaten entfernen</string>
<string name="strip_metadata_description">Versucht, private Metadaten aus unterstützten Mediendateien vor dem Hochladen zu entfernen</string>
<string name="metadata_strip_failed_title">Metadaten konnten nicht entfernt werden</string>
@@ -1188,6 +1204,27 @@ anz der Bedingungen ist erforderlich</string>
<string name="wallet_filter_all">Alle</string>
<string name="wallet_filter_zaps">Zaps</string>
<string name="wallet_filter_non_zaps">Keine Zaps</string>
<string name="wallet_add">Wallet hinzufügen</string>
<string name="wallet_default">Standard</string>
<string name="wallet_set_default">Als Standard festlegen</string>
<string name="wallet_remove">Entfernen</string>
<string name="wallet_remove_confirm">Diese Wallet entfernen?</string>
<string name="wallet_remove_confirm_description">Dies entfernt die Wallet-Verbindung. Du kannst sie später jederzeit wieder hinzufügen.</string>
<string name="wallet_name">Wallet-Name</string>
<string name="wallet_name_hint">Meine Wallet</string>
<string name="wallet_your_wallets">Deine Wallets</string>
<string name="wallet_manage">Wallets verwalten</string>
<string name="wallet_no_wallets">Keine Wallets verbunden</string>
<string name="wallet_no_wallets_description">Verbinde eine oder mehrere NWC-Wallets, um Zahlungen zu senden und zu empfangen.</string>
<string name="wallet_add_connection">NWC-Verbindung hinzufügen</string>
<string name="wallet_paste_uri">nostr+walletconnect://-URI einfügen</string>
<string name="wallet_save">Speichern</string>
<string name="wallet_rename">Umbenennen</string>
<string name="wallet_rename_title">Wallet umbenennen</string>
<string name="wallet_edit">Bearbeiten</string>
<string name="wallet_invalid_uri">Ungültige NWC-Verbindungs-URI</string>
<string name="wallet_move_up">Nach oben</string>
<string name="wallet_move_down">Nach unten</string>
<string name="route_security_filters">Sicherheitsfilter</string>
<string name="route_import_follows">Folgeliste importieren</string>
<string name="new_post">Neuer Beitrag</string>
@@ -1943,4 +1980,18 @@ anz der Bedingungen ist erforderlich</string>
<string name="relay_members_removed">%1$d Mitglieder aus Relay entfernt</string>
<string name="relay_join_request">Relay-Beitrittsanfrage</string>
<string name="relay_leave_request">Relay-Austrittsanfrage</string>
<string name="ai_writing_help">KI-Schreibhilfe</string>
<string name="ai_writing_setting_title">Textverbesserungen vorschlagen</string>
<string name="ai_writing_setting_description">Verwendet ein KI-Modell auf dem Gerät, um Textkorrekturen und Tonänderungen vorzuschlagen.</string>
<string name="ai_writing_use_this">Verwenden</string>
<string name="ai_writing_dismiss">Schließen</string>
<string name="ai_tone_correct">Korrigieren</string>
<string name="ai_tone_rephrase">Umformulieren</string>
<string name="ai_tone_shorter">Kürzer</string>
<string name="ai_tone_elaborate">Ausführen</string>
<string name="ai_tone_friendly">Freundlich</string>
<string name="ai_tone_professional">Professionell</string>
<string name="ai_tone_more_direct">Direkter</string>
<string name="ai_tone_punchy">Prägnant</string>
<string name="ai_tone_emojify">+ Emoji</string>
</resources>
@@ -703,6 +703,7 @@
<string name="call_unmute">मौन हटाएँ</string>
<string name="call_camera_on">चित्रग्राहक चालू</string>
<string name="call_camera_off">चित्रग्राहक निष्क्रिय</string>
<string name="call_switch_camera">अन्य चित्रग्राहक</string>
<string name="call_speaker">वक्ता</string>
<string name="call_earpiece">कर्णयन्त्र</string>
<string name="call_bluetooth">ब्लूटूथ</string>
@@ -714,6 +715,19 @@
<string name="call_failed_start">आह्वान आरम्भ करने में असफल</string>
<string name="call_failed_accept">आह्वान स्वीकार करने में असफल</string>
<string name="call_failed_session">आह्वान सत्र बनानें में असफल</string>
<string name="call_settings">आह्वान स्थापना विकल्प</string>
<string name="call_settings_video_quality">दृश्य गुणवत्ता</string>
<string name="call_settings_max_bitrate">अधिकतम दृश्य द्व्यंकगति</string>
<string name="call_settings_turn_servers">टर्ण॰ / स्टन॰ सेवासंगणक</string>
<string name="call_settings_turn_description">मूलविकल्प स्टन॰ तथा टर्ण॰ सेवासंगणक सर्वदा समावेशित। विशिष्ट टर्ण॰ सेवासंगणक जोडें प्रतिबन्धनकारी संचारजालों के लिए।</string>
<string name="call_settings_default_servers">मूलविकल्प सेवासंगणक (सर्वदा सक्रिय)</string>
<string name="call_settings_custom_turn">विशिष्ट टर्ण॰ सेवासंगणक</string>
<string name="call_settings_no_custom_turn">कोई विशिष्ट टर्ण॰ सेवासंगणक समाकृत नहीं।</string>
<string name="call_settings_add_turn">टर्ण॰ सेवासंगणक जोडें</string>
<string name="call_settings_remove_turn">हटाएँ</string>
<string name="call_settings_turn_url">सेवासंगणक जालपता</string>
<string name="call_settings_turn_username">उपयोगकर्ता नाम</string>
<string name="call_settings_turn_credential">योग्यता प्रमाण</string>
<string name="reply_notify">सूचित करें : </string>
<string name="channel_list_join_conversation">संवाद में जुड जाएँ</string>
<string name="channel_list_user_or_group_id">उपयोगकर्ता अथवा झुण्ड विभेदक</string>
@@ -878,6 +892,7 @@
<string name="settings">स्थापना विकल्प</string>
<string name="account_settings">लेखा स्थापना विकल्प</string>
<string name="app_settings">क्रमक स्थापना विकल्प</string>
<string name="danger_zone">संकट क्षेत्र</string>
<string name="connectivity_type_always">सर्वदा</string>
<string name="connectivity_type_wifi_only">केवल वै॰फै॰</string>
<string name="connectivity_type_unmetered_wifi_only">मात्रांकन रहित वैफै॰</string>
@@ -1142,6 +1157,8 @@
<string name="media_compression_quality_uncompressed">असंकुचित</string>
<string name="video_codec_h265_label">उच्च कार्यक्षमता चलचित्र संकुचनविस्तारण अथवा एच॰२६५ क्रमलेख का प्रयोग करें</string>
<string name="video_codec_h265_description">अधिक गुणवत्ता के साथ सूक्ष्मतर अभिलेख आकार परन्तु एच॰२६५ चालन सभी यन्त्रों द्वारा अवलम्बित नहीं।</string>
<string name="convert_gif_to_mp4_label">जिफ॰ से एमपी४॰ में परिवर्तित करें</string>
<string name="convert_gif_to_mp4_description">चलन्त जिफ॰ से एमपी४॰ में परिवर्तित करता है सूक्ष्मतर अभिलेख आकार तथा अधिक चालन अनुकूलता के लिए।</string>
<string name="strip_metadata_label">निजी परितथ्य हटाएँ</string>
<string name="strip_metadata_description">निजी परितथ्य हटाने का प्रयास करता है आलम्बित चित्रध्वनिदृश्य अभिलेखों से आरोहण पूर्व</string>
<string name="metadata_strip_failed_title">परितथ्य हटाने में असफल</string>
@@ -1187,6 +1204,27 @@
<string name="wallet_filter_all">सभी</string>
<string name="wallet_filter_zaps">ज्साप</string>
<string name="wallet_filter_non_zaps">ज्साप अतिरिक्त</string>
<string name="wallet_add">धनकोष जोडें</string>
<string name="wallet_default">मूलविकल्प</string>
<string name="wallet_set_default">मूलविकल्पतः स्थापित करें</string>
<string name="wallet_remove">हटाएँ</string>
<string name="wallet_remove_confirm">क्या इस धनकोष को हटाएँ।</string>
<string name="wallet_remove_confirm_description">यह धनकोष संयोजन को हटाएगा। आप इसे कभी भी पुनः जोड सकते हैं।</string>
<string name="wallet_name">धनकोष नाम</string>
<string name="wallet_name_hint">मेरा धनकोष</string>
<string name="wallet_your_wallets">आपके धनकोष</string>
<string name="wallet_manage">धनकोष प्रबन्धन</string>
<string name="wallet_no_wallets">कोई धनकोष संयोजित नहीं</string>
<string name="wallet_no_wallets_description">एक अथवा अधिक नोस्टर धनकोष संयोजन जोडें भुगतान भेजने तथा प्राप्त करने के लिए।</string>
<string name="wallet_add_connection">नोस्टर धनकोष संयोजन जोडें</string>
<string name="wallet_paste_uri">nostr+walletconnect:// पता चिपकाएँ</string>
<string name="wallet_save">अभिलेखन करें</string>
<string name="wallet_rename">पुनःनामकरण</string>
<string name="wallet_rename_title">धनकोष पुनःनामकरण</string>
<string name="wallet_edit">सम्पादन</string>
<string name="wallet_invalid_uri">अमान्य नोस्टर धनकोष संयोजन पता</string>
<string name="wallet_move_up">ऊर्ध्वगमन</string>
<string name="wallet_move_down">अधोगमन</string>
<string name="route_security_filters">सुरक्षार्थ छलनियाँ</string>
<string name="route_import_follows">अनुचरित आयात करें</string>
<string name="new_post">नया पत्र प्रकाशन</string>
@@ -1295,6 +1333,7 @@
<string name="inbox_relays_not_found_examples">ये अच्छे विकल्प हैं :\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="dm_upload">सीधा संदेश आरोहण</string>
<string name="relay_settings">पुनःप्रसारक स्थापना विकल्प</string>
<string name="relay_reorder">पुनःक्रमबद्ध पुनःप्रसारक</string>
<string name="public_home_section">सार्वजनिक मुख्य पुनःप्रसारक</string>
<string name="public_home_section_explainer_profile">प्रयोक्ता अपने विषयवस्तुओं को इन पुनःप्रसारकों को भेज रहा है</string>
<string name="public_home_section_explainer">यह पुनःप्रसारक प्रकार आपके सभी विषयवस्तु रखता है। अमेथिस्ट आपके पत्रों को प्रकाशित करने के लिए यहाँ भेजेगा तथा अन्य लोग आपके विषयवस्तु ढूँढने के लिए इनका प्रयोग करेंगे। १ - ३ पुनःप्रसारकों को जोडें। ये व्यक्तिगत अथवा सशुल्क अथवा सार्वजनिक पुनःप्रसारक हो सकते हैं।</string>
@@ -1681,6 +1720,7 @@
<string name="kind_shorts">छोटे</string>
<string name="kind_voice_msg">ध्वनि सन्देश</string>
<string name="kind_voice_reply">ध्वनि उत्तर</string>
<string name="kind_wake">जगाना</string>
<string name="kind_web_bookmark">जाल स्मर्त्तव्यचिह्न</string>
<string name="kind_wiki">विकि॰</string>
<string name="start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust">एक बढिया सूचनावली के साथ आरम्भ करें उन लोगों का अनुचरण करके जिनको आपके द्वारा विश्वास प्राप्त कोई व्यक्ति करते हैं।</string>
@@ -1940,4 +1980,18 @@
<string name="relay_members_removed">%1$d सदस्य हटाए गए पुनःप्रसारक से</string>
<string name="relay_join_request">पुनःप्रसारक से जुडने का अनुरोध</string>
<string name="relay_leave_request">पुनःप्रसारक से चले जाने का अनुरोध</string>
<string name="ai_writing_help">एआई॰ लेखन सहायता</string>
<string name="ai_writing_setting_title">लेख शोधन प्रस्ताव करें</string>
<string name="ai_writing_setting_description">यन्त्र स्थित एआई॰ प्रतिरूप का प्रयोग करता है लेख सुधार तथा स्वर परिवर्तन प्रस्तावों के लिए।</string>
<string name="ai_writing_use_this">इसका प्रयोग करें</string>
<string name="ai_writing_dismiss">हटाएँ</string>
<string name="ai_tone_correct">सम्यक</string>
<string name="ai_tone_rephrase">वाक्याम्श पुनःसम्पादन</string>
<string name="ai_tone_shorter">सूक्ष्मतर</string>
<string name="ai_tone_elaborate">विस्तार</string>
<string name="ai_tone_friendly">मित्रतापूर्ण</string>
<string name="ai_tone_professional">व्यावसायिक</string>
<string name="ai_tone_more_direct">अधिक सीधा</string>
<string name="ai_tone_punchy">आघाती</string>
<string name="ai_tone_emojify">चित्राक्षर युक्त</string>
</resources>
@@ -703,6 +703,7 @@
<string name="call_unmute">Némítás megszüntetése</string>
<string name="call_camera_on">Kamera bekapcsolása</string>
<string name="call_camera_off">Kamera kikapcsolása</string>
<string name="call_switch_camera">Kameraváltás</string>
<string name="call_speaker">Hangszóró</string>
<string name="call_earpiece">Fülhallgató</string>
<string name="call_bluetooth">Bluetooth</string>
@@ -714,6 +715,19 @@
<string name="call_failed_start">Nem sikerült hívást kezdeményezni</string>
<string name="call_failed_accept">Nem sikerült elfogadni a hívást</string>
<string name="call_failed_session">Nem sikerült létrehozni a hívási munkamenetet</string>
<string name="call_settings">Hívásbeállítások</string>
<string name="call_settings_video_quality">Videóminőség</string>
<string name="call_settings_max_bitrate">Legmagasabb videó-bitsebesség</string>
<string name="call_settings_turn_servers">TURN-/ STUN-kiszolgálók</string>
<string name="call_settings_turn_description">Az alapértelmezett STUN- és TURN-kiszolgálók minden esetben biztosítottak. Korlátozó hálózatok esetén adjon hozzá egyéni TURN kiszolgálókat.</string>
<string name="call_settings_default_servers">Alapértelmezett kiszolgálók (mindíg aktívak)</string>
<string name="call_settings_custom_turn">Egyéni TURN-kiszolgálók</string>
<string name="call_settings_no_custom_turn">Nincsenek egyéni TURN-kiszolgálók beállítva.</string>
<string name="call_settings_add_turn">TURN-kiszolgáló hozzáadása</string>
<string name="call_settings_remove_turn">Eltávolítás</string>
<string name="call_settings_turn_url">Kiszolgáló webcíme</string>
<string name="call_settings_turn_username">Felhasználónév</string>
<string name="call_settings_turn_credential">Hitelesítési adatok</string>
<string name="reply_notify">Értesítés: </string>
<string name="channel_list_join_conversation">Csatlakozás a beszélgetéshez</string>
<string name="channel_list_user_or_group_id">Felhasználó- vagy csoport-azonosító</string>
@@ -1144,6 +1158,7 @@
<string name="video_codec_h265_label">H.265/HEVC-kodek használata</string>
<string name="video_codec_h265_description">Jobb minőség kisebb fájlméret mellett, de nem minden eszköz támogatja a H.265 lejátszást.</string>
<string name="convert_gif_to_mp4_label">GIF átalakítása MP4 formátumra</string>
<string name="convert_gif_to_mp4_description">Animált GIF-fájlokat MP4-videókká alakít át, így kisebb fájlméretet és jobb lejátszási kompatibilitást biztosít.</string>
<string name="strip_metadata_label">Privát metaadatok törlése</string>
<string name="strip_metadata_description">Kísérletek a támogatott médiafájlokból a privát metaadatok eltávolítására a feltöltés előtt</string>
<string name="metadata_strip_failed_title">A metaadatok nem távolíthatók el</string>
@@ -1194,7 +1209,22 @@
<string name="wallet_set_default">Beállítás alapértelmezettként</string>
<string name="wallet_remove">Eltávolítás</string>
<string name="wallet_remove_confirm">Eltávolítja ezt a pénztárcát?</string>
<string name="wallet_remove_confirm_description">Ezzel eltávolítja a pénztárca-kapcsolatot. Később bármikor újra hozzáadhatja.</string>
<string name="wallet_name">Pénztárca neve</string>
<string name="wallet_name_hint">Saját pénztárca</string>
<string name="wallet_your_wallets">Saját pénztárcák</string>
<string name="wallet_manage">Pénztárcák kezelése</string>
<string name="wallet_no_wallets">Nincs pénztárca összekapcsolva</string>
<string name="wallet_no_wallets_description">Kapcsolja össze fiókját egy vagy több NWC-pénztárcával a fizetések küldéséhez és fogadásához.</string>
<string name="wallet_add_connection">NWC-kapcsolat hozzáadása</string>
<string name="wallet_paste_uri">Illessze be a nostr+walletconnect:// címet</string>
<string name="wallet_save">Mentés</string>
<string name="wallet_rename">Átnevezés</string>
<string name="wallet_rename_title">Pénztárca átnevezése</string>
<string name="wallet_edit">Szerkesztés</string>
<string name="wallet_invalid_uri">Érvénytelen NWC-kapcsolati cím</string>
<string name="wallet_move_up">Mozgatás felfelé</string>
<string name="wallet_move_down">Mozgatás lefelé</string>
<string name="route_security_filters">Biztonsági szűrők</string>
<string name="route_import_follows">Követettek importálása</string>
<string name="new_post">Új bejegyzés</string>
@@ -1950,4 +1980,18 @@
<string name="relay_members_removed">%1$d tag el lett távolítva az átjátszóról</string>
<string name="relay_join_request">Átjátszóhoz való csatlakozás kérése</string>
<string name="relay_leave_request">Átjátszó elhagyásának kérése</string>
<string name="ai_writing_help">Súgó az LLM-alapú íráshoz</string>
<string name="ai_writing_setting_title">Javaslatok a szöveg javítására</string>
<string name="ai_writing_setting_description">Az eszközön futó LLM-modell segítségével szövegjavításokkal és hangnemváltoztatásokkal kapcsolatos javaslatokat kaphat.</string>
<string name="ai_writing_use_this">Ennek használata</string>
<string name="ai_writing_dismiss">Elvetés</string>
<string name="ai_tone_correct">Javítás</string>
<string name="ai_tone_rephrase">Újrafogalmazás</string>
<string name="ai_tone_shorter">Rövidebb</string>
<string name="ai_tone_elaborate">Kidolgozott</string>
<string name="ai_tone_friendly">Barátságos</string>
<string name="ai_tone_professional">Szakmai</string>
<string name="ai_tone_more_direct">Közvetlenebb</string>
<string name="ai_tone_punchy">Erőteljes</string>
<string name="ai_tone_emojify">+ Emodzsi</string>
</resources>
@@ -700,6 +700,7 @@
<string name="call_unmute">Włącz dźwięk</string>
<string name="call_camera_on">Kamera włączona</string>
<string name="call_camera_off">Kamera wyłączona</string>
<string name="call_switch_camera">Przełącz kamerę</string>
<string name="call_speaker">Głośnik</string>
<string name="call_earpiece">Słuchawka</string>
<string name="call_bluetooth">Bluetooth</string>
@@ -711,6 +712,19 @@
<string name="call_failed_start">Nie udało się nawiązać połączenia</string>
<string name="call_failed_accept">Nie udało się przyjąć połączenia</string>
<string name="call_failed_session">Nie udało się utworzyć sesji połączenia</string>
<string name="call_settings">Ustawienia połączeń</string>
<string name="call_settings_video_quality">Jakość Wideo</string>
<string name="call_settings_max_bitrate">Maks. przepływność wideo</string>
<string name="call_settings_turn_servers">Serwery TURN / STUN</string>
<string name="call_settings_turn_description">Domyślne serwery STUN i TURN są zawsze uwzględnione. W przypadku sieci o ograniczonym dostępie dodaj własne serwery TURN.</string>
<string name="call_settings_default_servers">Domyślne serwery (zawsze aktywne)</string>
<string name="call_settings_custom_turn">Własne serwery TURN</string>
<string name="call_settings_no_custom_turn">Nie skonfigurowano własnych serwerów TURN.</string>
<string name="call_settings_add_turn">Dodaj serwer TURN</string>
<string name="call_settings_remove_turn">Usuń</string>
<string name="call_settings_turn_url">Adres URL serwera</string>
<string name="call_settings_turn_username">Nazwa użytkownika</string>
<string name="call_settings_turn_credential">Uwierzytelnienie</string>
<string name="reply_notify">Powiadom: </string>
<string name="channel_list_join_conversation">Dołącz do dyskusji</string>
<string name="channel_list_user_or_group_id">ID Użytkownika lub Grupy</string>
@@ -699,6 +699,7 @@
<string name="call_unmute">Ativar som</string>
<string name="call_camera_on">Câmera ligada</string>
<string name="call_camera_off">Câmera desligada</string>
<string name="call_switch_camera">Trocar câmera</string>
<string name="call_speaker">Alto-falante</string>
<string name="call_earpiece">Auricular</string>
<string name="call_bluetooth">Bluetooth</string>
@@ -710,6 +711,19 @@
<string name="call_failed_start">Falha ao iniciar chamada</string>
<string name="call_failed_accept">Falha ao aceitar chamada</string>
<string name="call_failed_session">Falha ao criar sessão de chamada</string>
<string name="call_settings">Configurações de Chamada</string>
<string name="call_settings_video_quality">Qualidade do Vídeo</string>
<string name="call_settings_max_bitrate">Taxa de Bits Máxima de Vídeo</string>
<string name="call_settings_turn_servers">Servidores TURN / STUN</string>
<string name="call_settings_turn_description">Servidores STUN e TURN padrão estão sempre incluídos. Adicione servidores TURN personalizados para redes restritivas.</string>
<string name="call_settings_default_servers">Servidores padrão (sempre ativos)</string>
<string name="call_settings_custom_turn">Servidores TURN Personalizados</string>
<string name="call_settings_no_custom_turn">Nenhum servidor TURN personalizado configurado.</string>
<string name="call_settings_add_turn">Adicionar servidor TURN</string>
<string name="call_settings_remove_turn">Remover</string>
<string name="call_settings_turn_url">URL do Servidor</string>
<string name="call_settings_turn_username">Nome de Usuário</string>
<string name="call_settings_turn_credential">Credencial</string>
<string name="reply_notify">Notificar: </string>
<string name="channel_list_join_conversation">Entrar na conversa</string>
<string name="channel_list_user_or_group_id">ID do usuário ou grupo</string>
@@ -1138,6 +1152,8 @@
<string name="media_compression_quality_uncompressed">Sem compressão</string>
<string name="video_codec_h265_label">Usar codec H.265/HEVC</string>
<string name="video_codec_h265_description">Melhor qualidade em arquivos menores, mas nem todos os dispositivos suportam reprodução em H.265.</string>
<string name="convert_gif_to_mp4_label">Converter GIF em MP4</string>
<string name="convert_gif_to_mp4_description">Converte GIF animado em vídeo MP4 para menor tamanho de arquivo e melhor compatibilidade de reprodução.</string>
<string name="strip_metadata_label">Remover metadados privados</string>
<string name="strip_metadata_description">Tenta remover metadados privados de arquivos de mídia compatíveis antes do envio</string>
<string name="metadata_strip_failed_title">Não foi possível remover os metadados</string>
@@ -1183,6 +1199,27 @@
<string name="wallet_filter_all">Todos</string>
<string name="wallet_filter_zaps">Zaps</string>
<string name="wallet_filter_non_zaps">Outros</string>
<string name="wallet_add">Adicionar carteira</string>
<string name="wallet_default">Padrão</string>
<string name="wallet_set_default">Definir como padrão</string>
<string name="wallet_remove">Remover</string>
<string name="wallet_remove_confirm">Remover esta carteira?</string>
<string name="wallet_remove_confirm_description">Isto removerá a conexão da carteira. Você sempre pode adicioná-la novamente mais tarde.</string>
<string name="wallet_name">Nome da carteira</string>
<string name="wallet_name_hint">Minha carteira</string>
<string name="wallet_your_wallets">Suas carteiras</string>
<string name="wallet_manage">Gerenciar carteiras</string>
<string name="wallet_no_wallets">Nenhuma carteira conectada</string>
<string name="wallet_no_wallets_description">Conecte uma ou mais carteiras NWC para enviar e receber pagamentos.</string>
<string name="wallet_add_connection">Adicionar conexão NWC</string>
<string name="wallet_paste_uri">Colar URI nostr+walletconnect://</string>
<string name="wallet_save">Salvar</string>
<string name="wallet_rename">Renomear</string>
<string name="wallet_rename_title">Renomear carteira</string>
<string name="wallet_edit">Editar</string>
<string name="wallet_invalid_uri">URI de conexão NWC inválida</string>
<string name="wallet_move_up">Mover para cima</string>
<string name="wallet_move_down">Mover para baixo</string>
<string name="route_security_filters">Filtros de Segurança</string>
<string name="route_import_follows">Importar Seguidos</string>
<string name="new_post">Novo Post</string>
@@ -1938,4 +1975,18 @@
<string name="relay_members_removed">%1$d membros removidos do relay</string>
<string name="relay_join_request">Solicitação de entrada no relay</string>
<string name="relay_leave_request">Solicitação de saída do relay</string>
<string name="ai_writing_help">Assistente de escrita com IA</string>
<string name="ai_writing_setting_title">Sugerir melhorias de texto</string>
<string name="ai_writing_setting_description">Usa um modelo de IA no dispositivo para sugerir correções de texto e mudanças de tom.</string>
<string name="ai_writing_use_this">Usar isto</string>
<string name="ai_writing_dismiss">Dispensar</string>
<string name="ai_tone_correct">Corrigir</string>
<string name="ai_tone_rephrase">Reformular</string>
<string name="ai_tone_shorter">Mais curto</string>
<string name="ai_tone_elaborate">Elaborar</string>
<string name="ai_tone_friendly">Amigável</string>
<string name="ai_tone_professional">Profissional</string>
<string name="ai_tone_more_direct">Mais direto</string>
<string name="ai_tone_punchy">Impactante</string>
<string name="ai_tone_emojify">+ Emoji</string>
</resources>
@@ -55,6 +55,7 @@
<string name="boost">Продвинуть</string>
<string name="edited">изменено</string>
<string name="edited_number">изменить #%1$s</string>
<string name="original">оригинал</string>
<string name="quote">Цитата</string>
<string name="propose_an_edit">Предложить изменение</string>
<string name="new_amount_in_sats">Новая сумма в sat</string>
@@ -67,6 +68,8 @@
<string name="error_parsing_error_message">Ошибка при анализе сообщения об ошибке</string>
<string name="following">" Подписок"</string>
<string name="followers">" Подписчиков"</string>
<string name="number_following">"%1$s Подписок"</string>
<string name="number_followers">"%1$s Подписчиков"</string>
<string name="profile">Профиль</string>
<string name="security_filters">Фильтры безопасности</string>
<string name="log_out">Выйти</string>
@@ -143,7 +146,11 @@
<string name="upload_file">Загрузить файл</string>
<string name="take_a_picture">Сделать фото</string>
<string name="record_a_video">Снять видео</string>
<string name="record_a_message_description">Нажмите и удерживайте, чтобы записать сообщение</string>
<string name="re_record">Перезаписать</string>
<string name="recording_indicator_description">Запись</string>
<string name="uploading">Загрузка…</string>
<string name="voice_preset_none">Отсутствует</string>
<string name="voice_preset_deep">Глубокий</string>
<string name="voice_preset_high">Высокий</string>
<string name="voice_preset_neutral">Нейтральный</string>
@@ -161,6 +168,7 @@
<string name="conversations">Обсуждения</string>
<string name="notes">Записи</string>
<string name="replies">Ответы</string>
<string name="mutual">Ваш</string>
<string name="gallery">Галерея</string>
<string name="follows">"Подписок"</string>
<string name="reports">"Жалоб"</string>
@@ -507,6 +515,33 @@
<string name="classifieds_category_food">Еда</string>
<string name="classifieds_category_misc">Разное</string>
<string name="classifieds_category_other">Другое</string>
<string name="share_image">Поделиться изображением…</string>
<string name="unable_to_share_image">Не удается поделиться изображением, повторите попытку позже…</string>
<string name="share_video">Поделиться видео…</string>
<string name="unable_to_share_video">Не удается поделиться видео, повторите попытку позже…</string>
<string name="downloading_video_for_sharing">Скачивание видео…</string>
<string name="search_by_hashtag">Поиск по хештегу: #%1$s</string>
<string name="dont_translate_from">Не переводить с</string>
<string name="dont_translate_from_description">Языки показанные здесь не будут переведены. Выберите язык, чтобы удалить его и снова перевести.</string>
<string name="translate_to">Перевести на</string>
<string name="translate_to_description">Выберите язык для перевода содержимого.</string>
<string name="language_preferences">Настройки отображения языка</string>
<string name="language_preferences_description">Для каждой языковой пары, выберите язык, который показывать в первую очередь.</string>
<string name="language_preference_pair">%1$s → %2$s</string>
<string name="search_languages">Поиск языков</string>
<string name="add_language">Добавить язык</string>
<string name="add_language_pair">Добавить языковую пару</string>
<string name="source_language">Исходный язык</string>
<string name="target_language">Целевой язык</string>
<string name="show_first">Показать сперва %1$s</string>
<string name="no_language_preferences">Настройки языка пока отсутствуют. Они создаются автоматически во время переводов, вы можете добавить их в ручную.</string>
<string name="delete_preference">Удалить предпочтение</string>
<string name="pause">Пауза</string>
<string name="play">Воспроизведение</string>
<string name="open_dropdown_menu">Открыть выпадающее меню</string>
<string name="option_of">Вариант %1$s из %2$s</string>
<string name="crashreport_found_send">Отправить</string>
<string name="in_the_list">Уже в списке</string>
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
<!-- NIP-75 Zap Goals -->
@@ -622,11 +622,11 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="follow_set_rename_dialog_indicator_first_part">Preimenuješ iz </string>
<string name="follow_set_rename_dialog_indicator_second_part"> v..</string>
<string name="connect_through_your_orbot_setup_short">Prevzeta vrata so 9050</string>
<string name="connect_through_your_orbot_setup_markdown"> ## Poveži se preko Tor omrežja z Orbot aplikacijo
\n\n1. Namesti [Orbot aplikacijo](https://play.google.com/store/apps/details?id=org.torproject.android)
<string name="connect_through_your_orbot_setup_markdown"> ## Poveži se preko Tor omrežja z Orbot
\n\n1. Namesti [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android)
\n2. Zaženi Orbot aplikacijo
\n3. V Orbot aplikaciji, preveri \"Socks port\". Pradnastavitev uporablja vrata 9050
\n4. Po potrebi spremenite vrata v Orbot aplikaciji
\n4. Po potrebi spremenite vrata v Orbot
\n5. Prilagodi vrata na tem zaslonu
\n6. Pritisni gumb aktiviraj za uporabo Orbot aplikacije kot proxy
</string>
@@ -714,6 +714,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="call_unmute">Vklopi zvok</string>
<string name="call_camera_on">Vklopljena kamera</string>
<string name="call_camera_off">Izklopljena kamera</string>
<string name="call_switch_camera">Zamenjaj kamero</string>
<string name="call_speaker">Zvočnik</string>
<string name="call_earpiece">Slušalka</string>
<string name="call_bluetooth">Bluetooth</string>
@@ -725,6 +726,19 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="call_failed_start">Klic ni uspel</string>
<string name="call_failed_accept">Sprejem klica ni uspel</string>
<string name="call_failed_session">Vzpostavitev klicne seje ni uspela</string>
<string name="call_settings">Klicne nastavitve</string>
<string name="call_settings_video_quality">Kakovost videa</string>
<string name="call_settings_max_bitrate">Najvišja bitna hitrost videa</string>
<string name="call_settings_turn_servers">Strežniki TURN / STUN</string>
<string name="call_settings_turn_description">Privzeti strežniki STUN in TURN so vedno vključeni. Za omejena omrežja dodajte strežnike TURN po meri.</string>
<string name="call_settings_default_servers">Privzeti strežniki (vedno aktivni)</string>
<string name="call_settings_custom_turn">Strežniki TURN po meri</string>
<string name="call_settings_no_custom_turn">Ni nastavljenih strežnikov TURN po meri.</string>
<string name="call_settings_add_turn">Dodaj TURN strežnik</string>
<string name="call_settings_remove_turn">Odstrani</string>
<string name="call_settings_turn_url">URL strežnika</string>
<string name="call_settings_turn_username">Uporabniško ime</string>
<string name="call_settings_turn_credential">Akreditivi</string>
<string name="reply_notify">Obvesti: </string>
<string name="channel_list_join_conversation">Pridruži se pogovoru</string>
<string name="channel_list_user_or_group_id">ID uporabnika ali skupine</string>
@@ -1154,6 +1168,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="media_compression_quality_uncompressed">Nestisnjeno</string>
<string name="video_codec_h265_label">Uporabi H.265/HEVC Codec</string>
<string name="video_codec_h265_description">Boljša kakovost pri manjših velikostih datotek, vendar predvajanja H.265 ne podpirajo vse naprave.</string>
<string name="convert_gif_to_mp4_label">Pretvori GIF v MP4</string>
<string name="convert_gif_to_mp4_description">Pretvori animirane slike GIF v video MP4 za manjšo datoteko in boljšo združljivost predvajanja.</string>
<string name="strip_metadata_label">Odstrani zasebne metapodatke</string>
<string name="strip_metadata_description">Poskusi odstraniti zasebne metapodatke iz podprtih datotek pred nalaganjem.</string>
<string name="metadata_strip_failed_title">Metapodatkov ni možno odstraniti</string>
@@ -1199,6 +1215,27 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="wallet_filter_all">Vse</string>
<string name="wallet_filter_zaps">Zapi</string>
<string name="wallet_filter_non_zaps">Brez zapov</string>
<string name="wallet_add">Dodaj denarnico</string>
<string name="wallet_default">Privzeto</string>
<string name="wallet_set_default">Nastavi kot privzeto</string>
<string name="wallet_remove">Odstrani</string>
<string name="wallet_remove_confirm">Odstrani to denarnico</string>
<string name="wallet_remove_confirm_description">S tem boste odstranili povezavo z denarnico. Pozneje jo lahko kadar koli znova dodate.</string>
<string name="wallet_name">Ime denarnice</string>
<string name="wallet_name_hint">Moja denarnica</string>
<string name="wallet_your_wallets">Vaše denarnice</string>
<string name="wallet_manage">Upravljanje denarnic</string>
<string name="wallet_no_wallets">Ni povezanih denarnic</string>
<string name="wallet_no_wallets_description">Povežite eno ali več NWC denarnic, za pošiljanje in sprejemanje plačil.</string>
<string name="wallet_add_connection">Dodaj NWC povezavo</string>
<string name="wallet_paste_uri">Prilepi nostr+walletconnect:// URI</string>
<string name="wallet_save">Shrani</string>
<string name="wallet_rename">Preimenuj</string>
<string name="wallet_rename_title">Preimenuj denarnico</string>
<string name="wallet_edit">Uredi</string>
<string name="wallet_invalid_uri">Neveljaven NWC URI povezave</string>
<string name="wallet_move_up">Premakni navzgor</string>
<string name="wallet_move_down">Premakni navzdol</string>
<string name="route_security_filters">Varnostni filtri</string>
<string name="route_import_follows">Uvozi sledilce</string>
<string name="new_post">Nova objava</string>
@@ -1308,6 +1345,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="inbox_relays_not_found_examples">dobre opcije so::\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social</string>
<string name="dm_upload">Naloži ZS</string>
<string name="relay_settings">Nastavitve relejev</string>
<string name="relay_reorder">Razvrsti releje</string>
<string name="public_home_section">Javni za odhajajočo pošto/domači releji</string>
<string name="public_home_section_explainer_profile">Uporabnik pošilja objave na te releje</string>
<string name="public_home_section_explainer">Ta tip releja shranjuje vse vaše vsebine. Amethyst bo sem pošiljal vaše objave, ostali uporabniki pa bodo te releje uporabljali za iskanje vaših vsebin. Vstavite 1–3 releje. To so lahko osebni releji, plačljivi releji ali javni releji.</string>
@@ -1694,6 +1732,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="kind_shorts">Kratki videoposnetki</string>
<string name="kind_voice_msg">Zvočno sporočilo</string>
<string name="kind_voice_reply">Zvočni odgovori</string>
<string name="kind_wake">ZbudiSe</string>
<string name="kind_web_bookmark">Spletni zaznamek</string>
<string name="kind_wiki">Wiki</string>
<string name="start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust">Zagotovite si odličen vir objav tako, da sledite istim ljudem kot nekdo, ki mu zaupate.</string>
@@ -1953,4 +1992,18 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="relay_members_removed">%1$d članov odstranjenih iz releja</string>
<string name="relay_join_request">Prošnja za dostop do releja</string>
<string name="relay_leave_request">Prošnja za izstop z releja</string>
<string name="ai_writing_help">Pomoč za pisanje z umetno inteligenco</string>
<string name="ai_writing_setting_title">Predlagaj tekstovne izboljšave</string>
<string name="ai_writing_setting_description">Uporablja model umetne inteligence v napravi za predloge popravkov besedila in spremembe tona.</string>
<string name="ai_writing_use_this">Uporabi to</string>
<string name="ai_writing_dismiss">Prekliči</string>
<string name="ai_tone_correct">Pravilno</string>
<string name="ai_tone_rephrase">Izrazi drugače</string>
<string name="ai_tone_shorter">Krajše</string>
<string name="ai_tone_elaborate">Razloži podrobneje</string>
<string name="ai_tone_friendly">Prijateljsko</string>
<string name="ai_tone_professional">Profesionalno</string>
<string name="ai_tone_more_direct">Boj direktno</string>
<string name="ai_tone_punchy">Udarno</string>
<string name="ai_tone_emojify">+ Emoji</string>
</resources>
@@ -698,6 +698,7 @@
<string name="call_unmute">Ljud på</string>
<string name="call_camera_on">Kamera på</string>
<string name="call_camera_off">Kamera av</string>
<string name="call_switch_camera">Byt kamera</string>
<string name="call_speaker">Högtalare</string>
<string name="call_earpiece">Hörlur</string>
<string name="call_bluetooth">Bluetooth</string>
@@ -709,6 +710,19 @@
<string name="call_failed_start">Kunde inte starta samtal</string>
<string name="call_failed_accept">Kunde inte ta emot samtal</string>
<string name="call_failed_session">Kunde inte skapa samtalssession</string>
<string name="call_settings">Samtalsinställningar</string>
<string name="call_settings_video_quality">Videokvalitet</string>
<string name="call_settings_max_bitrate">Maximal videobitfrekvens</string>
<string name="call_settings_turn_servers">TURN- / STUN-servrar</string>
<string name="call_settings_turn_description">Standard-STUN- och TURN-servrar inkluderas alltid. Lägg till anpassade TURN-servrar för restriktiva nätverk.</string>
<string name="call_settings_default_servers">Standardservrar (alltid aktiva)</string>
<string name="call_settings_custom_turn">Anpassade TURN-servrar</string>
<string name="call_settings_no_custom_turn">Inga anpassade TURN-servrar konfigurerade.</string>
<string name="call_settings_add_turn">Lägg till TURN-server</string>
<string name="call_settings_remove_turn">Ta bort</string>
<string name="call_settings_turn_url">Server-URL</string>
<string name="call_settings_turn_username">Användarnamn</string>
<string name="call_settings_turn_credential">Inloggningsuppgift</string>
<string name="reply_notify">Meddela: </string>
<string name="channel_list_join_conversation">Gå med i konversation</string>
<string name="channel_list_user_or_group_id">Användare eller grupp ID</string>
@@ -1137,6 +1151,8 @@
<string name="media_compression_quality_uncompressed">Okomprimerad</string>
<string name="video_codec_h265_label">Använd H.265/HEVC-codec</string>
<string name="video_codec_h265_description">Bättre kvalitet med mindre filstorlek, men inte alla enheter stöder H.265-uppspelning.</string>
<string name="convert_gif_to_mp4_label">Konvertera GIF till MP4</string>
<string name="convert_gif_to_mp4_description">Konverterar animerad GIF till MP4-video för mindre filstorlek och bättre uppspelningskompatibilitet.</string>
<string name="strip_metadata_label">Ta bort privat metadata</string>
<string name="strip_metadata_description">Försöker ta bort privat metadata från mediefiler som stöds innan uppladdning</string>
<string name="metadata_strip_failed_title">Metadata kunde inte tas bort</string>
@@ -1182,6 +1198,27 @@
<string name="wallet_filter_all">Alla</string>
<string name="wallet_filter_zaps">Zaps</string>
<string name="wallet_filter_non_zaps">Övriga</string>
<string name="wallet_add">Lägg till plånbok</string>
<string name="wallet_default">Standard</string>
<string name="wallet_set_default">Ställ in som standard</string>
<string name="wallet_remove">Ta bort</string>
<string name="wallet_remove_confirm">Ta bort denna plånbok?</string>
<string name="wallet_remove_confirm_description">Detta tar bort plånboksanslutningen. Du kan alltid lägga till den igen senare.</string>
<string name="wallet_name">Plånboksnamn</string>
<string name="wallet_name_hint">Min plånbok</string>
<string name="wallet_your_wallets">Dina plånböcker</string>
<string name="wallet_manage">Hantera plånböcker</string>
<string name="wallet_no_wallets">Inga plånböcker anslutna</string>
<string name="wallet_no_wallets_description">Anslut en eller flera NWC-plånböcker för att skicka och ta emot betalningar.</string>
<string name="wallet_add_connection">Lägg till NWC-anslutning</string>
<string name="wallet_paste_uri">Klistra in nostr+walletconnect://-URI</string>
<string name="wallet_save">Spara</string>
<string name="wallet_rename">Byt namn</string>
<string name="wallet_rename_title">Byt namn på plånbok</string>
<string name="wallet_edit">Redigera</string>
<string name="wallet_invalid_uri">Ogiltig URI för NWC-anslutning</string>
<string name="wallet_move_up">Flytta upp</string>
<string name="wallet_move_down">Flytta ner</string>
<string name="route_security_filters">Säkerhetsfilter</string>
<string name="route_import_follows">Importera följare</string>
<string name="new_post">Nytt inlägg</string>
@@ -1937,4 +1974,18 @@
<string name="relay_members_removed">%1$d medlemmar borttagna från relä</string>
<string name="relay_join_request">Begäran om att gå med i relä</string>
<string name="relay_leave_request">Begäran om att lämna relä</string>
<string name="ai_writing_help">AI-skrivhjälp</string>
<string name="ai_writing_setting_title">Föreslå textförbättringar</string>
<string name="ai_writing_setting_description">Använder en AI-modell på enheten för att föreslå textkorrigeringar och tonändringar.</string>
<string name="ai_writing_use_this">Använd detta</string>
<string name="ai_writing_dismiss">Avvisa</string>
<string name="ai_tone_correct">Korrigera</string>
<string name="ai_tone_rephrase">Formulera om</string>
<string name="ai_tone_shorter">Kortare</string>
<string name="ai_tone_elaborate">Utveckla</string>
<string name="ai_tone_friendly">Vänlig</string>
<string name="ai_tone_professional">Professionell</string>
<string name="ai_tone_more_direct">Mer direkt</string>
<string name="ai_tone_punchy">Slagkraftig</string>
<string name="ai_tone_emojify">+ Emoji</string>
</resources>
@@ -703,6 +703,7 @@
<string name="call_unmute">取消静音</string>
<string name="call_camera_on">开启摄像头</string>
<string name="call_camera_off">关闭摄像头</string>
<string name="call_switch_camera">切换相机</string>
<string name="call_speaker">扬声器</string>
<string name="call_earpiece">耳机</string>
<string name="call_bluetooth">蓝牙</string>
@@ -714,6 +715,19 @@
<string name="call_failed_start">未能开始通话</string>
<string name="call_failed_accept">未能接受通话</string>
<string name="call_failed_session">创建通话会话失败</string>
<string name="call_settings">通话设置</string>
<string name="call_settings_video_quality">画质</string>
<string name="call_settings_max_bitrate">最大视频比特率</string>
<string name="call_settings_turn_servers">TURN / STUN 服务器</string>
<string name="call_settings_turn_description">始终包含默认STUN 和 TURN 服务器。为限制网络添加自定义 TURN 服务器。</string>
<string name="call_settings_default_servers">默认服务器 (始终活跃)</string>
<string name="call_settings_custom_turn">自定义 TURN 服务器</string>
<string name="call_settings_no_custom_turn">未配置自定义TURN服务器。</string>
<string name="call_settings_add_turn">添加 TURN 服务器</string>
<string name="call_settings_remove_turn">删除</string>
<string name="call_settings_turn_url">服务器 URL</string>
<string name="call_settings_turn_username">用户名</string>
<string name="call_settings_turn_credential">凭证</string>
<string name="reply_notify">通知:</string>
<string name="channel_list_join_conversation">加入对话</string>
<string name="channel_list_user_or_group_id">用户或群组 ID</string>
@@ -1143,6 +1157,8 @@
<string name="media_compression_quality_uncompressed">未压缩</string>
<string name="video_codec_h265_label">使用 H.265/HEVC 编解码器</string>
<string name="video_codec_h265_description">文件较小而质量更佳,但不是所有设备都支持 H.265 播放。</string>
<string name="convert_gif_to_mp4_label">转换 GIF 为 MP4</string>
<string name="convert_gif_to_mp4_description">将动画的 GIF 转换为 MP4 视频以获取较小文件大小和更好的播放兼容性。</string>
<string name="strip_metadata_label">删除私密元数据</string>
<string name="strip_metadata_description">尝试在上传之前从支持的媒体文件中删除私密元数据</string>
<string name="metadata_strip_failed_title">无法删除元数据</string>
+14
View File
@@ -802,6 +802,7 @@
<string name="call_unmute">Unmute</string>
<string name="call_camera_on">Camera on</string>
<string name="call_camera_off">Camera off</string>
<string name="call_switch_camera">Switch camera</string>
<string name="call_speaker">Speaker</string>
<string name="call_earpiece">Earpiece</string>
<string name="call_bluetooth">Bluetooth</string>
@@ -813,6 +814,19 @@
<string name="call_failed_start">Failed to start call</string>
<string name="call_failed_accept">Failed to accept call</string>
<string name="call_failed_session">Failed to create call session</string>
<string name="call_settings">Call Settings</string>
<string name="call_settings_video_quality">Video Quality</string>
<string name="call_settings_max_bitrate">Max Video Bitrate</string>
<string name="call_settings_turn_servers">TURN / STUN Servers</string>
<string name="call_settings_turn_description">Default STUN and TURN servers are always included. Add custom TURN servers for restrictive networks.</string>
<string name="call_settings_default_servers">Default servers (always active)</string>
<string name="call_settings_custom_turn">Custom TURN Servers</string>
<string name="call_settings_no_custom_turn">No custom TURN servers configured.</string>
<string name="call_settings_add_turn">Add TURN server</string>
<string name="call_settings_remove_turn">Remove</string>
<string name="call_settings_turn_url">Server URL</string>
<string name="call_settings_turn_username">Username</string>
<string name="call_settings_turn_credential">Credential</string>
<string name="reply_notify">Notify: </string>
@@ -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
}
@@ -1,293 +0,0 @@
/*
* 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.uploads
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.ByteArrayOutputStream
/**
* Unit tests for the pure-Kotlin GIF binary parser in [GifToMp4Converter].
*
* These tests exercise the bounds-checking fixes from the code review:
* - Image Descriptor (0x2C) block: corrected packed-byte offset and length precheck
* - skipSubBlocks: clamp to bytes.size to keep pos as a valid index
*
* The tests construct minimal synthetic GIF byte streams rather than decoding
* real images, so they run as plain JVM tests with no Android dependencies.
*/
class GifToMp4ConverterTest {
// --- Helpers to build synthetic GIF byte streams ---
private fun ByteArrayOutputStream.writeHeader(
width: Int = 1,
height: Int = 1,
gctBits: Int = 0,
) {
// "GIF89a"
write("GIF89a".toByteArray(Charsets.US_ASCII))
// Logical Screen Descriptor
write(width and 0xFF)
write((width shr 8) and 0xFF)
write(height and 0xFF)
write((height shr 8) and 0xFF)
// packed: bit 7 = has GCT, bits 0-2 = GCT size bits
val packed = if (gctBits > 0) 0x80 or (gctBits - 1) else 0x00
write(packed)
write(0) // bg color index
write(0) // pixel aspect ratio
if (gctBits > 0) {
repeat(3 * (1 shl gctBits)) { write(0) }
}
}
private fun ByteArrayOutputStream.writeGce(delayCentiseconds: Int) {
write(0x21)
write(0xF9)
write(0x04) // block size
write(0x00) // packed (disposal/transparent)
write(delayCentiseconds and 0xFF)
write((delayCentiseconds shr 8) and 0xFF)
write(0x00) // transparent color index
write(0x00) // block terminator
}
private fun ByteArrayOutputStream.writeImageDescriptor(
left: Int = 0,
top: Int = 0,
width: Int = 1,
height: Int = 1,
lctBits: Int = 0,
) {
write(0x2C)
write(left and 0xFF)
write((left shr 8) and 0xFF)
write(top and 0xFF)
write((top shr 8) and 0xFF)
write(width and 0xFF)
write((width shr 8) and 0xFF)
write(height and 0xFF)
write((height shr 8) and 0xFF)
val packed = if (lctBits > 0) 0x80 or (lctBits - 1) else 0x00
write(packed)
if (lctBits > 0) {
repeat(3 * (1 shl lctBits)) { write(0) }
}
}
private fun ByteArrayOutputStream.writeMinimalLzwData() {
write(0x02) // LZW minimum code size
write(0x01) // sub-block size = 1
write(0x00) // one byte of (meaningless) data
write(0x00) // sub-block terminator
}
private fun ByteArrayOutputStream.writeTrailer() {
write(0x3B)
}
private fun buildSingleFrameGif(delayCentiseconds: Int): ByteArray =
ByteArrayOutputStream()
.apply {
writeHeader()
writeGce(delayCentiseconds)
writeImageDescriptor()
writeMinimalLzwData()
writeTrailer()
}.toByteArray()
// --- Tests ---
@Test
fun `empty bytes return empty delay list`() {
assertEquals(emptyList<Int>(), GifToMp4Converter.parseGifFrameDelays(ByteArray(0)))
}
@Test
fun `bytes shorter than header return empty delay list`() {
assertEquals(emptyList<Int>(), GifToMp4Converter.parseGifFrameDelays(ByteArray(12)))
}
@Test
fun `single frame with 10 centisecond delay yields 100 ms`() {
val gif = buildSingleFrameGif(delayCentiseconds = 10)
assertEquals(listOf(100), GifToMp4Converter.parseGifFrameDelays(gif))
}
@Test
fun `single frame with 20 centisecond delay yields 200 ms`() {
val gif = buildSingleFrameGif(delayCentiseconds = 20)
assertEquals(listOf(200), GifToMp4Converter.parseGifFrameDelays(gif))
}
@Test
fun `delay of 0 centiseconds is normalized to 100 ms`() {
val gif = buildSingleFrameGif(delayCentiseconds = 0)
assertEquals(listOf(100), GifToMp4Converter.parseGifFrameDelays(gif))
}
@Test
fun `delay of 1 centisecond is normalized to 100 ms`() {
val gif = buildSingleFrameGif(delayCentiseconds = 1)
assertEquals(listOf(100), GifToMp4Converter.parseGifFrameDelays(gif))
}
@Test
fun `multiple frames with different delays are all parsed`() {
val gif =
ByteArrayOutputStream()
.apply {
writeHeader()
writeGce(5)
writeImageDescriptor()
writeMinimalLzwData()
writeGce(20)
writeImageDescriptor()
writeMinimalLzwData()
writeGce(7)
writeImageDescriptor()
writeMinimalLzwData()
writeTrailer()
}.toByteArray()
assertEquals(listOf(50, 200, 70), GifToMp4Converter.parseGifFrameDelays(gif))
}
@Test
fun `Image Descriptor with Local Color Table is parsed correctly`() {
// This exercises Fix #6: the packed byte must be read at offset 9 from
// the 0x2C separator, not from (pos - 1) after pos is already advanced.
val gif =
ByteArrayOutputStream()
.apply {
writeHeader()
writeGce(15)
writeImageDescriptor(lctBits = 3) // 2^(3+1) = 16 entries, 48-byte LCT
writeMinimalLzwData()
writeTrailer()
}.toByteArray()
assertEquals(listOf(150), GifToMp4Converter.parseGifFrameDelays(gif))
}
@Test
fun `Global Color Table is skipped correctly`() {
val gif =
ByteArrayOutputStream()
.apply {
writeHeader(gctBits = 2) // 2^(2+1) = 8 entries, 24-byte GCT
writeGce(12)
writeImageDescriptor()
writeMinimalLzwData()
writeTrailer()
}.toByteArray()
assertEquals(listOf(120), GifToMp4Converter.parseGifFrameDelays(gif))
}
@Test
fun `truncated GIF at Image Descriptor does not crash`() {
// Fix #6: `if (pos + 10 > bytes.size) break` must prevent the packed-byte read
// from falling off the end. This constructs a GCE followed by a 0x2C separator
// with only a few trailing bytes — less than the 10-byte descriptor.
val gif =
ByteArrayOutputStream()
.apply {
writeHeader()
writeGce(10)
write(0x2C)
// Only 5 bytes of descriptor content, not the full 10
write(0)
write(0)
write(0)
write(0)
write(0)
}.toByteArray()
// Should return the GCE delay without crashing; the truncated descriptor is skipped.
val delays = GifToMp4Converter.parseGifFrameDelays(gif)
assertEquals(listOf(100), delays)
}
@Test
fun `truncated GIF with oversized sub-block length does not crash`() {
// Fix #9: skipSubBlocks clamps pos to bytes.size so an adversarial sub-block
// length that reaches past end-of-buffer doesn't leave pos in an invalid state.
val gif =
ByteArrayOutputStream()
.apply {
writeHeader()
writeGce(10)
writeImageDescriptor()
write(0x02) // LZW min code size
write(0xFF) // sub-block length claiming 255 bytes of data
// ... but we only write 3 bytes, then abruptly end
write(0x00)
write(0x00)
write(0x00)
}.toByteArray()
// Must not throw ArrayIndexOutOfBoundsException
val delays = GifToMp4Converter.parseGifFrameDelays(gif)
assertEquals(listOf(100), delays)
}
@Test
fun `GIF with only trailer after header returns empty`() {
val gif =
ByteArrayOutputStream()
.apply {
writeHeader()
writeTrailer()
}.toByteArray()
assertEquals(emptyList<Int>(), GifToMp4Converter.parseGifFrameDelays(gif))
}
@Test
fun `non-GCE extension blocks are skipped without adding a delay`() {
// An Application Extension (label 0xFF) should be walked over via skipSubBlocks
// and produce no delay entry. The subsequent GCE's delay should still be read.
val gif =
ByteArrayOutputStream()
.apply {
writeHeader()
// Application Extension
write(0x21)
write(0xFF)
write(0x0B) // block size = 11 for NETSCAPE2.0
write("NETSCAPE2.0".toByteArray(Charsets.US_ASCII))
write(0x03) // sub-block size
write(0x01)
write(0x00)
write(0x00)
write(0x00) // sub-block terminator
writeGce(8)
writeImageDescriptor()
writeMinimalLzwData()
writeTrailer()
}.toByteArray()
val delays = GifToMp4Converter.parseGifFrameDelays(gif)
assertTrue("Expected delay list to contain 80 ms, got $delays", delays.contains(80))
}
}
@@ -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,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.quartz.benchmark
import androidx.benchmark.junit4.BenchmarkRule
import androidx.benchmark.junit4.measureRepeated
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.quartz.utils.Secp256k1InstanceC
import com.vitorpamplona.quartz.utils.Secp256k1InstanceKotlin
import fr.acinq.secp256k1.Secp256k1
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Android benchmark comparing three secp256k1 implementations:
* 1. ACINQ C (libsecp256k1 via JNI) — "Foo"
* 2. Pure Kotlin — "FooOurs"
* 3. Custom C (our implementation via JNI) — "FooC"
*
* Run with: ./gradlew :benchmark:connectedAndroidTest
*/
@RunWith(AndroidJUnit4::class)
class Secp256k1CBenchmark {
@get:Rule
val benchmarkRule = BenchmarkRule()
private val privKey = hexToBytes("67E56582298859DDAE725F972992A07C6C4FB9F62A8FFF58CE3CA926A1063530")
private val msg32 = hexToBytes("243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89")
private val auxRand = hexToBytes("0000000000000000000000000000000000000000000000000000000000000001")
private val native = Secp256k1.get()
private val nativePubKey = native.pubKeyCompress(native.pubkeyCreate(privKey))
private val nativeXOnlyPub = nativePubKey.copyOfRange(1, 33)
private val nativeSig = native.signSchnorr(msg32, privKey, auxRand)
private val kotlinSig =
com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
.signSchnorr(msg32, privKey, auxRand)
private val kotlinXOnlyPub =
com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
.pubKeyCompress(
com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
.pubkeyCreate(privKey),
).copyOfRange(1, 33)
// ==================== ACINQ C (Reference) ====================
@Test fun verifySchnorr() = benchmarkRule.measureRepeated { native.verifySchnorr(nativeSig, msg32, nativeXOnlyPub) }
@Test fun signSchnorr() = benchmarkRule.measureRepeated { native.signSchnorr(msg32, privKey, auxRand) }
@Test
fun pubkeyCreate() = benchmarkRule.measureRepeated { native.pubKeyCompress(native.pubkeyCreate(privKey)) }
// ==================== Pure Kotlin ====================
@Test
fun verifySchnorrOurs() =
benchmarkRule.measureRepeated {
com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
.verifySchnorr(kotlinSig, msg32, kotlinXOnlyPub)
}
@Test
fun verifySchnorrFastOurs() =
benchmarkRule.measureRepeated {
com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
.verifySchnorrFast(kotlinSig, msg32, kotlinXOnlyPub)
}
@Test
fun signSchnorrOurs() =
benchmarkRule.measureRepeated {
com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
.signSchnorr(msg32, privKey, auxRand)
}
@Test
fun signSchnorrXOnlyOurs() =
benchmarkRule.measureRepeated {
com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
.signSchnorrWithXOnlyPubKey(msg32, privKey, kotlinXOnlyPub, auxRand)
}
@Test
fun pubkeyCreateOurs() =
benchmarkRule.measureRepeated {
com.vitorpamplona.quartz.utils.secp256k1.Secp256k1.pubKeyCompress(
com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
.pubkeyCreate(privKey),
)
}
// ==================== Custom C (Our JNI) ====================
@Test
fun verifySchnorrC() {
Secp256k1InstanceC.init()
val cSig = Secp256k1InstanceC.signSchnorr(msg32, privKey, auxRand)
val cXOnly = Secp256k1InstanceC.compressedPubKeyFor(privKey).copyOfRange(1, 33)
benchmarkRule.measureRepeated { Secp256k1InstanceC.verifySchnorr(cSig, msg32, cXOnly) }
}
@Test
fun verifySchnorrFastC() {
Secp256k1InstanceC.init()
val cSig = Secp256k1InstanceC.signSchnorr(msg32, privKey, auxRand)
val cXOnly = Secp256k1InstanceC.compressedPubKeyFor(privKey).copyOfRange(1, 33)
benchmarkRule.measureRepeated { Secp256k1InstanceC.verifySchnorrFast(cSig, msg32, cXOnly) }
}
@Test
fun signSchnorrC() {
Secp256k1InstanceC.init()
benchmarkRule.measureRepeated { Secp256k1InstanceC.signSchnorr(msg32, privKey, auxRand) }
}
@Test
fun signSchnorrXOnlyC() {
Secp256k1InstanceC.init()
val cXOnly = Secp256k1InstanceC.compressedPubKeyFor(privKey).copyOfRange(1, 33)
benchmarkRule.measureRepeated { Secp256k1InstanceC.signSchnorrWithXOnlyPubKey(msg32, privKey, cXOnly, auxRand) }
}
@Test
fun pubkeyCreateC() {
Secp256k1InstanceC.init()
benchmarkRule.measureRepeated { Secp256k1InstanceC.compressedPubKeyFor(privKey) }
}
@Test
fun ecdhXOnlyC() {
Secp256k1InstanceC.init()
val pub2xOnly = hexToBytes("c2f9d9948dc8c7c38321e4b85c8558872eafa0641cd269db76848a6073e69133")
benchmarkRule.measureRepeated { Secp256k1InstanceC.ecdhXOnly(pub2xOnly, privKey) }
}
// ==================== Batch Verification (all three) ====================
@Test
fun verifySchnorrBatch16Ours() {
val sigs =
(0 until 16).map { i ->
val m = ByteArray(32) { (i * 7 + it).toByte() }
com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
.signSchnorr(m, privKey, auxRand)
}
val msgs = (0 until 16).map { i -> ByteArray(32) { (i * 7 + it).toByte() } }
benchmarkRule.measureRepeated {
Secp256k1InstanceKotlin.verifySchnorrBatch(kotlinXOnlyPub, sigs, msgs)
}
}
@Test
fun verifySchnorrBatch16C() {
Secp256k1InstanceC.init()
val cXOnly = Secp256k1InstanceC.compressedPubKeyFor(privKey).copyOfRange(1, 33)
val sigs =
(0 until 16).map { i ->
val m = ByteArray(32) { (i * 7 + it).toByte() }
Secp256k1InstanceC.signSchnorr(m, privKey, auxRand)
}
val msgs = (0 until 16).map { i -> ByteArray(32) { (i * 7 + it).toByte() } }
benchmarkRule.measureRepeated {
Secp256k1InstanceC.verifySchnorrBatch(cXOnly, sigs, msgs)
}
}
@Test
fun verifySchnorrBatch200Ours() {
val sigs =
(0 until 200).map { i ->
val m = ByteArray(32) { (i * 7 + it).toByte() }
com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
.signSchnorr(m, privKey, auxRand)
}
val msgs = (0 until 200).map { i -> ByteArray(32) { (i * 7 + it).toByte() } }
benchmarkRule.measureRepeated {
Secp256k1InstanceKotlin.verifySchnorrBatch(kotlinXOnlyPub, sigs, msgs)
}
}
@Test
fun verifySchnorrBatch200C() {
Secp256k1InstanceC.init()
val cXOnly = Secp256k1InstanceC.compressedPubKeyFor(privKey).copyOfRange(1, 33)
val sigs =
(0 until 200).map { i ->
val m = ByteArray(32) { (i * 7 + it).toByte() }
Secp256k1InstanceC.signSchnorr(m, privKey, auxRand)
}
val msgs = (0 until 200).map { i -> ByteArray(32) { (i * 7 + it).toByte() } }
benchmarkRule.measureRepeated {
Secp256k1InstanceC.verifySchnorrBatch(cXOnly, sigs, msgs)
}
}
// ==================== SHA-256 Comparison ====================
private val sha256Input = ByteArray(160) { it.toByte() } // BIP-340 tagged hash size
@Test
fun sha256Android() {
// Android's native SHA-256 (BoringSSL with ARM64 Crypto Extensions)
val md = java.security.MessageDigest.getInstance("SHA-256")
benchmarkRule.measureRepeated {
md.reset()
md.update(sha256Input)
md.digest()
}
}
@Test
fun sha256OurC() {
// Our C SHA-256 (ARM64 CE hardware acceleration)
Secp256k1InstanceC.init()
benchmarkRule.measureRepeated {
com.vitorpamplona.quartz.utils.Secp256k1C
.nativeSha256(sha256Input)
}
}
@Test
fun sha256Kotlin() {
// Kotlin SHA-256 (uses platform MessageDigest on Android)
benchmarkRule.measureRepeated {
com.vitorpamplona.quartz.utils.sha256
.sha256(sha256Input)
}
}
private fun hexToBytes(hex: String): ByteArray {
val len = hex.length / 2
val result = ByteArray(len)
for (i in 0 until len) {
result[i] = hex.substring(i * 2, i * 2 + 2).toInt(16).toByte()
}
return result
}
}
@@ -199,15 +199,17 @@ class CallManager(
) {
Log.d("CallManager") { "initiateCall: callId=$callId, callee=${calleePubKey.take(8)}, type=$callType, sdpLength=${sdpOffer.length}" }
val result = factory.createCallOffer(sdpOffer, calleePubKey, callId, callType, signer)
_state.value = CallState.Offering(callId, setOf(calleePubKey), callType)
stateMutex.withLock {
_state.value = CallState.Offering(callId, setOf(calleePubKey), callType)
startTimeout(callId)
}
publishEvent(result.wrap)
startTimeout(callId)
Log.d("CallManager") { "initiateCall: offer published, timeout started" }
}
// ---- Incoming call handling ----
fun onIncomingCallEvent(event: CallOfferEvent) {
private fun onIncomingCallEvent(event: CallOfferEvent) {
val callerPubKey = event.pubKey
val callId = event.callId() ?: return
val callType = event.callType() ?: CallType.VOICE
@@ -279,9 +281,10 @@ class CallManager(
discoveredCalleePeers.clear()
}
val allRecipients = current.groupMembers + signer.pubKey
Log.d("CallManager") { "acceptCall: publishing answer to ${allRecipients.size} recipients" }
val result = factory.createGroupCallAnswer(sdpAnswer, allRecipients, current.callId, signer)
val allMembers = current.groupMembers + signer.pubKey
val otherMembers = allMembers - signer.pubKey
Log.d("CallManager") { "acceptCall: publishing answer to ${otherMembers.size} recipients" }
val result = factory.createGroupCallAnswer(sdpAnswer, otherMembers, current.callId, signer)
result.wraps.forEach { publishEvent(it) }
Log.d("CallManager") { "acceptCall: answer published, now in Connecting state" }
@@ -304,12 +307,12 @@ class CallManager(
transitionToEnded(current.callId, current.peerPubKeys(), EndReason.REJECTED)
}
val allRecipients = current.groupMembers + signer.pubKey
val result = factory.createGroupReject(allRecipients, current.callId, signer = signer)
val otherMembers = current.groupMembers - signer.pubKey
val result = factory.createGroupReject(otherMembers, current.callId, signer = signer)
result.wraps.forEach { publishEvent(it) }
}
fun onCallAnswered(event: CallAnswerEvent) {
private fun onCallAnswered(event: CallAnswerEvent) {
val current = _state.value
val callId = event.callId()
val answeringPeer = event.pubKey
@@ -394,7 +397,7 @@ class CallManager(
}
}
fun onCallRejected(event: CallRejectEvent) {
private fun onCallRejected(event: CallRejectEvent) {
val current = _state.value
val callId = event.callId()
val rejectingPeer = event.pubKey
@@ -451,11 +454,11 @@ class CallManager(
}
}
fun onIceCandidate(event: CallIceCandidateEvent) {
private fun onIceCandidate(event: CallIceCandidateEvent) {
onIceCandidateReceived?.invoke(event)
}
fun onRenegotiate(event: CallRenegotiateEvent) {
private fun onRenegotiate(event: CallRenegotiateEvent) {
val current = _state.value
val callId = event.callId()
val currentCallId =
@@ -575,7 +578,7 @@ class CallManager(
result.wraps.forEach { publishEvent(it) }
}
fun onPeerHangup(event: CallHangupEvent) {
private fun onPeerHangup(event: CallHangupEvent) {
val current = _state.value
val callId = event.callId() ?: return
val leavingPeer = event.pubKey
@@ -765,22 +768,32 @@ class CallManager(
timeoutJob =
scope.launch {
delay(CALL_TIMEOUT_MS)
val current = _state.value
val currentCallId =
when (current) {
is CallState.Offering -> current.callId
is CallState.IncomingCall -> current.callId
else -> null
}
if (currentCallId == callId) {
val peerPubKeys =
val peerPubKeys: Set<HexKey>
val wasOffering: Boolean
stateMutex.withLock {
val current = _state.value
val currentCallId =
when (current) {
is CallState.Offering -> current.callId
is CallState.IncomingCall -> current.callId
else -> null
}
if (currentCallId != callId) return@launch
peerPubKeys =
when (current) {
is CallState.Offering -> current.peerPubKeys
is CallState.IncomingCall -> current.peerPubKeys()
else -> return@launch
}
wasOffering = current is CallState.Offering
transitionToEnded(callId, peerPubKeys, EndReason.TIMEOUT)
}
// Notify peers so their phones stop ringing immediately
// instead of waiting for their own 60-second timeout.
if (wasOffering && peerPubKeys.isNotEmpty()) {
val result = factory.createGroupHangup(peerPubKeys, callId, signer = signer)
result.wraps.forEach { publishEvent(it) }
}
}
}
@@ -32,6 +32,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
* - **Callee-to-callee mesh**: lower pubkey initiates
*
* It is platform-independent and testable without real WebRTC.
*
* All map-accessing methods are synchronized because ICE candidates
* arrive from WebRTC native threads while session management runs
* on coroutine dispatchers.
*/
class PeerSessionManager(
val localPubKey: HexKey,
@@ -42,6 +46,7 @@ class PeerSessionManager(
val pendingIceCandidates: MutableList<IceCandidateData> = mutableListOf(),
)
private val lock = Any()
private val sessions = mutableMapOf<HexKey, SessionEntry>()
/** Candidates received before a session exists for the sender. */
@@ -52,24 +57,26 @@ class PeerSessionManager(
fun registerSession(
peerPubKey: HexKey,
session: PeerSession,
): SessionEntry {
val globalPending = globalPendingIce.remove(peerPubKey) ?: emptyList()
val entry = SessionEntry(session)
entry.pendingIceCandidates.addAll(globalPending)
sessions[peerPubKey] = entry
return entry
}
): SessionEntry =
synchronized(lock) {
val globalPending = globalPendingIce.remove(peerPubKey) ?: emptyList()
val entry = SessionEntry(session)
entry.pendingIceCandidates.addAll(globalPending)
sessions[peerPubKey] = entry
entry
}
fun getSession(peerPubKey: HexKey): SessionEntry? = sessions[peerPubKey]
fun getSession(peerPubKey: HexKey): SessionEntry? = synchronized(lock) { sessions[peerPubKey] }
fun hasSession(peerPubKey: HexKey): Boolean = sessions.containsKey(peerPubKey)
fun hasSession(peerPubKey: HexKey): Boolean = synchronized(lock) { sessions.containsKey(peerPubKey) }
fun removeSession(peerPubKey: HexKey): SessionEntry? {
globalPendingIce.remove(peerPubKey)
return sessions.remove(peerPubKey)
}
fun removeSession(peerPubKey: HexKey): SessionEntry? =
synchronized(lock) {
globalPendingIce.remove(peerPubKey)
sessions.remove(peerPubKey)
}
fun allSessionKeys(): Set<HexKey> = sessions.keys.toSet()
fun allSessionKeys(): Set<HexKey> = synchronized(lock) { sessions.keys.toSet() }
// ---- ICE candidate routing (two-layer buffering) ----
@@ -84,42 +91,47 @@ class PeerSessionManager(
fun routeIceCandidate(
senderPubKey: HexKey,
candidate: IceCandidateData,
): IceRouteAction {
val entry = sessions[senderPubKey]
return when {
entry != null && entry.remoteDescriptionSet -> {
entry.session.addIceCandidate(candidate)
IceRouteAction.ADDED_DIRECTLY
}
): IceRouteAction =
synchronized(lock) {
val entry = sessions[senderPubKey]
when {
entry != null && entry.remoteDescriptionSet -> {
entry.session.addIceCandidate(candidate)
IceRouteAction.ADDED_DIRECTLY
}
entry != null -> {
entry.pendingIceCandidates.add(candidate)
IceRouteAction.BUFFERED_PER_SESSION
}
entry != null -> {
entry.pendingIceCandidates.add(candidate)
IceRouteAction.BUFFERED_PER_SESSION
}
else -> {
globalPendingIce.getOrPut(senderPubKey) { mutableListOf() }.add(candidate)
IceRouteAction.BUFFERED_GLOBALLY
else -> {
globalPendingIce.getOrPut(senderPubKey) { mutableListOf() }.add(candidate)
IceRouteAction.BUFFERED_GLOBALLY
}
}
}
}
/**
* Flushes all per-session buffered candidates into the PeerConnection.
* Called after setRemoteDescription succeeds.
*/
fun flushPendingIceCandidates(peerPubKey: HexKey): Int {
val entry = sessions[peerPubKey] ?: return 0
entry.remoteDescriptionSet = true
val candidates = entry.pendingIceCandidates.toList()
entry.pendingIceCandidates.clear()
val candidates: List<IceCandidateData>
val entry: SessionEntry
synchronized(lock) {
entry = sessions[peerPubKey] ?: return 0
entry.remoteDescriptionSet = true
candidates = entry.pendingIceCandidates.toList()
entry.pendingIceCandidates.clear()
}
candidates.forEach { entry.session.addIceCandidate(it) }
return candidates.size
}
fun globalPendingCount(peerPubKey: HexKey): Int = globalPendingIce[peerPubKey]?.size ?: 0
fun globalPendingCount(peerPubKey: HexKey): Int = synchronized(lock) { globalPendingIce[peerPubKey]?.size ?: 0 }
fun sessionPendingCount(peerPubKey: HexKey): Int = sessions[peerPubKey]?.pendingIceCandidates?.size ?: 0
fun sessionPendingCount(peerPubKey: HexKey): Int = synchronized(lock) { sessions[peerPubKey]?.pendingIceCandidates?.size ?: 0 }
// ---- Renegotiation glare handling ----
@@ -135,7 +147,7 @@ class PeerSessionManager(
remoteSdpOffer: String,
onAcceptRemote: (SessionEntry) -> Unit,
): GlareResolution {
val entry = sessions[peerPubKey] ?: return GlareResolution.NO_SESSION
val entry = synchronized(lock) { sessions[peerPubKey] } ?: return GlareResolution.NO_SESSION
val signalingState = entry.session.getSignalingState()
if (signalingState != SignalingState.HAVE_LOCAL_OFFER) {
@@ -172,7 +184,7 @@ class PeerSessionManager(
peerPubKey: HexKey,
sdpAnswer: String,
): AnswerRouteAction {
val entry = sessions[peerPubKey] ?: return AnswerRouteAction.NO_SESSION
val entry = synchronized(lock) { sessions[peerPubKey] } ?: return AnswerRouteAction.NO_SESSION
val signalingState = entry.session.getSignalingState()
if (signalingState != SignalingState.HAVE_LOCAL_OFFER) {
@@ -187,11 +199,15 @@ class PeerSessionManager(
// ---- Cleanup ----
fun disposeAll() {
for (entry in sessions.values) {
val entries: List<SessionEntry>
synchronized(lock) {
entries = sessions.values.toList()
sessions.clear()
globalPendingIce.clear()
}
for (entry in entries) {
entry.session.dispose()
}
sessions.clear()
globalPendingIce.clear()
}
}
@@ -301,13 +301,13 @@ class MarmotManager(
/**
* Check if KeyPackage rotation is needed.
*/
fun needsKeyPackageRotation(): Boolean = keyPackageRotationManager.needsRotation()
suspend fun needsKeyPackageRotation(): Boolean = keyPackageRotationManager.needsRotation()
/**
* Check if there are active (locally generated) KeyPackages.
* Returns true if at least one KeyPackage has been generated and not yet consumed.
*/
fun hasActiveKeyPackages(): Boolean = keyPackageRotationManager.hasActiveKeyPackages()
suspend fun hasActiveKeyPackages(): Boolean = keyPackageRotationManager.hasActiveKeyPackages()
/**
* Check if a specific group membership exists.
@@ -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 },
)
}
}
@@ -21,11 +21,11 @@
package com.vitorpamplona.amethyst.desktop.service.media
import uk.co.caprica.vlcj.factory.discovery.strategy.NativeDiscoveryStrategy
import java.io.File
/**
* Discovers bundled VLC libraries on Windows and Linux.
* Reads the Compose application resources directory and looks for a vlc/ subdirectory.
* Uses [VlcResourceResolver] to find the VLC directory from the Compose application
* resources or development fallback paths.
*/
class BundledVlcDiscoverer : NativeDiscoveryStrategy {
override fun supported(): Boolean {
@@ -34,9 +34,8 @@ class BundledVlcDiscoverer : NativeDiscoveryStrategy {
}
override fun discover(): String {
val resourcesDir = System.getProperty("compose.application.resources.dir") ?: return ""
val vlcDir = File(resourcesDir, "vlc")
return if (vlcDir.isDirectory) vlcDir.absolutePath else ""
val vlcDir = VlcResourceResolver.findVlcDir() ?: return ""
return vlcDir.absolutePath
}
override fun onFound(path: String): Boolean = true
@@ -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
@@ -24,11 +24,12 @@ import com.sun.jna.NativeLibrary
import uk.co.caprica.vlcj.binding.lib.LibC
import uk.co.caprica.vlcj.binding.support.runtime.RuntimeUtil
import uk.co.caprica.vlcj.factory.discovery.strategy.BaseNativeDiscoveryStrategy
import java.io.File
/**
* Discovers bundled VLC libraries on macOS.
* Must force-load libvlccore before libvlc to avoid link errors.
* Uses [VlcResourceResolver] to find the VLC directory from the Compose application
* resources or development fallback paths.
*/
class MacOsVlcDiscoverer :
BaseNativeDiscoveryStrategy(
@@ -41,9 +42,8 @@ class MacOsVlcDiscoverer :
}
override fun discoveryDirectories(): List<String> {
val resourcesDir = System.getProperty("compose.application.resources.dir") ?: return emptyList()
val vlcDir = File(resourcesDir, "vlc")
return if (vlcDir.isDirectory) listOf(vlcDir.absolutePath) else emptyList()
val vlcDir = VlcResourceResolver.findVlcDir() ?: return emptyList()
return listOf(vlcDir.absolutePath)
}
override fun onFound(path: String): Boolean {
@@ -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
}
@@ -0,0 +1,63 @@
/*
* 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.service.media
import java.io.File
/**
* Resolves the bundled VLC directory, with fallbacks for development (Gradle run).
*
* Resolution order:
* 1. `compose.application.resources.dir` system property (set by packaged app)
* 2. Gradle `prepareAppResources` build output
* 3. Source `appResources` directory (platform-specific)
*/
object VlcResourceResolver {
private val currentPlatform: String by lazy {
val os = System.getProperty("os.name").lowercase()
when {
"mac" in os || "darwin" in os -> "macos"
"win" in os -> "windows"
else -> "linux"
}
}
/**
* Returns the VLC directory if found, or null.
*/
fun findVlcDir(): File? {
// 1. Compose application resources dir (packaged app or plugin-provided)
System.getProperty("compose.application.resources.dir")?.let { dir ->
val vlcDir = File(dir, "vlc")
if (vlcDir.isDirectory) return vlcDir
}
// 2. Gradle prepareAppResources build output (relative to working dir)
val buildOutput = File("desktopApp/build/compose/tmp/prepareAppResources/vlc")
if (buildOutput.isDirectory) return buildOutput
// 3. Source appResources (platform-specific subdirectory)
val sourceResources = File("desktopApp/src/jvmMain/appResources/$currentPlatform/vlc")
if (sourceResources.isDirectory) return sourceResources
return null
}
}
@@ -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.**

Some files were not shown because too many files have changed in this diff Show More