mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 19:53:08 +00:00
Merge branch 'main' of https://github.com/vitorpamplona/amethyst
# Conflicts: # gradle/libs.versions.toml
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
+15
-2
@@ -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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-27
@@ -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
|
||||
|
||||
+2
-9
@@ -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 =
|
||||
|
||||
+46
-4
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+73
-16
@@ -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() {
|
||||
|
||||
+20
-9
@@ -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,
|
||||
)
|
||||
|
||||
+37
-23
@@ -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,
|
||||
|
||||
+72
-8
@@ -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) =
|
||||
|
||||
+13
-2
@@ -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)
|
||||
|
||||
+6
@@ -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()
|
||||
|
||||
+8
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
-629
@@ -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
|
||||
}
|
||||
+2
-1
@@ -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(
|
||||
|
||||
+5
-2
@@ -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()
|
||||
}
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+8
@@ -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,
|
||||
|
||||
+380
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
+235
@@ -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))
|
||||
}
|
||||
}
|
||||
+151
@@ -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
|
||||
}
|
||||
-293
@@ -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)
|
||||
}
|
||||
}
|
||||
+259
@@ -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
|
||||
}
|
||||
}
|
||||
+35
-22
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+56
-40
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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()
|
||||
}
|
||||
+32
@@ -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)
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.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
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.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,
|
||||
)
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.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,
|
||||
)
|
||||
+147
@@ -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))
|
||||
}
|
||||
}
|
||||
+182
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
+146
-7
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+6
-4
@@ -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),
|
||||
)
|
||||
|
||||
+44
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-5
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
+4
-4
@@ -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 {
|
||||
|
||||
+2
-10
@@ -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
|
||||
}
|
||||
|
||||
+63
@@ -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
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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,
|
||||
|
||||
+228
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
@@ -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
|
||||
|
||||
+9
-1
@@ -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 -> {
|
||||
|
||||
+7
-1
@@ -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(
|
||||
|
||||
+13
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
+2
-9
@@ -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()
|
||||
|
||||
+2
-2
@@ -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.
|
||||
|
||||
+30
-5
@@ -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 ""}",
|
||||
|
||||
+37
@@ -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() }
|
||||
+259
@@ -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)
|
||||
}
|
||||
}
|
||||
+159
@@ -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") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+109
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
-41
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+23
-2
@@ -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")
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user