From de0494d393d1fe40f34603861691c9cbbab54621 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 11 Dec 2025 15:53:44 +1100 Subject: [PATCH 01/77] WIP --- .../libsession/network/SessionNetwork.kt | 195 ++++ .../network/model/OnionDestination.kt | 16 + .../libsession/network/model/OnionError.kt | 76 ++ .../libsession/network/model/OnionResponse.kt | 11 + .../libsession/network/model/PathStatus.kt | 7 + .../session/libsession/network/model/Types.kt | 5 + .../libsession/network/onion/OnionBuilder.kt | 59 ++ .../onion}/OnionRequestEncryption.kt | 24 +- .../network/onion/OnionTransport.kt | 26 + .../libsession/network/onion/PathManager.kt | 170 ++++ .../network/onion/http/HttpOnionTransport.kt | 251 +++++ .../network/snode/SnodeDirectory.kt | 39 + .../libsession/network/snode/SnodeStorage.kt | 21 + .../utilities/OKHTTPUtilities.kt | 4 +- .../libsession/snode/OnionRequestAPI.kt | 772 --------------- .../libsession/snode/OwnedSwarmAuth.kt | 34 - .../org/session/libsession/snode/SnodeAPI.kt | 933 ------------------ .../session/libsession/snode/SnodeClock.kt | 120 --- .../session/libsession/snode/SnodeMessage.kt | 38 - .../session/libsession/snode/SnodeModule.kt | 25 - .../libsession/snode/StorageProtocol.kt | 18 - .../org/session/libsession/snode/SwarmAuth.kt | 17 - .../libsession/snode/model/BatchResponse.kt | 32 - .../snode/model/MessageResponses.kt | 45 - .../libsession/snode/utilities/PromiseUtil.kt | 73 -- 25 files changed, 890 insertions(+), 2121 deletions(-) create mode 100644 app/src/main/java/org/session/libsession/network/SessionNetwork.kt create mode 100644 app/src/main/java/org/session/libsession/network/model/OnionDestination.kt create mode 100644 app/src/main/java/org/session/libsession/network/model/OnionError.kt create mode 100644 app/src/main/java/org/session/libsession/network/model/OnionResponse.kt create mode 100644 app/src/main/java/org/session/libsession/network/model/PathStatus.kt create mode 100644 app/src/main/java/org/session/libsession/network/model/Types.kt create mode 100644 app/src/main/java/org/session/libsession/network/onion/OnionBuilder.kt rename app/src/main/java/org/session/libsession/{snode => network/onion}/OnionRequestEncryption.kt (76%) create mode 100644 app/src/main/java/org/session/libsession/network/onion/OnionTransport.kt create mode 100644 app/src/main/java/org/session/libsession/network/onion/PathManager.kt create mode 100644 app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt create mode 100644 app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt create mode 100644 app/src/main/java/org/session/libsession/network/snode/SnodeStorage.kt rename app/src/main/java/org/session/libsession/{snode => network}/utilities/OKHTTPUtilities.kt (94%) delete mode 100644 app/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt delete mode 100644 app/src/main/java/org/session/libsession/snode/OwnedSwarmAuth.kt delete mode 100644 app/src/main/java/org/session/libsession/snode/SnodeAPI.kt delete mode 100644 app/src/main/java/org/session/libsession/snode/SnodeClock.kt delete mode 100644 app/src/main/java/org/session/libsession/snode/SnodeMessage.kt delete mode 100644 app/src/main/java/org/session/libsession/snode/SnodeModule.kt delete mode 100644 app/src/main/java/org/session/libsession/snode/StorageProtocol.kt delete mode 100644 app/src/main/java/org/session/libsession/snode/SwarmAuth.kt delete mode 100644 app/src/main/java/org/session/libsession/snode/model/BatchResponse.kt delete mode 100644 app/src/main/java/org/session/libsession/snode/model/MessageResponses.kt delete mode 100644 app/src/main/java/org/session/libsession/snode/utilities/PromiseUtil.kt diff --git a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt new file mode 100644 index 0000000000..548474a186 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt @@ -0,0 +1,195 @@ +package org.session.libsession.network + +import org.session.libsession.network.model.OnionDestination +import org.session.libsession.network.model.OnionError +import org.session.libsession.network.model.OnionResponse +import org.session.libsession.network.model.Path +import org.session.libsession.network.onion.OnionTransport +import org.session.libsession.network.onion.Version +import org.session.libsession.network.onion.PathManager +import org.session.libsignal.utilities.Snode +import org.session.libsignal.utilities.Log + +/** + * High-level façade over onion routing: + * + * - asks PathManager for a path + * - uses OnionTransport to send over that path + * - maps OnionError -> path/node repair via PathManager + * - decides whether to retry once with a new path + */ +class SessionNetwork( + private val pathManager: PathManager, + private val transport: OnionTransport, +) { + + /** + * Main entry point for “send an onion request”. + * + * - destination: Snode or Server (file server, open group, etc.) + * - payload: the already-built request body to wrap in an onion + * - version: V2/V3/V4 onion protocol + */ + suspend fun sendOnionRequest( + destination: OnionDestination, + payload: ByteArray, + version: Version = Version.V4, + ): Result { + // If the destination is a specific snode, try not to route *through* it + val snodeToExclude: Snode? = when (destination) { + is OnionDestination.SnodeDestination -> destination.snode + is OnionDestination.ServerDestination -> null + } + + // 1. Pick a path + val initialPath = try { + pathManager.getPath(exclude = snodeToExclude) + } catch (t: Throwable) { + return Result.failure(t) + } + + // 2. First attempt + val first = transport.send( + path = initialPath, + destination = destination, + payload = payload, + version = version + ) + + if (first.isSuccess) { + return first + } + + val error = first.exceptionOrNull() + if (error !is OnionError) { + // Some unexpected exception coming out of the transport. + Log.w("SessionNetwork", "Non-OnionError failure: $error") + return Result.failure(error ?: IllegalStateException("Unknown failure")) + } + + // 3. Let PathManager react (drop path / snode) + handleOnionError(initialPath, error) + + // 4. Decide whether to retry + if (!shouldRetry(error)) { + return Result.failure(error) + } + + // 5. Retry once with a new path + val retryPath = try { + pathManager.getPath(exclude = snodeToExclude) + } catch (t: Throwable) { + // Couldn't even get a new path; keep original onion error + return Result.failure(error) + } + + val retry = transport.send( + path = retryPath, + destination = destination, + payload = payload, + version = version + ) + + // If second attempt fails with an OnionError, update paths again + val retryErr = retry.exceptionOrNull() + if (retryErr is OnionError) { + handleOnionError(retryPath, retryErr) + } + + return retry + } + + /** + * Map a specific OnionError to PathManager operations (node/path surgery). + */ + private fun handleOnionError(path: Path, error: OnionError) { + when (error) { + is OnionError.GuardConnectionFailed -> { + // Guard is the first node in the path. + Log.w("SessionNetwork", "Guard connection failed for ${error.guard}, dropping path") + pathManager.handleBadPath(path) + } + + is OnionError.GuardProtocolError -> { + Log.w( + "SessionNetwork", + "Guard protocol error code=${error.code}, dropping path" + ) + pathManager.handleBadPath(path) + } + + is OnionError.IntermediateNodeFailed -> { + val failedKey = error.failedPublicKey + if (failedKey != null) { + val badNode = findNodeByEd25519(path, failedKey) + if (badNode != null) { + Log.w("SessionNetwork", "Intermediate node failed: $badNode, repairing path") + pathManager.handleBadSnode(badNode) + } else { + Log.w( + "SessionNetwork", + "Intermediate node failed; key not found in path. Dropping path." + ) + pathManager.handleBadPath(path) + } + } else { + Log.w("SessionNetwork", "Intermediate node failed (no failed key); dropping path") + pathManager.handleBadPath(path) + } + } + + is OnionError.DestinationUnreachable -> { + // Exit node is usually last in the path + val exit = error.exitNode ?: path.lastOrNull() + if (exit != null && path.contains(exit)) { + Log.w("SessionNetwork", "Destination unreachable; marking exit node $exit as bad") + pathManager.handleBadSnode(exit) + } else { + Log.w("SessionNetwork", "Destination unreachable; dropping entire path") + pathManager.handleBadPath(path) + } + } + + is OnionError.DestinationError -> { + // Pure app-level error (404, 401, etc.): path is fine. + Log.i("SessionNetwork", "Destination error ${error.code}, not penalising path") + } + + is OnionError.ClockOutOfSync -> { + // Network is “working” but user must fix their device clock. + Log.w("SessionNetwork", "Clock out of sync: code=${error.code}") + } + + is OnionError.InvalidResponse -> { + Log.w("SessionNetwork", "Invalid onion response, dropping path") + pathManager.handleBadPath(path) + } + + is OnionError.Unknown -> { + Log.w("SessionNetwork", "Unknown onion error, dropping path: ${error.underlying}") + pathManager.handleBadPath(path) + } + } + } + + /** + * Policy: when does it make sense to try again with a new path? + */ + private fun shouldRetry(error: OnionError): Boolean = + when (error) { + is OnionError.GuardConnectionFailed -> true // try another guard/path + is OnionError.GuardProtocolError -> true // different guard may succeed + is OnionError.IntermediateNodeFailed -> true // path surgery then retry + is OnionError.DestinationUnreachable -> true // exit/node connectivity + is OnionError.DestinationError -> false // app-level; retrying won’t fix 404/401 + is OnionError.ClockOutOfSync -> false // must fix clock + is OnionError.InvalidResponse -> true // treat as corrupt path + is OnionError.Unknown -> true // conservative + } + + /** + * Find a node in the path by its ed25519 public key. + */ + private fun findNodeByEd25519(path: Path, ed25519: String): Snode? = + path.firstOrNull { it.publicKeySet?.ed25519Key == ed25519 } +} diff --git a/app/src/main/java/org/session/libsession/network/model/OnionDestination.kt b/app/src/main/java/org/session/libsession/network/model/OnionDestination.kt new file mode 100644 index 0000000000..322f269652 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/model/OnionDestination.kt @@ -0,0 +1,16 @@ +package org.session.libsession.network.model + +import org.session.libsignal.utilities.Snode + +sealed class OnionDestination(val description: String) { + class SnodeDestination(val snode: Snode) : + OnionDestination("Service node ${snode.ip}:${snode.port}") + + class ServerDestination( + val host: String, + val target: String, + val x25519PublicKey: String, + val scheme: String, + val port: Int + ) : OnionDestination(host) +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/network/model/OnionError.kt b/app/src/main/java/org/session/libsession/network/model/OnionError.kt new file mode 100644 index 0000000000..e7fa7f99d3 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/model/OnionError.kt @@ -0,0 +1,76 @@ +package org.session.libsession.network.model + +import org.session.libsignal.utilities.Snode + +sealed class OnionError(message: String, cause: Throwable? = null) : Exception(message, cause) { + + /** + * We couldn't even talk to the guard node. + * Typical causes: offline, DNS failure, TCP connect fails, TLS failure. + */ + data class GuardConnectionFailed( + val guard: Snode, + val underlying: Throwable + ) : OnionError("Failed to connect to guard ${guard.ip}:${guard.port}", underlying) + + /** + * Guard responded with a valid HTTP response but rejected the onion request as such. + * E.g. 4xx/5xx from the guard itself, protocol mismatch, overloaded, etc. + */ + data class GuardProtocolError( + val guard: Snode?, + val code: Int, + val body: String? + ) : OnionError("Guard ${guard?.ip}:${guard?.port} rejected onion request with $code", null) + + /** + * The onion chain broke mid-path: one hop reported that the next node was not found. + * failedPublicKey is the ed25519 key of the missing snode if known. + */ + data class IntermediateNodeFailed( + val reportingNode: Snode?, + val failedPublicKey: String? + ) : OnionError("Intermediate node failure (failedPublicKey=$failedPublicKey)", null) + + /** + * The exit node tried to reach the destination (server or snode) but failed at the network layer. + * DNS failure, connection refused, timeout, etc. + */ + data class DestinationUnreachable( + val exitNode: Snode?, + val destination: String, + val underlying: Throwable? + ) : OnionError("Exit node could not reach destination $destination", underlying) + + /** + * The destination (server or snode) responded with a non-success application-level status. + * E.g. 404, 401, 500, app-specific error JSON, etc. + * This means the path worked; usually we don't penalize the path. + */ + data class DestinationError( + val code: Int, + val body: String? + ) : OnionError("Destination returned error $code", null) + + /** + * Clock out of sync with the snode network (your special 406/425 cases). + */ + data class ClockOutOfSync( + val code: Int, + val body: String? + ) : OnionError("Clock out of sync with service node network (code=$code)", null) + + /** + * The guard/destination returned something that we couldn't decode as a valid onion response. + */ + data class InvalidResponse( + val raw: ByteArray + ) : OnionError("Invalid onion response", null) + + /** + * Fallback for anything we haven't classified yet. + */ + data class Unknown( + val underlying: Throwable + ) : OnionError("Unknown onion error", underlying) +} diff --git a/app/src/main/java/org/session/libsession/network/model/OnionResponse.kt b/app/src/main/java/org/session/libsession/network/model/OnionResponse.kt new file mode 100644 index 0000000000..7ab0a60710 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/model/OnionResponse.kt @@ -0,0 +1,11 @@ +package org.session.libsession.network.model + +import org.session.libsignal.utilities.ByteArraySlice + +data class OnionResponse( + val info: Map<*, *>, + val body: ByteArraySlice? = null +) { + val code: Int? get() = info["code"] as? Int + val message: String? get() = info["message"] as? String +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/network/model/PathStatus.kt b/app/src/main/java/org/session/libsession/network/model/PathStatus.kt new file mode 100644 index 0000000000..52bbf509e0 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/model/PathStatus.kt @@ -0,0 +1,7 @@ +package org.session.libsession.network.model + +enum class PathStatus { + READY, // green + BUILDING, // orange + ERROR // red (offline, no path, repeated failures, etc.) +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/network/model/Types.kt b/app/src/main/java/org/session/libsession/network/model/Types.kt new file mode 100644 index 0000000000..994895c64e --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/model/Types.kt @@ -0,0 +1,5 @@ +package org.session.libsession.network.model + +import org.session.libsignal.utilities.Snode + +typealias Path = List \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/network/onion/OnionBuilder.kt b/app/src/main/java/org/session/libsession/network/onion/OnionBuilder.kt new file mode 100644 index 0000000000..c57438780d --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/onion/OnionBuilder.kt @@ -0,0 +1,59 @@ +package org.session.libsession.network.onion + +import org.session.libsession.network.model.OnionDestination +import org.session.libsession.utilities.AESGCM.EncryptionResult +import org.session.libsignal.utilities.Snode + +object OnionBuilder { + + data class BuiltOnion( + val guard: Snode, + val ciphertext: ByteArray, + val ephemeralPublicKey: ByteArray, + val destinationSymmetricKey: ByteArray + ) + + fun build( + path: List, + destination: OnionDestination, + payload: ByteArray, + version: Version + ): BuiltOnion { + require(path.isNotEmpty()) { "Path must not be empty" } + + val guardSnode = path.first() + + val destResult: EncryptionResult = + OnionRequestEncryption.encryptPayloadForDestination(payload, destination, version) + + var encryptionResult: EncryptionResult = destResult + var rhs: OnionDestination = destination + var remainingPath = path + + fun addLayer(): EncryptionResult { + return if (remainingPath.isEmpty()) { + encryptionResult + } else { + val lhs = OnionDestination.SnodeDestination(remainingPath.last()) + remainingPath = remainingPath.dropLast(1) + + OnionRequestEncryption.encryptHop(lhs, rhs, encryptionResult).also { r -> + encryptionResult = r + rhs = lhs + } + } + } + + while (remainingPath.isNotEmpty()) { + addLayer() + } + + return BuiltOnion( + guard = guardSnode, + ciphertext = encryptionResult.ciphertext, + ephemeralPublicKey = encryptionResult.ephemeralPublicKey, + destinationSymmetricKey = destResult.symmetricKey + ) + } +} + diff --git a/app/src/main/java/org/session/libsession/snode/OnionRequestEncryption.kt b/app/src/main/java/org/session/libsession/network/onion/OnionRequestEncryption.kt similarity index 76% rename from app/src/main/java/org/session/libsession/snode/OnionRequestEncryption.kt rename to app/src/main/java/org/session/libsession/network/onion/OnionRequestEncryption.kt index dc3435b65f..196ce60dcd 100644 --- a/app/src/main/java/org/session/libsession/snode/OnionRequestEncryption.kt +++ b/app/src/main/java/org/session/libsession/network/onion/OnionRequestEncryption.kt @@ -1,6 +1,6 @@ -package org.session.libsession.snode +package org.session.libsession.network.onion -import org.session.libsession.snode.OnionRequestAPI.Destination +import org.session.libsession.network.model.OnionDestination import org.session.libsession.utilities.AESGCM import org.session.libsession.utilities.AESGCM.EncryptionResult import org.session.libsignal.utilities.JsonUtil @@ -31,7 +31,7 @@ object OnionRequestEncryption { */ internal fun encryptPayloadForDestination( payload: ByteArray, - destination: Destination, + destination: OnionDestination, version: Version ): EncryptionResult { val plaintext = if (version == Version.V4) { @@ -39,13 +39,13 @@ object OnionRequestEncryption { } else { // Wrapping isn't needed for file server or open group onion requests when (destination) { - is Destination.Snode -> encode(payload, mapOf("headers" to "")) - is Destination.Server -> payload + is OnionDestination.SnodeDestination -> encode(payload, mapOf("headers" to "")) + is OnionDestination.ServerDestination -> payload } } val x25519PublicKey = when (destination) { - is Destination.Snode -> destination.snode.publicKeySet!!.x25519Key - is Destination.Server -> destination.x25519PublicKey + is OnionDestination.SnodeDestination -> destination.snode.publicKeySet!!.x25519Key + is OnionDestination.ServerDestination -> destination.x25519PublicKey } return AESGCM.encrypt(plaintext, x25519PublicKey) } @@ -53,13 +53,13 @@ object OnionRequestEncryption { /** * Encrypts the previous encryption result (i.e. that of the hop after this one) for this hop. Use this to build the layers of an onion request. */ - internal fun encryptHop(lhs: Destination, rhs: Destination, previousEncryptionResult: EncryptionResult): EncryptionResult { + internal fun encryptHop(lhs: OnionDestination, rhs: OnionDestination, previousEncryptionResult: EncryptionResult): EncryptionResult { val payload: MutableMap = when (rhs) { - is Destination.Snode -> { + is OnionDestination.SnodeDestination -> { mutableMapOf("destination" to rhs.snode.publicKeySet!!.ed25519Key) } - is Destination.Server -> { + is OnionDestination.ServerDestination -> { mutableMapOf( "host" to rhs.host, "target" to rhs.target, @@ -71,11 +71,11 @@ object OnionRequestEncryption { } payload["ephemeral_key"] = previousEncryptionResult.ephemeralPublicKey.toHexString() val x25519PublicKey = when (lhs) { - is Destination.Snode -> { + is OnionDestination.SnodeDestination -> { lhs.snode.publicKeySet!!.x25519Key } - is Destination.Server -> { + is OnionDestination.ServerDestination -> { lhs.x25519PublicKey } } diff --git a/app/src/main/java/org/session/libsession/network/onion/OnionTransport.kt b/app/src/main/java/org/session/libsession/network/onion/OnionTransport.kt new file mode 100644 index 0000000000..3a68f16291 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/onion/OnionTransport.kt @@ -0,0 +1,26 @@ +package org.session.libsession.network.onion + +import org.session.libsession.network.model.OnionDestination +import org.session.libsession.network.model.OnionResponse +import org.session.libsignal.utilities.Snode + +interface OnionTransport { + /** + * Sends an onion request over one path. + * + * @return Result.success(response) on success + * Result.failure(OnionError) on onion/path/guard/destination error + */ + suspend fun send( + path: List, + destination: OnionDestination, + payload: ByteArray, + version: Version + ): Result +} + +enum class Version(val value: String) { + V2("/loki/v2/lsrpc"), + V3("/loki/v3/lsrpc"), + V4("/oxen/v4/lsrpc"); +} diff --git a/app/src/main/java/org/session/libsession/network/onion/PathManager.kt b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt new file mode 100644 index 0000000000..23f0d289f4 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt @@ -0,0 +1,170 @@ +package org.session.libsession.network.onion + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.session.libsession.network.model.Path +import org.session.libsession.network.model.PathStatus +import org.session.libsession.network.snode.SnodeDirectory +import org.session.libsession.network.snode.SnodePathStorage +import org.session.libsignal.crypto.secureRandom +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Snode + +class PathManager( + private val scope: CoroutineScope, + private val directory: SnodeDirectory, + private val storage: SnodePathStorage, // mapping of old get/setOnionRequestPaths + private val pathSize: Int = 3, + private val targetPathCount: Int = 2, +) { + + private val _paths = MutableStateFlow( + sanitizePaths(storage.getOnionRequestPaths()) + ) + val paths: StateFlow> = _paths.asStateFlow() + + private val _isBuilding = MutableStateFlow(false) + + @OptIn(FlowPreview::class) + val status: StateFlow = + combine(_paths, _isBuilding) { paths, building -> + when { + building -> PathStatus.BUILDING + paths.isEmpty() -> PathStatus.ERROR + else -> PathStatus.READY + } + } + .debounce(250) + .stateIn( + scope, + SharingStarted.Eagerly, + if (_paths.value.isEmpty()) PathStatus.ERROR else PathStatus.READY + ) + + init { + // persist to DB whenever paths change + scope.launch { + _paths.drop(1).collectLatest { paths -> + if (paths.isEmpty()) storage.clearOnionRequestPaths() + else storage.setOnionRequestPaths(paths) + } + } + } + + suspend fun getPath(exclude: Snode? = null): Path { + val current = _paths.value + if (current.size >= targetPathCount && current.any { exclude == null || !it.contains(exclude) }) { + return selectPath(current, exclude) + } + + // Need to (re)build paths + rebuildPaths(reusablePaths = current) + val rebuilt = _paths.value + if (rebuilt.isEmpty()) throw IllegalStateException("No paths after rebuild") + return selectPath(rebuilt, exclude) + } + + suspend fun rebuildPaths(reusablePaths: List) { + if (_isBuilding.value) return + + _isBuilding.value = true + try { + val safeReusable = sanitizePaths(reusablePaths) + val reusableGuards = safeReusable.map { it.first() }.toSet() + + val guardSnodes = directory.getGuardSnodes( + existingGuards = reusableGuards, + targetGuardCount = targetPathCount + ) + + var unused = directory.getSnodePool() + .minus(guardSnodes) + .minus(safeReusable.flatten().toSet()) + + val newPaths = guardSnodes + .minus(reusableGuards) + .map { guard -> + val rest = (0 until pathSize - 1).map { + val next = unused.secureRandom() + unused = unused - next + next + } + listOf(guard) + rest + } + + val allPaths = (safeReusable + newPaths).take(targetPathCount) + val sanitized = sanitizePaths(allPaths) + _paths.value = sanitized + } finally { + _isBuilding.value = false + } + } + + /** Called when we know a specific snode is bad. */ + fun handleBadSnode(snode: Snode) { + val current = _paths.value.toMutableList() + val pathIndex = current.indexOfFirst { it.contains(snode) } + if (pathIndex == -1) return + + val path = current[pathIndex].toMutableList() + path.remove(snode) + + val unused = directory.getSnodePool().minus(current.flatten().toSet()) + if (unused.isEmpty()) { + Log.w("Onion", "No unused snodes to repair path, dropping path entirely") + current.removeAt(pathIndex) + _paths.value = current + return + } + + val replacement = unused.secureRandom() + path.add(replacement) + current[pathIndex] = path + _paths.value = sanitizePaths(current) + } + + /** Called when an entire path is considered unreliable. */ + fun handleBadPath(path: Path) { + val current = _paths.value.toMutableList() + current.remove(path) + _paths.value = current + // Next call to getPath() will trigger rebuild if needed + } + + // --- helpers --- + + private fun selectPath(paths: List, exclude: Snode?): Path { + val candidates = if (exclude != null) { + paths.filter { !it.contains(exclude) } + } else paths + + if (candidates.isEmpty()) { + // fallback: ignore exclude and just pick something + return paths.secureRandom() + } + + return candidates.secureRandom() + } + + private fun sanitizePaths(paths: List): List { + if (paths.isEmpty()) return emptyList() + if (arePathsDisjoint(paths)) return paths + Log.w("Onion", "Paths contained overlapping snodes. Dropping backups.") + return paths.take(1) + } + + private fun arePathsDisjoint(paths: List): Boolean { + val all = paths.flatten() + return all.size == all.toSet().size + } +} diff --git a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt new file mode 100644 index 0000000000..ec6ae873d6 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt @@ -0,0 +1,251 @@ +package org.session.libsession.network.onion.http + +import kotlin.text.Charsets +import org.session.libsession.network.model.OnionDestination +import org.session.libsession.network.model.OnionError +import org.session.libsession.network.model.OnionResponse +import org.session.libsession.network.onion.OnionBuilder +import org.session.libsession.network.onion.OnionRequestEncryption +import org.session.libsession.network.onion.OnionTransport +import org.session.libsession.network.onion.Version +import org.session.libsession.utilities.AESGCM +import org.session.libsession.utilities.AESGCM.ivSize +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.ByteArraySlice +import org.session.libsignal.utilities.ByteArraySlice.Companion.view +import org.session.libsignal.utilities.HTTP +import org.session.libsignal.utilities.JsonUtil +import org.session.libsignal.utilities.Snode +import org.session.libsignal.utilities.toHexString + +class HttpOnionTransporter : OnionTransport { + + override suspend fun send( + path: List, + destination: OnionDestination, + payload: ByteArray, + version: Version + ): Result { + return try { + val built = OnionBuilder.build(path, destination, payload, version) + val guard = built.guard + val url = "${guard.address}:${guard.port}/onion_req/v2" + + val params = mapOf( + "ephemeral_key" to built.ephemeralPublicKey.toHexString() + ) + + val body = OnionRequestEncryption.encode( + ciphertext = built.ciphertext, + json = params + ) + + val responseBytes = try { + HTTP.execute(HTTP.Verb.POST, url, body) + } catch (httpEx: HTTP.HTTPRequestFailedException) { + // This is an HTTP-level failure to the guard + return Result.failure(classifyHttpFailure(path, destination, httpEx)) + } catch (t: Throwable) { + return Result.failure( + OnionError.GuardConnectionFailed(guard, t) + ) + } + + val response = decodeResponse(responseBytes, destination, version, built.destinationSymmetricKey) + Result.success(response) + } catch (e: OnionError) { + Result.failure(e) + } catch (t: Throwable) { + Result.failure(OnionError.Unknown(t)) + } + } + + /** + * Turn a HTTP.HTTPRequestFailedException from the guard into a structured OnionError. + * + * This is where we replicate the old logic that interpreted "Next node not found", etc. + */ + private fun classifyHttpFailure( + path: List, + destination: OnionDestination, + ex: HTTP.HTTPRequestFailedException + ): OnionError { + val json = ex.json + val statusCode = ex.statusCode + val message = json?.get("result") as? String + val guard = path.firstOrNull() + + val prefix = "Next node not found: " + if (message != null && message.startsWith(prefix)) { + val failedKey = message.removePrefix(prefix) + return OnionError.IntermediateNodeFailed( + reportingNode = guard, + failedPublicKey = failedKey + ) + } + + // Destination-related 4xx/5xx that we don't want to penalize path for + if (destination is OnionDestination.ServerDestination && + (statusCode in 500..504 || statusCode == 400) && + (ex.body?.contains(destination.host) == true) + ) { + return OnionError.DestinationError(code = statusCode, body = ex.body) + } + + // Special clock out of sync codes from your old logic + if (statusCode == 406 || statusCode == 425) { + return OnionError.ClockOutOfSync(code = statusCode, body = message) + } + + // 404, 403, etc. that are likely application or resource errors + if (statusCode in listOf(400, 401, 403, 404)) { + return OnionError.DestinationError(code = statusCode, body = message) + } + + // Fallback: treat as guard protocol error + return OnionError.GuardProtocolError( + guard = guard, + code = statusCode, + body = message + ) + } + + private fun decodeResponse( + response: ByteArray, + destination: OnionDestination, + version: Version, + destinationSymmetricKey: ByteArray + ): OnionResponse { + return when (version) { + Version.V4 -> decodeV4(response, destination, destinationSymmetricKey) + Version.V2, Version.V3 -> decodeLegacy(response, destination, destinationSymmetricKey) + } + } + + private fun decodeV4( + response: ByteArray, + destination: OnionDestination, + destinationSymmetricKey: ByteArray + ): OnionResponse { + if (response.size <= ivSize) throw OnionError.InvalidResponse(response) + + val plaintext = try { + AESGCM.decrypt(response, symmetricKey = destinationSymmetricKey) + } catch (e: Throwable) { + throw OnionError.InvalidResponse(response) + } + + if (!byteArrayOf(plaintext.first()).contentEquals("l".toByteArray())) { + throw OnionError.InvalidResponse(response) + } + + val infoSepIdx = plaintext.indexOfFirst { byteArrayOf(it).contentEquals(":".toByteArray()) } + val infoLenSlice = plaintext.slice(1 until infoSepIdx) + val infoLength = infoLenSlice.toByteArray().toString(Charsets.US_ASCII).toIntOrNull() + ?: throw OnionError.InvalidResponse(response) + + if (infoLenSlice.size <= 1) throw OnionError.InvalidResponse(response) + + val infoStartIndex = "l$infoLength".length + 1 + val infoEndIndex = infoStartIndex + infoLength + val info = plaintext.slice(infoStartIndex until infoEndIndex) + val responseInfo = JsonUtil.fromJson(info.toByteArray(), Map::class.java) + + val statusCode = responseInfo["code"].toString().toInt() + + when (statusCode) { + 406, 425 -> throw OnionError.ClockOutOfSync(statusCode, responseInfo["result"]?.toString()) + !in 200..299 -> { + val responseBody = + if (destination is OnionDestination.ServerDestination && statusCode == 400) { + plaintext.getBody(infoLength, infoEndIndex) + } else null + + val requireBlinding = + "Invalid authentication: this server requires the use of blinded ids" + + if (responseBody != null && responseBody.decodeToString() == requireBlinding) { + // You could introduce a dedicated error subtype if you want. + throw OnionError.DestinationError(400, requireBlinding) + } else { + throw OnionError.DestinationError(statusCode, responseBody?.decodeToString()) + } + } + } + + val responseBody = plaintext.getBody(infoLength, infoEndIndex) + + return if (responseBody.isEmpty()) { + OnionResponse(responseInfo, null) + } else { + OnionResponse(responseInfo, responseBody) + } + } + + private fun decodeLegacy( + response: ByteArray, + destination: OnionDestination, + destinationSymmetricKey: ByteArray + ): OnionResponse { + val json = try { + JsonUtil.fromJson(response, Map::class.java) + } catch (e: Exception) { + mapOf("result" to response.decodeToString()) + } + + val base64EncodedIVAndCiphertext = + json["result"] as? String ?: throw OnionError.InvalidResponse(response) + + val ivAndCiphertext = Base64.decode(base64EncodedIVAndCiphertext) + + val plaintext = try { + AESGCM.decrypt(ivAndCiphertext, symmetricKey = destinationSymmetricKey) + } catch (e: Throwable) { + throw OnionError.InvalidResponse(response) + } + + val parsed = try { + JsonUtil.fromJson(plaintext.toString(Charsets.UTF_8), Map::class.java) + } catch (e: Exception) { + throw OnionError.InvalidResponse(plaintext) + } + + val statusCode = parsed["status_code"] as? Int ?: parsed["status"] as Int + + if (statusCode == 406) { + throw OnionError.ClockOutOfSync(statusCode, parsed["result"]?.toString()) + } + + if (parsed["body"] != null) { + @Suppress("UNCHECKED_CAST") + val body = if (parsed["body"] is Map<*, *>) { + parsed["body"] as Map<*, *> + } else { + val bodyAsString = parsed["body"] as String + JsonUtil.fromJson(bodyAsString, Map::class.java) + } + + if (statusCode != 200) { + throw OnionError.DestinationError(statusCode, body.toString()) + } + + return OnionResponse(body, JsonUtil.toJson(body).toByteArray().view()) + } else { + if (statusCode != 200) { + throw OnionError.DestinationError(statusCode, parsed.toString()) + } + + return OnionResponse(parsed, JsonUtil.toJson(parsed).toByteArray().view()) + } + } + + private fun ByteArray.getBody(infoLength: Int, infoEndIndex: Int): ByteArraySlice { + val infoLengthStringLength = infoLength.toString().length + if (size <= infoLength + infoLengthStringLength + 2 /* l and e */) { + return ByteArraySlice.EMPTY + } + val dataSlice = view(infoEndIndex + 1 until size - 1) + val dataSepIdx = dataSlice.asList().indexOfFirst { it.toInt() == ':'.code } + return dataSlice.view(dataSepIdx + 1 until dataSlice.len) + } +} diff --git a/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt b/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt new file mode 100644 index 0000000000..16ddf1010d --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt @@ -0,0 +1,39 @@ +package org.session.libsession.network.snode + + +import org.session.libsignal.utilities.Snode +import org.session.libsignal.crypto.secureRandom +import org.session.libsignal.utilities.Log + +class SnodeDirectory( + private val storage: SnodePoolStorage, +) { + fun getSnodePool(): Set = storage.getSnodePool() + + fun updateSnodePool(newPool: Set) { + storage.setSnodePool(newPool) + } + + fun getGuardSnodes( + existingGuards: Set, + targetGuardCount: Int + ): Set { + if (existingGuards.size >= targetGuardCount) return existingGuards + + var unused = getSnodePool().minus(existingGuards) + val needed = targetGuardCount - existingGuards.size + + if (unused.size < needed) { + throw IllegalStateException("Insufficient snodes to build guards") + } + + val newGuards = (0 until needed).map { + val candidate = unused.secureRandom() + unused = unused - candidate + Log.d("Onion", "Selected guard snode: $candidate") + candidate + } + + return (existingGuards + newGuards).toSet() + } +} diff --git a/app/src/main/java/org/session/libsession/network/snode/SnodeStorage.kt b/app/src/main/java/org/session/libsession/network/snode/SnodeStorage.kt new file mode 100644 index 0000000000..566b3a2c25 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/snode/SnodeStorage.kt @@ -0,0 +1,21 @@ +package org.session.libsession.network.snode + + +import org.session.libsession.network.model.Path +import org.session.libsignal.utilities.Snode + +interface SnodePathStorage { + fun getOnionRequestPaths(): List + fun setOnionRequestPaths(paths: List) + fun clearOnionRequestPaths() +} + +interface SwarmStorage { + fun getSwarm(publicKey: String): Set? + fun setSwarm(publicKey: String, swarm: Set) +} + +interface SnodePoolStorage { + fun getSnodePool(): Set + fun setSnodePool(newValue: Set) +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/snode/utilities/OKHTTPUtilities.kt b/app/src/main/java/org/session/libsession/network/utilities/OKHTTPUtilities.kt similarity index 94% rename from app/src/main/java/org/session/libsession/snode/utilities/OKHTTPUtilities.kt rename to app/src/main/java/org/session/libsession/network/utilities/OKHTTPUtilities.kt index f6b44398d2..a15aadbe68 100644 --- a/app/src/main/java/org/session/libsession/snode/utilities/OKHTTPUtilities.kt +++ b/app/src/main/java/org/session/libsession/network/utilities/OKHTTPUtilities.kt @@ -1,4 +1,4 @@ -package org.session.libsession.utilities +package org.session.libsession.network.utilities import okhttp3.MultipartBody import okhttp3.Request @@ -43,7 +43,7 @@ internal fun Request.getBodyForOnionRequest(): Any? { return bodyAsData } else { val charset = body.contentType()?.charset() ?: Charsets.UTF_8 - return bodyAsData?.toString(charset) + return bodyAsData.toString(charset) } } catch (e: IOException) { return null diff --git a/app/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt b/app/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt deleted file mode 100644 index c9c8fb4785..0000000000 --- a/app/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt +++ /dev/null @@ -1,772 +0,0 @@ -package org.session.libsession.snode - -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import nl.komponents.kovenant.Deferred -import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.all -import nl.komponents.kovenant.deferred -import nl.komponents.kovenant.functional.bind -import nl.komponents.kovenant.functional.map -import okhttp3.Request -import org.session.libsession.messaging.file_server.FileServerApi -import org.session.libsession.snode.utilities.asyncPromise -import org.session.libsession.utilities.AESGCM -import org.session.libsession.utilities.AESGCM.EncryptionResult -import org.session.libsession.utilities.getBodyForOnionRequest -import org.session.libsession.utilities.getHeadersForOnionRequest -import org.session.libsignal.crypto.secureRandom -import org.session.libsignal.crypto.secureRandomOrNull -import org.session.libsignal.database.LokiAPIDatabaseProtocol -import org.session.libsignal.utilities.Base64 -import org.session.libsignal.utilities.ByteArraySlice -import org.session.libsignal.utilities.ByteArraySlice.Companion.view -import org.session.libsignal.utilities.ForkInfo -import org.session.libsignal.utilities.HTTP -import org.session.libsignal.utilities.JsonUtil -import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.Snode -import org.session.libsignal.utilities.recover -import org.session.libsignal.utilities.toHexString - -private typealias Path = List - -/** - * See the "Onion Requests" section of [The Session Whitepaper](https://arxiv.org/pdf/2002.04609.pdf) for more information. - */ - - -object OnionRequestAPI { - private var buildPathsPromise: Promise, Exception>? = null - private val database: LokiAPIDatabaseProtocol - get() = SnodeModule.shared.storage - private val pathFailureCount = mutableMapOf() - private val snodeFailureCount = mutableMapOf() - - var guardSnodes = setOf() - - private val mutablePaths = MutableStateFlow( - sanitizePaths(database.getOnionRequestPaths()) - ) - - val paths: StateFlow> get() = mutablePaths - - enum class PathStatus { - READY, // green - BUILDING, // orange - ERROR // red (offline, no path, repeated failures, etc.) - } - - private val mutablePathStatus = MutableStateFlow( - if (database.getOnionRequestPaths().isNotEmpty()) PathStatus.READY else PathStatus.ERROR - ) - - @OptIn(FlowPreview::class) - val pathStatus: StateFlow get() = mutablePathStatus - .debounce(250) - .stateIn( - scope = GlobalScope, - started = SharingStarted.Eagerly, - initialValue = PathStatus.BUILDING - ) - - private val NON_PENALIZING_STATUSES = setOf(403, 404, 406, 425) - - init { - // Listen for the changes in paths and persist it to the db - GlobalScope.launch { - mutablePaths - .drop(1) // Drop the first result where it just comes from the db - .collectLatest { - // Update status based on new paths - mutablePathStatus.value = if (it.isNotEmpty()) { - PathStatus.READY - } else { - PathStatus.ERROR - } - - if (it.isEmpty()) { - database.clearOnionRequestPaths() - } else { - database.setOnionRequestPaths(it) - } - } - } - } - - // region Settings - /** - * The number of snodes (including the guard snode) in a path. - */ - private const val pathSize = 3 - /** - * The number of times a path can fail before it's replaced. - */ - private const val pathFailureThreshold = 3 - /** - * The number of times a snode can fail before it's replaced. - */ - private const val snodeFailureThreshold = 3 - /** - * The number of guard snodes required to maintain `targetPathCount` paths. - */ - private val targetGuardSnodeCount - get() = targetPathCount // One per path - /** - * The number of paths to maintain. - */ - const val targetPathCount = 2 // A main path and a backup path for the case where the target snode is in the main path - // endregion - - class HTTPRequestFailedBlindingRequiredException(statusCode: Int, json: Map<*, *>, destination: String): HTTPRequestFailedAtDestinationException(statusCode, json, destination) - open class HTTPRequestFailedAtDestinationException(statusCode: Int, json: Map<*, *>, val destination: String) - : HTTP.HTTPRequestFailedException(statusCode, json, "HTTP request failed at destination ($destination) with status code $statusCode.") - class InsufficientSnodesException : Exception("Couldn't find enough snodes to build a path.") - - private data class OnionBuildingResult( - val guardSnode: Snode, - val finalEncryptionResult: EncryptionResult, - val destinationSymmetricKey: ByteArray - ) - - internal sealed class Destination(val description: String) { - class Snode(val snode: org.session.libsignal.utilities.Snode) : Destination("Service node ${snode.ip}:${snode.port}") - class Server(val host: String, val target: String, val x25519PublicKey: String, val scheme: String, val port: Int) : Destination("$host") - } - - // Helper to check for ANY duplicate snodes across all paths - private fun arePathsDisjoint(paths: List): Boolean { - val allNodes = paths.flatten() - val uniqueNodes = allNodes.toSet() - - // If the count of nodes equals the count of unique nodes, - // there are no duplicates anywhere. - return allNodes.size == uniqueNodes.size - } - - // If paths overlap, keep the first one, drop the rest. - private fun sanitizePaths(paths: List): List { - if (arePathsDisjoint(paths)) return paths - - Log.w("Loki", "Paths contained overlapping Snodes. Dropping backups.") - // Return only the first path, or empty list if none exist. - // This forces the app to rebuild the second path from scratch, safely. - return paths.take(1) - } - - // region Private API - /** - * Tests the given snode. The returned promise errors out if the snode is faulty; the promise is fulfilled otherwise. - */ - private fun testSnode(snode: Snode): Promise { - return GlobalScope.asyncPromise { // No need to block the shared context for this - val url = "${snode.address}:${snode.port}/get_stats/v1" - val response = HTTP.execute(HTTP.Verb.GET, url, 3).decodeToString() - val json = JsonUtil.fromJson(response, Map::class.java) - val version = json["version"] as? String - require(version != null) { "Missing snode version." } - require(version >= "2.0.7") { "Unsupported snode version: $version." } - } - } - - /** - * Finds `targetGuardSnodeCount` guard snodes to use for path building. The returned promise errors out if not - * enough (reliable) snodes are available. - */ - private fun getGuardSnodes(reusableGuardSnodes: List): Promise, Exception> { - if (guardSnodes.count() >= targetGuardSnodeCount) { - return Promise.of(guardSnodes) - } else { - Log.d("Loki", "Populating guard snode cache.") - return SnodeAPI.getRandomSnode().bind { // Just used to populate the snode pool - var unusedSnodes = SnodeAPI.snodePool.minus(reusableGuardSnodes) - val reusableGuardSnodeCount = reusableGuardSnodes.count() - if (unusedSnodes.count() < (targetGuardSnodeCount - reusableGuardSnodeCount)) { throw InsufficientSnodesException() } - fun getGuardSnode(): Promise { - val candidate = unusedSnodes.secureRandomOrNull() - ?: return Promise.ofFail(InsufficientSnodesException()) - unusedSnodes = unusedSnodes.minus(candidate) - Log.d("Loki", "Testing guard snode: $candidate.") - // Loop until a reliable guard snode is found - val deferred = deferred() - testSnode(candidate).success { - deferred.resolve(candidate) - }.fail { - deferred.reject(it) - } - return deferred.promise - } - val promises = (0 until (targetGuardSnodeCount - reusableGuardSnodeCount)).map { getGuardSnode() } - all(promises).map { guardSnodes -> - val guardSnodesAsSet = (guardSnodes + reusableGuardSnodes).toSet() - OnionRequestAPI.guardSnodes = guardSnodesAsSet - guardSnodesAsSet - } - } - } - } - - private fun updatePathsSafe(newPaths: List) { - val safe = if (arePathsDisjoint(newPaths)) { - newPaths - } else { - Log.w("Loki", "Trying to set the mutablePath with paths that have overlapping snodes... Pruning.") - sanitizePaths(newPaths) - } - - mutablePaths.value = safe - mutablePathStatus.value = if (safe.isNotEmpty()) PathStatus.READY else PathStatus.ERROR - } - - /** - * Builds and returns `targetPathCount` paths. The returned promise errors out if not - * enough (reliable) snodes are available. - */ - private fun buildPaths(reusablePaths: List): Promise, Exception> { - mutablePathStatus.value = PathStatus.BUILDING - - // making sure we have safe lists of path without repeated snodes - val safeReusablePaths = if (arePathsDisjoint(reusablePaths)) { - reusablePaths - } else { - // If the input is bad, discard the backups and keep only the main path - sanitizePaths(reusablePaths) - } - - val existingBuildPathsPromise = buildPathsPromise - if (existingBuildPathsPromise != null) { return existingBuildPathsPromise } - - Log.d("Loki", "Building onion request paths.") - - val promise = SnodeAPI.getRandomSnode().bind { // Just used to populate the snode pool - val reusableGuardSnodes = safeReusablePaths.map { it[0] } - getGuardSnodes(reusableGuardSnodes).map { guardSnodes -> - var unusedSnodes = SnodeAPI.snodePool.minus(guardSnodes).minus(safeReusablePaths.flatten()) - val reusableGuardSnodeCount = reusableGuardSnodes.count() - val pathSnodeCount = (targetGuardSnodeCount - reusableGuardSnodeCount) * pathSize - (targetGuardSnodeCount - reusableGuardSnodeCount) - if (unusedSnodes.count() < pathSnodeCount) { throw InsufficientSnodesException() } - // Don't test path snodes as this would reveal the user's IP to them - guardSnodes.minus(reusableGuardSnodes).map { guardSnode -> - val result = listOf(guardSnode) + (0 until (pathSize - 1)).map { - val pathSnode = unusedSnodes.secureRandom() - unusedSnodes = unusedSnodes.minus(pathSnode) - pathSnode - } - Log.d("Loki", "Built new onion request path: $result.") - result - } - }.map { paths -> - updatePathsSafe(paths + safeReusablePaths) - mutablePaths.value - } - } - - promise.success { - buildPathsPromise = null - mutablePathStatus.value = PathStatus.READY - } - promise.fail { - buildPathsPromise = null - // Path building failed; we’re in an error state. - mutablePathStatus.value = PathStatus.ERROR - } - - buildPathsPromise = promise - return promise - } - - /** - * Returns a `Path` to be used for building an onion request. Builds new paths as needed. - */ - private fun getPath(snodeToExclude: Snode?): Promise { - if (pathSize < 1) { throw Exception("Can't build path of size zero.") } - val paths = this.paths.value - val guardSnodes = mutableSetOf() - if (paths.isNotEmpty()) { - guardSnodes.add(paths[0][0]) - if (paths.count() >= 2) { - guardSnodes.add(paths[1][0]) - } - } - OnionRequestAPI.guardSnodes = guardSnodes - fun getPath(paths: List): Path { - return if (snodeToExclude != null) { - paths.filter { !it.contains(snodeToExclude) }.secureRandom() - } else { - paths.secureRandom() - } - } - when { - paths.count() >= targetPathCount -> { - return Promise.of(getPath(paths)) - } - paths.isNotEmpty() -> { - return if (paths.any { !it.contains(snodeToExclude) }) { - buildPaths(paths) // Re-build paths in the background - Promise.of(getPath(paths)) - } else { - buildPaths(paths).map { newPaths -> - getPath(newPaths) - } - } - } - else -> { - return buildPaths(listOf()).map { newPaths -> - getPath(newPaths) - } - } - } - } - - private fun dropGuardSnode(snode: Snode) { - guardSnodes = guardSnodes.filter { it != snode }.toSet() - } - - private fun dropSnode(snode: Snode) { - // We repair the path here because we can do it sync. In the case where we drop a whole - // path we leave the re-building up to getPath() because re-building the path in that case - // is async. - snodeFailureCount[snode] = 0 - val oldPaths = mutablePaths.value.toMutableList() - val pathIndex = oldPaths.indexOfFirst { it.contains(snode) } - if (pathIndex == -1) { return } - val path = oldPaths[pathIndex].toMutableList() - val snodeIndex = path.indexOf(snode) - if (snodeIndex == -1) { return } - path.removeAt(snodeIndex) - val unusedSnodes = SnodeAPI.snodePool.minus(oldPaths.flatten()) - if (unusedSnodes.isEmpty()) { throw InsufficientSnodesException() } - path.add(unusedSnodes.secureRandom()) - // Don't test the new snode as this would reveal the user's IP - oldPaths.removeAt(pathIndex) - val newPaths = oldPaths + listOf( path ) - updatePathsSafe(newPaths) - } - - private fun dropPath(path: Path) { - pathFailureCount[path] = 0 - val paths = mutablePaths.value.toMutableList() - val pathIndex = paths.indexOf(path) - if (pathIndex == -1) { return } - paths.removeAt(pathIndex) - updatePathsSafe(paths) - } - - /** - * Builds an onion around `payload` and returns the result. - */ - private fun buildOnionForDestination( - payload: ByteArray, - destination: Destination, - version: Version - ): Promise { - lateinit var guardSnode: Snode - lateinit var destinationSymmetricKey: ByteArray // Needed by LokiAPI to decrypt the response sent back by the destination - lateinit var encryptionResult: EncryptionResult - val snodeToExclude = when (destination) { - is Destination.Snode -> destination.snode - is Destination.Server -> null - } - return getPath(snodeToExclude).map { path -> - guardSnode = path.first() - // Encrypt in reverse order, i.e. the destination first - OnionRequestEncryption.encryptPayloadForDestination(payload, destination, version).let { r -> - destinationSymmetricKey = r.symmetricKey - // Recursively encrypt the layers of the onion (again in reverse order) - encryptionResult = r - @Suppress("NAME_SHADOWING") var path = path - var rhs = destination - fun addLayer(): EncryptionResult { - return if (path.isEmpty()) { - encryptionResult - } else { - val lhs = Destination.Snode(path.last()) - path = path.dropLast(1) - OnionRequestEncryption.encryptHop(lhs, rhs, encryptionResult).let { r -> - encryptionResult = r - rhs = lhs - addLayer() - } - } - } - addLayer() - } - }.map { OnionBuildingResult(guardSnode, encryptionResult, destinationSymmetricKey) } - } - - /** - * Sends an onion request to `destination`. Builds new paths as needed. - */ - private fun sendOnionRequest( - destination: Destination, - payload: ByteArray, - version: Version - ): Promise { - val deferred = deferred() - var guardSnode: Snode? = null - buildOnionForDestination(payload, destination, version).success { result -> - guardSnode = result.guardSnode - val nonNullGuardSnode = result.guardSnode - val url = "${nonNullGuardSnode.address}:${nonNullGuardSnode.port}/onion_req/v2" - val finalEncryptionResult = result.finalEncryptionResult - val onion = finalEncryptionResult.ciphertext - if (destination is Destination.Server && onion.count().toDouble() > 0.75 * FileServerApi.MAX_FILE_SIZE.toDouble()) { - Log.d("Loki", "Approaching request size limit: ~${onion.count()} bytes.") - } - @Suppress("NAME_SHADOWING") val parameters = mapOf( - "ephemeral_key" to finalEncryptionResult.ephemeralPublicKey.toHexString() - ) - val body: ByteArray - try { - body = OnionRequestEncryption.encode(onion, parameters) - } catch (exception: Exception) { - return@success deferred.reject(exception) - } - val destinationSymmetricKey = result.destinationSymmetricKey - GlobalScope.launch { - try { - val response = HTTP.execute(HTTP.Verb.POST, url, body) - handleResponse(response, destinationSymmetricKey, destination, version, deferred) - } catch (exception: Exception) { - deferred.reject(exception) - } - } - }.fail { exception -> - deferred.reject(exception) - } - val promise = deferred.promise - promise.fail { exception -> - if (exception is HTTP.HTTPRequestFailedException) { - val checkedGuardSnode = guardSnode - val path = - if (checkedGuardSnode == null) null - else paths.value.firstOrNull { it.contains(checkedGuardSnode) } - - fun handleUnspecificError() { - if (path == null) { return } - var pathFailureCount = OnionRequestAPI.pathFailureCount[path] ?: 0 - pathFailureCount += 1 - if (pathFailureCount >= pathFailureThreshold) { - guardSnode?.let { dropGuardSnode(it) } - path.forEach { snode -> - @Suppress("ThrowableNotThrown") - SnodeAPI.handleSnodeError(exception.statusCode, exception.json, snode, null) // Intentionally don't throw - } - dropPath(path) - } else { - OnionRequestAPI.pathFailureCount[path] = pathFailureCount - } - } - val json = exception.json - val message = json?.get("result") as? String - val prefix = "Next node not found: " - if (message != null && message.startsWith(prefix)) { - val ed25519PublicKey = message.substringAfter(prefix) - val snode = path?.firstOrNull { it.publicKeySet!!.ed25519Key == ed25519PublicKey } - if (snode != null) { - var snodeFailureCount = OnionRequestAPI.snodeFailureCount[snode] ?: 0 - snodeFailureCount += 1 - if (snodeFailureCount >= snodeFailureThreshold) { - @Suppress("ThrowableNotThrown") - SnodeAPI.handleSnodeError(exception.statusCode, json, snode, null) // Intentionally don't throw - try { - dropSnode(snode) - } catch (exception: Exception) { - handleUnspecificError() - } - } else { - OnionRequestAPI.snodeFailureCount[snode] = snodeFailureCount - } - } else { - handleUnspecificError() - } - } else if(exception.statusCode in NON_PENALIZING_STATUSES){ - // error codes that shouldn't penalize our path or drop snodes - // 404 is probably file server missing a file, don't rebuild path or mark a snode as bad here - Log.d("Loki","Request returned a non penalizing code ${exception.statusCode} with message: $message") - } - // we do not want to penalize the path/nodes when: - // - the exit node reached the server but the destination returned 5xx or 400 - // - the exit node couldn't reach its destination with a 5xx or 400, but the destination was a community (which we can know from the server's name being in the error message) - else if (destination is Destination.Server && - (exception.statusCode in 500..504 || exception.statusCode == 400) && - (exception is HTTPRequestFailedAtDestinationException || exception.body?.contains(destination.host) == true)) { - Log.d("Loki","Destination server error - Non path penalizing. Request returned code ${exception.statusCode} with message: $message") - } else if (message == "Loki Server error") { - Log.d("Loki", "message was $message") - } else { // Only drop snode/path if not receiving above two exception cases - handleUnspecificError() - } - } - } - return promise - } - // endregion - - // region Internal API - /** - * Sends an onion request to `snode`. Builds new paths as needed. - */ - internal fun sendOnionRequest( - method: Snode.Method, - parameters: Map<*, *>, - snode: Snode, - version: Version, - publicKey: String? = null - ): Promise { - val payload = mapOf( - "method" to method.rawValue, - "params" to parameters - ) - val payloadData = JsonUtil.toJson(payload).toByteArray() - return sendOnionRequest(Destination.Snode(snode), payloadData, version).recover { exception -> - val error = when (exception) { - is HTTPRequestFailedAtDestinationException -> SnodeAPI.handleSnodeError(exception.statusCode, exception.json, snode, publicKey) - is HTTP.HTTPRequestFailedException -> SnodeAPI.handleSnodeError(exception.statusCode, exception.json, snode, publicKey) - else -> null - } - if (error != null) { throw error } - throw exception - } - } - - /** - * Sends an onion request to `server`. Builds new paths as needed. - * - * `publicKey` is the hex encoded public key of the user the call is associated with. This is needed for swarm cache maintenance. - */ - fun sendOnionRequest( - request: Request, - server: String, - x25519PublicKey: String, - version: Version = Version.V4 - ): Promise { - val url = request.url - val payload = generatePayload(request, server, version) - val destination = Destination.Server(url.host, version.value, x25519PublicKey, url.scheme, url.port) - return sendOnionRequest(destination, payload, version).recover { exception -> - Log.d("Loki", "Couldn't reach server: $url due to error: $exception.") - throw exception - } - } - - private fun generatePayload(request: Request, server: String, version: Version): ByteArray { - val headers = request.getHeadersForOnionRequest().toMutableMap() - val url = request.url - val urlAsString = url.toString() - val body = request.getBodyForOnionRequest() ?: "null" - val endpoint = when { - server.count() < urlAsString.count() -> urlAsString.substringAfter(server) - else -> "" - } - return if (version == Version.V4) { - if (request.body != null && - headers.keys.find { it.equals("Content-Type", true) } == null) { - headers["Content-Type"] = "application/json" - } - val requestPayload = mapOf( - "endpoint" to endpoint, - "method" to request.method, - "headers" to headers - ) - val requestData = JsonUtil.toJson(requestPayload).toByteArray() - val prefixData = "l${requestData.size}:".toByteArray(Charsets.US_ASCII) - val suffixData = "e".toByteArray(Charsets.US_ASCII) - if (request.body != null) { - val bodyData = if (body is ByteArray) body else body.toString().toByteArray() - val bodyLengthData = "${bodyData.size}:".toByteArray(Charsets.US_ASCII) - prefixData + requestData + bodyLengthData + bodyData + suffixData - } else { - prefixData + requestData + suffixData - } - } else { - val payload = mapOf( - "body" to body, - "endpoint" to endpoint.removePrefix("/"), - "method" to request.method, - "headers" to headers - ) - JsonUtil.toJson(payload).toByteArray() - } - } - - private fun handleResponse( - response: ByteArray, - destinationSymmetricKey: ByteArray, - destination: Destination, - version: Version, - deferred: Deferred - ) { - if (version == Version.V4) { - try { - if (response.size <= AESGCM.ivSize) return deferred.reject(Exception("Invalid response")) - // The data will be in the form of `l123:jsone` or `l123:json456:bodye` so we need to break the data into - // parts to properly process it - val plaintext = AESGCM.decrypt(response, symmetricKey = destinationSymmetricKey) - if (!byteArrayOf(plaintext.first()).contentEquals("l".toByteArray())) return deferred.reject(Exception("Invalid response")) - val infoSepIdx = plaintext.indexOfFirst { byteArrayOf(it).contentEquals(":".toByteArray()) } - val infoLenSlice = plaintext.slice(1 until infoSepIdx) - val infoLength = infoLenSlice.toByteArray().toString(Charsets.US_ASCII).toIntOrNull() - if (infoLenSlice.size <= 1 || infoLength == null) return deferred.reject(Exception("Invalid response")) - val infoStartIndex = "l$infoLength".length + 1 - val infoEndIndex = infoStartIndex + infoLength - val info = plaintext.slice(infoStartIndex until infoEndIndex) - val responseInfo = JsonUtil.fromJson(info.toByteArray(), Map::class.java) - when (val statusCode = responseInfo["code"].toString().toInt()) { - // Custom handle a clock out of sync error (v4 returns '425' but included the '406' just in case) - 406, 425 -> { - @Suppress("NAME_SHADOWING") - val exception = HTTPRequestFailedAtDestinationException( - statusCode, - mapOf("result" to "Your clock is out of sync with the service node network."), - destination.description - ) - return deferred.reject(exception) - } - // Handle error status codes - !in 200..299 -> { - val responseBody = if (destination is Destination.Server && statusCode == 400) plaintext.getBody(infoLength, infoEndIndex) else null - val requireBlinding = "Invalid authentication: this server requires the use of blinded ids" - val exception = if (responseBody != null && responseBody.decodeToString() == requireBlinding) { - HTTPRequestFailedBlindingRequiredException(400, responseInfo, destination.description) - } else HTTPRequestFailedAtDestinationException( - statusCode, - responseInfo, - destination.description - ) - - return deferred.reject(exception) - } - } - - val responseBody = plaintext.getBody(infoLength, infoEndIndex) - - // If there is no data in the response, i.e. only `l123:jsone`, then just return the ResponseInfo - if (responseBody.isEmpty()) { - return deferred.resolve(OnionResponse(responseInfo, null)) - } - return deferred.resolve(OnionResponse(responseInfo, responseBody)) - } catch (exception: Exception) { - deferred.reject(exception) - } - } else { - val json = try { - JsonUtil.fromJson(response, Map::class.java) - } catch (exception: Exception) { - mapOf( "result" to response.decodeToString()) - } - val base64EncodedIVAndCiphertext = json["result"] as? String ?: return deferred.reject(Exception("Invalid JSON")) - val ivAndCiphertext = Base64.decode(base64EncodedIVAndCiphertext) - try { - val plaintext = AESGCM.decrypt( - ivAndCiphertext, - symmetricKey = destinationSymmetricKey - ) - try { - @Suppress("NAME_SHADOWING") val json = - JsonUtil.fromJson(plaintext.toString(Charsets.UTF_8), Map::class.java) - val statusCode = json["status_code"] as? Int ?: json["status"] as Int - when { - statusCode == 406 -> { - @Suppress("NAME_SHADOWING") - val body = - mapOf("result" to "Your clock is out of sync with the service node network.") - val exception = HTTPRequestFailedAtDestinationException( - statusCode, - body, - destination.description - ) - return deferred.reject(exception) - } - json["body"] != null -> { - @Suppress("NAME_SHADOWING") - val body = if (json["body"] is Map<*, *>) { - json["body"] as Map<*, *> - } else { - val bodyAsString = json["body"] as String - JsonUtil.fromJson(bodyAsString, Map::class.java) - } - - if (body.containsKey("hf")) { - @Suppress("UNCHECKED_CAST") - val currentHf = body["hf"] as List - if (currentHf.size < 2) { - Log.e("Loki", "Response contains fork information but doesn't have a hard and soft number") - } else { - val hf = currentHf[0] - val sf = currentHf[1] - val newForkInfo = ForkInfo(hf, sf) - if (newForkInfo > SnodeAPI.forkInfo) { - SnodeAPI.forkInfo = ForkInfo(hf,sf) - } else if (newForkInfo < SnodeAPI.forkInfo) { - Log.w("Loki", "Got a new snode info fork version that was $newForkInfo, less than current known ${SnodeAPI.forkInfo}") - } - } - } - if (statusCode != 200) { - val exception = HTTPRequestFailedAtDestinationException( - statusCode, - body, - destination.description - ) - return deferred.reject(exception) - } - deferred.resolve(OnionResponse(body, JsonUtil.toJson(body).toByteArray().view())) - } - else -> { - if (statusCode != 200) { - val exception = HTTPRequestFailedAtDestinationException( - statusCode, - json, - destination.description - ) - return deferred.reject(exception) - } - deferred.resolve(OnionResponse(json, JsonUtil.toJson(json).toByteArray().view())) - } - } - } catch (exception: Exception) { - deferred.reject(Exception("Invalid JSON: ${plaintext.toString(Charsets.UTF_8)}.")) - } - } catch (exception: Exception) { - deferred.reject(exception) - } - } - } - - private fun ByteArray.getBody(infoLength: Int, infoEndIndex: Int): ByteArraySlice { - // If there is no data in the response, i.e. only `l123:jsone`, then just return the ResponseInfo - val infoLengthStringLength = infoLength.toString().length - if (size <= infoLength + infoLengthStringLength + 2/*l and e bytes*/) { - return ByteArraySlice.EMPTY - } - // Extract the response data as well - val dataSlice = view(infoEndIndex + 1 until size - 1) - val dataSepIdx = dataSlice.asList().indexOfFirst { it.toInt() == ':'.code } - return dataSlice.view(dataSepIdx + 1 until dataSlice.len) - } - - // endregion -} - -enum class Version(val value: String) { - V2("/loki/v2/lsrpc"), - V3("/loki/v3/lsrpc"), - V4("/oxen/v4/lsrpc"); -} - -data class OnionResponse( - val info: Map<*, *>, - val body: ByteArraySlice? = null -) { - val code: Int? get() = info["code"] as? Int - val message: String? get() = info["message"] as? String -} diff --git a/app/src/main/java/org/session/libsession/snode/OwnedSwarmAuth.kt b/app/src/main/java/org/session/libsession/snode/OwnedSwarmAuth.kt deleted file mode 100644 index fa906fc259..0000000000 --- a/app/src/main/java/org/session/libsession/snode/OwnedSwarmAuth.kt +++ /dev/null @@ -1,34 +0,0 @@ -package org.session.libsession.snode - -import network.loki.messenger.libsession_util.ED25519 -import org.session.libsignal.utilities.AccountId -import org.session.libsignal.utilities.Base64 - -/** - * A [SwarmAuth] that signs message using a single ED25519 private key. - * - * This should be used for the owner of an account, like a user or a group admin. - */ -class OwnedSwarmAuth( - override val accountId: AccountId, - override val ed25519PublicKeyHex: String?, - val ed25519PrivateKey: ByteArray, -) : SwarmAuth { - override fun sign(data: ByteArray): Map { - val signature = Base64.encodeBytes(ED25519.sign(ed25519PrivateKey = ed25519PrivateKey, message = data)) - - return buildMap { - put("signature", signature) - } - } - - companion object { - fun ofClosedGroup(groupAccountId: AccountId, adminKey: ByteArray): OwnedSwarmAuth { - return OwnedSwarmAuth( - accountId = groupAccountId, - ed25519PublicKeyHex = null, - ed25519PrivateKey = adminKey - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/snode/SnodeAPI.kt b/app/src/main/java/org/session/libsession/snode/SnodeAPI.kt deleted file mode 100644 index b1a39c7f7a..0000000000 --- a/app/src/main/java/org/session/libsession/snode/SnodeAPI.kt +++ /dev/null @@ -1,933 +0,0 @@ -@file:Suppress("NAME_SHADOWING") - -package org.session.libsession.snode - -import android.os.SystemClock -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.launch -import kotlinx.coroutines.selects.onTimeout -import kotlinx.coroutines.selects.select -import kotlinx.serialization.DeserializationStrategy -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.decodeFromStream -import network.loki.messenger.libsession_util.ED25519 -import network.loki.messenger.libsession_util.Hash -import network.loki.messenger.libsession_util.SessionEncrypt -import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.functional.bind -import nl.komponents.kovenant.functional.map -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.snode.model.BatchResponse -import org.session.libsession.snode.model.StoreMessageResponse -import org.session.libsession.snode.utilities.asyncPromise -import org.session.libsession.snode.utilities.await -import org.session.libsession.snode.utilities.retrySuspendAsPromise -import org.session.libsession.utilities.Environment -import org.session.libsession.utilities.mapValuesNotNull -import org.session.libsession.utilities.toByteArray -import org.session.libsignal.crypto.secureRandom -import org.session.libsignal.crypto.shuffledRandom -import org.session.libsignal.database.LokiAPIDatabaseProtocol -import org.session.libsignal.utilities.Base64 -import org.session.libsignal.utilities.HTTP -import org.session.libsignal.utilities.Hex -import org.session.libsignal.utilities.JsonUtil -import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.Snode -import org.session.libsignal.utilities.prettifiedDescription -import org.session.libsignal.utilities.retryWithUniformInterval -import java.util.Locale -import kotlin.collections.component1 -import kotlin.collections.component2 -import kotlin.collections.set -import kotlin.properties.Delegates.observable - -object SnodeAPI { - internal val database: LokiAPIDatabaseProtocol - get() = SnodeModule.shared.storage - - private var snodeFailureCount: MutableMap = mutableMapOf() - - // the list of "generic" nodes we use to make non swarm specific api calls - internal var snodePool: Set - get() = database.getSnodePool() - set(newValue) { database.setSnodePool(newValue) } - - @Deprecated("Use a dependency injected SnodeClock.currentTimeMills() instead") - @JvmStatic - val nowWithOffset - get() = MessagingModuleConfiguration.shared.clock.currentTimeMills() - - internal var forkInfo by observable(database.getForkInfo()) { _, oldValue, newValue -> - if (newValue > oldValue) { - Log.d("Loki", "Setting new fork info new: $newValue, old: $oldValue") - database.setForkInfo(newValue) - } - } - - // Settings - private const val maxRetryCount = 6 - private const val minimumSnodePoolCount = 12 - private const val minimumSwarmSnodeCount = 3 - // Use port 4433 to enforce pinned certificates - private val seedNodePort = 4443 - - private val seedNodePool = when (SnodeModule.shared.environment) { - Environment.DEV_NET -> setOf("http://sesh-net.local:1280") - Environment.TEST_NET -> setOf("http://public.loki.foundation:38157") - Environment.MAIN_NET -> setOf( - "https://seed1.getsession.org:$seedNodePort", - "https://seed2.getsession.org:$seedNodePort", - "https://seed3.getsession.org:$seedNodePort", - ) - } - - private const val snodeFailureThreshold = 3 - private const val useOnionRequests = true - - const val KEY_BODY = "body" - const val KEY_CODE = "code" - const val KEY_RESULTS = "results" - private const val KEY_IP = "public_ip" - private const val KEY_PORT = "storage_port" - private const val KEY_X25519 = "pubkey_x25519" - private const val KEY_ED25519 = "pubkey_ed25519" - private const val KEY_VERSION = "storage_server_version" - - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - - // Error - sealed class Error(val description: String) : Exception(description) { - object Generic : Error("An error occurred.") - object ClockOutOfSync : Error("Your clock is out of sync with the Service Node network.") - object NoKeyPair : Error("Missing user key pair.") - object SigningFailed : Error("Couldn't sign verification data.") - - // ONS - object DecryptionFailed : Error("Couldn't decrypt ONS name.") - object HashingFailed : Error("Couldn't compute ONS name hash.") - object ValidationFailed : Error("ONS name validation failed.") - } - - // Batch - data class SnodeBatchRequestInfo( - val method: String, - val params: Map, - @Transient - val namespace: Int?, - ) // assume signatures, pubkey and namespaces are attached in parameters if required - - // Internal API - internal fun invoke( - method: Snode.Method, - snode: Snode, - parameters: Map, - publicKey: String? = null, - version: Version = Version.V3 - ): RawResponsePromise = when { - useOnionRequests -> OnionRequestAPI.sendOnionRequest(method, parameters, snode, version, publicKey).map { - JsonUtil.fromJson(it.body ?: throw Error.Generic, Map::class.java) - } - - else -> scope.asyncPromise { - HTTP.execute( - HTTP.Verb.POST, - url = "${snode.address}:${snode.port}/storage_rpc/v1", - parameters = buildMap { - this["method"] = method.rawValue - this["params"] = parameters - } - ).toString().let { - JsonUtil.fromJson(it, Map::class.java) - } - }.fail { e -> - when (e) { - is HTTP.HTTPRequestFailedException -> handleSnodeError(e.statusCode, e.json, snode, publicKey) - else -> Log.d("Loki", "Unhandled exception: $e.") - } - } - } - - private suspend fun invokeSuspend( - method: Snode.Method, - snode: Snode, - parameters: Map, - responseDeserializationStrategy: DeserializationStrategy, - publicKey: String? = null, - version: Version = Version.V3 - ): Res = when { - useOnionRequests -> { - val resp = OnionRequestAPI.sendOnionRequest(method, parameters, snode, version, publicKey).await() - (resp.body ?: throw Error.Generic).inputStream().use { inputStream -> - MessagingModuleConfiguration.shared.json.decodeFromStream( - deserializer = responseDeserializationStrategy, - stream = inputStream - ) - } - } - - else -> HTTP.execute( - HTTP.Verb.POST, - url = "${snode.address}:${snode.port}/storage_rpc/v1", - parameters = buildMap { - this["method"] = method.rawValue - this["params"] = parameters - } - ).toString().let { - MessagingModuleConfiguration.shared.json.decodeFromString( - deserializer = responseDeserializationStrategy, - string = it - ) - } - } - - private val GET_RANDOM_SNODE_PARAMS = buildMap { - this["method"] = "get_n_service_nodes" - this["params"] = buildMap { - this["active_only"] = true - this["fields"] = sequenceOf(KEY_IP, KEY_PORT, KEY_X25519, KEY_ED25519, KEY_VERSION).associateWith { true } - } - } - - internal fun getRandomSnode(): Promise = - snodePool.takeIf { it.size >= minimumSnodePoolCount }?.secureRandom()?.let { Promise.of(it) } ?: scope.asyncPromise { - val target = seedNodePool.random() - Log.d("Loki", "Populating snode pool using: $target.") - val url = "$target/json_rpc" - val response = HTTP.execute(HTTP.Verb.POST, url, GET_RANDOM_SNODE_PARAMS, useSeedNodeConnection = true) - val json = runCatching { JsonUtil.fromJson(response, Map::class.java) }.getOrNull() - ?: buildMap { this["result"] = response.toString() } - val intermediate = json["result"] as? Map<*, *> ?: throw Error.Generic - .also { Log.d("Loki", "Failed to update snode pool, intermediate was null.") } - val rawSnodes = intermediate["service_node_states"] as? List<*> ?: throw Error.Generic - .also { Log.d("Loki", "Failed to update snode pool, rawSnodes was null.") } - - rawSnodes.asSequence().mapNotNull { it as? Map<*, *> }.mapNotNull { rawSnode -> - createSnode( - address = rawSnode[KEY_IP] as? String, - port = rawSnode[KEY_PORT] as? Int, - ed25519Key = rawSnode[KEY_ED25519] as? String, - x25519Key = rawSnode[KEY_X25519] as? String, - version = (rawSnode[KEY_VERSION] as? List<*>) - ?.filterIsInstance() - ?.let(Snode::Version) - ).also { if (it == null) Log.d("Loki", "Failed to parse: ${rawSnode.prettifiedDescription()}.") } - }.toSet().also { - Log.d("Loki", "Persisting snode pool to database.") - snodePool = it - }.takeUnless { it.isEmpty() }?.secureRandom() ?: throw SnodeAPI.Error.Generic - } - - private fun createSnode(address: String?, port: Int?, ed25519Key: String?, x25519Key: String?, version: Snode.Version? = Snode.Version.ZERO): Snode? { - return Snode( - address?.takeUnless { it == "0.0.0.0" }?.let { "https://$it" } ?: return null, - port ?: return null, - Snode.KeySet(ed25519Key ?: return null, x25519Key ?: return null), - version ?: return null - ) - } - - internal fun dropSnodeFromSwarmIfNeeded(snode: Snode, publicKey: String) { - database.getSwarm(publicKey)?.takeIf { snode in it }?.let { - database.setSwarm(publicKey, it - snode) - } - } - - fun getSingleTargetSnode(publicKey: String): Promise { - // SecureRandom should be cryptographically secure - return getSwarm(publicKey).map { it.shuffledRandom().random() } - } - - // Public API - suspend fun getAccountID(onsName: String): String { - val validationCount = 3 - val accountIDByteCount = 33 - // Hash the ONS name using BLAKE2b - val onsName = onsName.lowercase(Locale.US) - // Ask 3 different snodes for the Account ID associated with the given name hash - val parameters = buildMap { - this["endpoint"] = "ons_resolve" - this["params"] = buildMap { - this["type"] = 0 - this["name_hash"] = Base64.encodeBytes(Hash.hash32(onsName.toByteArray())) - } - } - - return List(validationCount) { - scope.async { - retryWithUniformInterval( - maxRetryCount = maxRetryCount, - ) { - val snode = getRandomSnode().await() - invoke(Snode.Method.OxenDaemonRPCCall, snode, parameters).await() - } - } - }.awaitAll().map { json -> - val intermediate = json["result"] as? Map<*, *> ?: throw Error.Generic - val hexEncodedCiphertext = intermediate["encrypted_value"] as? String ?: throw Error.Generic - val ciphertext = Hex.fromStringCondensed(hexEncodedCiphertext) - val nonce = (intermediate["nonce"] as? String)?.let(Hex::fromStringCondensed) - SessionEncrypt.decryptOnsResponse( - lowercaseName = onsName, - ciphertext = ciphertext, - nonce = nonce - ) - }.takeIf { it.size == validationCount && it.toSet().size == 1 }?.first() - ?: throw Error.ValidationFailed - } - - // the list of snodes that represent the swarm for that pubkey - fun getSwarm(publicKey: String): Promise, Exception> = - database.getSwarm(publicKey)?.takeIf { it.size >= minimumSwarmSnodeCount }?.let(Promise.Companion::of) - ?: getRandomSnode().bind { - invoke(Snode.Method.GetSwarm, it, parameters = buildMap { this["pubKey"] = publicKey }, publicKey) - }.map { - parseSnodes(it).toSet() - }.success { - database.setSwarm(publicKey, it) - } - - /** - * Fetch swarm nodes for the specific public key. - * - * Note: this differs from [getSwarm] in that it doesn't store the swarm nodes in the database. - * This always fetches from network. - */ - suspend fun fetchSwarmNodes(publicKey: String): List { - val randomNode = getRandomSnode().await() - val response = invoke( - method = Snode.Method.GetSwarm, - snode = randomNode, parameters = buildMap { this["pubKey"] = publicKey }, - publicKey = publicKey - ).await() - - return parseSnodes(response) - } - - /** - * Build parameters required to call authenticated storage API. - * - * @param auth The authentication data required to sign the request - * @param namespace The namespace of the messages you want to retrieve. Null if not relevant. - * @param verificationData A function that returns the data to be signed. The function takes the namespace text and timestamp as arguments. - * @param timestamp The timestamp to be used in the request. Default is the current time. - * @param builder A lambda that allows the user to add additional parameters to the request. - */ - private fun buildAuthenticatedParameters( - auth: SwarmAuth, - namespace: Int?, - verificationData: ((namespaceText: String, timestamp: Long) -> Any)? = null, - timestamp: Long = nowWithOffset, - builder: MutableMap.() -> Unit = {} - ): Map { - return buildMap { - // Build user provided parameter first - this.builder() - - if (verificationData != null) { - // Namespace shouldn't be in the verification data if it's null or 0. - val namespaceText = when (namespace) { - null, 0 -> "" - else -> namespace.toString() - } - - val verifyData = when (val verify = verificationData(namespaceText, timestamp)) { - is String -> verify.toByteArray() - is ByteArray -> verify - else -> throw IllegalArgumentException("verificationData must return a String or ByteArray") - } - - putAll(auth.sign(verifyData)) - put("timestamp", timestamp) - } - - put("pubkey", auth.accountId.hexString) - if (namespace != null && namespace != 0) { - put("namespace", namespace) - } - - auth.ed25519PublicKeyHex?.let { put("pubkey_ed25519", it) } - } - } - - fun buildAuthenticatedStoreBatchInfo( - namespace: Int, - message: SnodeMessage, - auth: SwarmAuth, - ): SnodeBatchRequestInfo { - check(message.recipient == auth.accountId.hexString) { - "Message sent to ${message.recipient} but authenticated with ${auth.accountId.hexString}" - } - - val params = buildAuthenticatedParameters( - namespace = namespace, - auth = auth, - verificationData = { ns, t -> "${Snode.Method.SendMessage.rawValue}$ns$t" }, - ) { - putAll(message.toJSON()) - } - - return SnodeBatchRequestInfo( - Snode.Method.SendMessage.rawValue, - params, - namespace - ) - } - - fun buildAuthenticatedUnrevokeSubKeyBatchRequest( - groupAdminAuth: OwnedSwarmAuth, - subAccountTokens: List, - ): SnodeBatchRequestInfo { - val params = buildAuthenticatedParameters( - namespace = null, - auth = groupAdminAuth, - verificationData = { _, t -> - subAccountTokens.fold( - "${Snode.Method.UnrevokeSubAccount.rawValue}$t".toByteArray() - ) { acc, subAccount -> acc + subAccount } - } - ) { - put("unrevoke", subAccountTokens.map(Base64::encodeBytes)) - } - - return SnodeBatchRequestInfo( - Snode.Method.UnrevokeSubAccount.rawValue, - params, - null - ) - } - - fun buildAuthenticatedRevokeSubKeyBatchRequest( - groupAdminAuth: OwnedSwarmAuth, - subAccountTokens: List, - ): SnodeBatchRequestInfo { - val params = buildAuthenticatedParameters( - namespace = null, - auth = groupAdminAuth, - verificationData = { _, t -> - subAccountTokens.fold( - "${Snode.Method.RevokeSubAccount.rawValue}$t".toByteArray() - ) { acc, subAccount -> acc + subAccount } - } - ) { - put("revoke", subAccountTokens.map(Base64::encodeBytes)) - } - - return SnodeBatchRequestInfo( - Snode.Method.RevokeSubAccount.rawValue, - params, - null - ) - } - - /** - * Message hashes can be shared across multiple namespaces (for a single public key destination) - * @param publicKey the destination's identity public key to delete from (05...) - * @param ed25519PubKey the destination's ed25519 public key to delete from. Only required for user messages. - * @param messageHashes a list of stored message hashes to delete from all namespaces on the server - * @param required indicates that *at least one* message in the list is deleted from the server, otherwise it will return 404 - */ - fun buildAuthenticatedDeleteBatchInfo( - auth: SwarmAuth, - messageHashes: List, - required: Boolean = false - ): SnodeBatchRequestInfo { - val params = buildAuthenticatedParameters( - namespace = null, - auth = auth, - verificationData = { _, _ -> - buildString { - append(Snode.Method.DeleteMessage.rawValue) - messageHashes.forEach(this::append) - } - } - ) { - put("messages", messageHashes) - put("required", required) - } - - return SnodeBatchRequestInfo( - Snode.Method.DeleteMessage.rawValue, - params, - null - ) - } - - fun buildAuthenticatedRetrieveBatchRequest( - auth: SwarmAuth, - lastHash: String?, - namespace: Int = 0, - maxSize: Int? = null - ): SnodeBatchRequestInfo { - val params = buildAuthenticatedParameters( - namespace = namespace, - auth = auth, - verificationData = { ns, t -> "${Snode.Method.Retrieve.rawValue}$ns$t" }, - ) { - put("last_hash", lastHash.orEmpty()) - if (maxSize != null) { - put("max_size", maxSize) - } - } - - return SnodeBatchRequestInfo( - Snode.Method.Retrieve.rawValue, - params, - namespace - ) - } - - fun buildAuthenticatedAlterTtlBatchRequest( - auth: SwarmAuth, - messageHashes: List, - newExpiry: Long, - shorten: Boolean = false, - extend: Boolean = false - ): SnodeBatchRequestInfo { - val params = - buildAlterTtlParams(auth, messageHashes, newExpiry, extend, shorten) - return SnodeBatchRequestInfo( - Snode.Method.Expire.rawValue, - params, - null - ) - } - - private data class RequestInfo( - val snode: Snode, - val publicKey: String, - val request: SnodeBatchRequestInfo, - val responseType: DeserializationStrategy<*>, - val callback: SendChannel>, - val requestTime: Long = SystemClock.elapsedRealtime(), - ) - - private val batchedRequestsSender: SendChannel - - init { - val batchRequests = Channel() - batchedRequestsSender = batchRequests - - val batchWindowMills = 100L - - data class BatchKey(val snodeAddress: String, val publicKey: String) - - scope.launch { - val batches = hashMapOf>() - - while (true) { - val batch = select?> { - // If we receive a request, add it to the batch - batchRequests.onReceive { - batches.getOrPut(BatchKey(it.snode.address, it.publicKey)) { mutableListOf() }.add(it) - null - } - - // If we have anything in the batch, look for the one that is about to expire - // and wait for it to expire, remove it from the batches and send it for - // processing. - if (batches.isNotEmpty()) { - val earliestBatch = batches.minBy { it.value.first().requestTime } - val deadline = earliestBatch.value.first().requestTime + batchWindowMills - onTimeout( - timeMillis = (deadline - SystemClock.elapsedRealtime()).coerceAtLeast(0) - ) { - batches.remove(earliestBatch.key) - } - } - } - - if (batch != null) { - launch batch@{ - val snode = batch.first().snode - val responses = try { - getBatchResponse( - snode = snode, - publicKey = batch.first().publicKey, - requests = batch.map { it.request }, - sequence = false - ) - } catch (e: Exception) { - for (req in batch) { - runCatching { - req.callback.send(Result.failure(e)) - } - } - return@batch - } - - // For each response, parse the result, match it with the request then send - // back through the request's callback. - for ((req, resp) in batch.zip(responses.results)) { - val result = runCatching { - if (!resp.isSuccessful) { - throw BatchResponse.Error(resp) - } - - MessagingModuleConfiguration.shared.json.decodeFromJsonElement( - req.responseType, resp.body)!! - } - - runCatching { - req.callback.send(result) - } - } - - // Close all channels in the requests just in case we don't have paired up - // responses. - for (req in batch) { - req.callback.close() - } - } - } - } - } - } - - suspend fun sendBatchRequest( - snode: Snode, - publicKey: String, - request: SnodeBatchRequestInfo, - responseType: DeserializationStrategy, - ): T { - val callback = Channel>(capacity = 1) - @Suppress("UNCHECKED_CAST") - batchedRequestsSender.send(RequestInfo( - snode = snode, - publicKey = publicKey, - request = request, - responseType = responseType, - callback = callback as SendChannel - )) - try { - return callback.receive().getOrThrow() - } catch (e: CancellationException) { - // Close the channel if the coroutine is cancelled, so the batch processing won't - // handle this one (best effort only) - callback.close() - throw e - } - } - - suspend fun sendBatchRequest( - snode: Snode, - publicKey: String, - request: SnodeBatchRequestInfo, - ): JsonElement { - return sendBatchRequest(snode, publicKey, request, JsonElement.serializer()) - } - - suspend fun getBatchResponse( - snode: Snode, - publicKey: String, - requests: List, - sequence: Boolean = false - ): BatchResponse { - return invokeSuspend( - method = if (sequence) Snode.Method.Sequence else Snode.Method.Batch, - snode = snode, - parameters = mapOf("requests" to requests), - responseDeserializationStrategy = BatchResponse.serializer(), - publicKey = publicKey - ).also { resp -> - // If there's a unsuccessful response, go through specific logic to handle - // potential snode errors. - val firstError = resp.results.firstOrNull { !it.isSuccessful } - if (firstError != null) { - handleSnodeError( - statusCode = firstError.code, - json = if (firstError.body is JsonObject) { - JsonUtil.fromJson(firstError.body.toString(), Map::class.java) - } else { - null - }, - snode = snode, - publicKey = publicKey - ) - } - } - } - - fun alterTtl( - auth: SwarmAuth, - messageHashes: List, - newExpiry: Long, - extend: Boolean = false, - shorten: Boolean = false - ): RawResponsePromise = scope.retrySuspendAsPromise(maxRetryCount) { - val params = buildAlterTtlParams(auth, messageHashes, newExpiry, extend, shorten) - val snode = getSingleTargetSnode(auth.accountId.hexString).await() - invoke(Snode.Method.Expire, snode, params, auth.accountId.hexString).await() - } - - private fun buildAlterTtlParams( - auth: SwarmAuth, - messageHashes: List, - newExpiry: Long, - extend: Boolean = false, - shorten: Boolean = false - ): Map { - val shortenOrExtend = if (extend) "extend" else if (shorten) "shorten" else "" - - return buildAuthenticatedParameters( - namespace = null, - auth = auth, - verificationData = { _, _ -> - buildString { - append("expire") - append(shortenOrExtend) - append(newExpiry.toString()) - messageHashes.forEach(this::append) - } - } - ) { - this["expiry"] = newExpiry - this["messages"] = messageHashes - when { - extend -> this["extend"] = true - shorten -> this["shorten"] = true - } - } - } - - fun getNetworkTime(snode: Snode): Promise, Exception> = - invoke(Snode.Method.Info, snode, emptyMap()).map { rawResponse -> - val timestamp = rawResponse["timestamp"] as? Long ?: -1 - snode to timestamp - } - - /** - * Note: After this method returns, [auth] will not be used by any of async calls and it's afe - * for the caller to clean up the associated resources if needed. - */ - suspend fun sendMessage( - message: SnodeMessage, - auth: SwarmAuth?, - namespace: Int = 0 - ): StoreMessageResponse { - return retryWithUniformInterval(maxRetryCount = maxRetryCount) { - val params = if (auth != null) { - check(auth.accountId.hexString == message.recipient) { - "Message sent to ${message.recipient} but authenticated with ${auth.accountId.hexString}" - } - - val timestamp = nowWithOffset - - buildAuthenticatedParameters( - auth = auth, - namespace = namespace, - verificationData = { ns, t -> "${Snode.Method.SendMessage.rawValue}$ns$t" }, - timestamp = timestamp - ) { - put("sig_timestamp", timestamp) - putAll(message.toJSON()) - } - } else { - buildMap { - putAll(message.toJSON()) - if (namespace != 0) { - put("namespace", namespace) - } - } - } - - sendBatchRequest( - snode = getSingleTargetSnode(message.recipient).await(), - publicKey = message.recipient, - request = SnodeBatchRequestInfo( - method = Snode.Method.SendMessage.rawValue, - params = params, - namespace = namespace - ), - responseType = StoreMessageResponse.serializer() - ) - } - } - - suspend fun deleteMessage(publicKey: String, swarmAuth: SwarmAuth, serverHashes: List) { - retryWithUniformInterval { - val snode = getSingleTargetSnode(publicKey).await() - val params = buildAuthenticatedParameters( - auth = swarmAuth, - namespace = null, - verificationData = { _, _ -> - buildString { - append(Snode.Method.DeleteMessage.rawValue) - serverHashes.forEach(this::append) - } - } - ) { - this["messages"] = serverHashes - } - val rawResponse = invoke( - Snode.Method.DeleteMessage, - snode, - params, - publicKey - ).await() - - // thie next step is to verify the nodes on our swarm and check that the message was deleted - // on at least one of them - val swarms = rawResponse["swarm"] as? Map ?: throw (Error.Generic) - - val deletedMessages = swarms.mapValuesNotNull { (hexSnodePublicKey, rawJSON) -> - (rawJSON as? Map)?.let { json -> - val isFailed = json["failed"] as? Boolean ?: false - val statusCode = json[KEY_CODE] as? String - val reason = json["reason"] as? String - - if (isFailed) { - Log.e( - "Loki", - "Failed to delete messages from: $hexSnodePublicKey due to error: $reason ($statusCode)." - ) - false - } else { - // Hashes of deleted messages - val hashes = json["deleted"] as List - val signature = json["signature"] as String - // The signature looks like ( PUBKEY_HEX || RMSG[0] || ... || RMSG[N] || DMSG[0] || ... || DMSG[M] ) - val message = sequenceOf(swarmAuth.accountId.hexString) - .plus(serverHashes) - .plus(hashes) - .toByteArray() - - ED25519.verify( - ed25519PublicKey = Hex.fromStringCondensed(hexSnodePublicKey), - signature = Base64.decode(signature), - message = message, - ) - } - } - } - - // if all the nodes returned false (the message was not deleted) then we consider this a failed scenario - if (deletedMessages.entries.all { !it.value }) throw (Error.Generic) - } - } - - // Parsing - private fun parseSnodes(rawResponse: Any): List = - (rawResponse as? Map<*, *>) - ?.run { get("snodes") as? List<*> } - ?.asSequence() - ?.mapNotNull { it as? Map<*, *> } - ?.mapNotNull { - createSnode( - address = it["ip"] as? String, - port = (it["port"] as? String)?.toInt(), - ed25519Key = it[KEY_ED25519] as? String, - x25519Key = it[KEY_X25519] as? String - ).apply { - if (this == null) Log.d( - "Loki", - "Failed to parse snode from: ${it.prettifiedDescription()}." - ) - } - }?.toList() ?: listOf().also { - Log.d( - "Loki", - "Failed to parse snodes from: ${rawResponse.prettifiedDescription()}." - ) - } - - fun deleteAllMessages(auth: SwarmAuth): Promise, Exception> = - scope.retrySuspendAsPromise(maxRetryCount) { - val snode = getSingleTargetSnode(auth.accountId.hexString).await() - val timestamp = MessagingModuleConfiguration.shared.clock.waitForNetworkAdjustedTime() - - val params = buildAuthenticatedParameters( - auth = auth, - namespace = null, - verificationData = { _, t -> "${Snode.Method.DeleteAll.rawValue}all$t" }, - timestamp = timestamp - ) { - put("namespace", "all") - } - - val rawResponse = invoke(Snode.Method.DeleteAll, snode, params, auth.accountId.hexString).await() - parseDeletions( - auth.accountId.hexString, - timestamp, - rawResponse - ) - } - - - @Suppress("UNCHECKED_CAST") - private fun parseDeletions(userPublicKey: String, timestamp: Long, rawResponse: RawResponse): Map = - (rawResponse["swarm"] as? Map)?.mapValuesNotNull { (hexSnodePublicKey, rawJSON) -> - val json = rawJSON as? Map ?: return@mapValuesNotNull null - if (json["failed"] as? Boolean == true) { - val reason = json["reason"] as? String - val statusCode = json[KEY_CODE] as? String - Log.e("Loki", "Failed to delete all messages from: $hexSnodePublicKey due to error: $reason ($statusCode).") - false - } else { - val hashes = (json["deleted"] as Map>).flatMap { (_, hashes) -> hashes }.sorted() // Hashes of deleted messages - val signature = json["signature"] as String - // The signature looks like ( PUBKEY_HEX || TIMESTAMP || DELETEDHASH[0] || ... || DELETEDHASH[N] ) - val message = sequenceOf(userPublicKey, "$timestamp").plus(hashes).toByteArray() - ED25519.verify( - ed25519PublicKey = Hex.fromStringCondensed(hexSnodePublicKey), - signature = Base64.decode(signature), - message = message, - ) - } - } ?: mapOf() - - // endregion - - // Error Handling - internal fun handleSnodeError(statusCode: Int, json: Map<*, *>?, snode: Snode, publicKey: String? = null): Throwable? = runCatching { - fun handleBadSnode() { - val oldFailureCount = snodeFailureCount[snode] ?: 0 - val newFailureCount = oldFailureCount + 1 - snodeFailureCount[snode] = newFailureCount - Log.d("Loki", "Couldn't reach snode at $snode; setting failure count to $newFailureCount.") - if (newFailureCount >= snodeFailureThreshold) { - Log.d("Loki", "Failure threshold reached for: $snode; dropping it.") - publicKey?.let { dropSnodeFromSwarmIfNeeded(snode, it) } - snodePool = (snodePool - snode).also { Log.d("Loki", "Snode pool count: ${it.count()}.") } - snodeFailureCount -= snode - } - } - when (statusCode) { - // Usually indicates that the snode isn't up to date - 400, 500, 502, 503 -> handleBadSnode() - 406 -> { - Log.d("Loki", "The user's clock is out of sync with the service node network.") - throw Error.ClockOutOfSync - } - 421 -> { - // The snode isn't associated with the given public key anymore - if (publicKey == null) Log.d("Loki", "Got a 421 without an associated public key.") - else json?.let(::parseSnodes) - ?.takeIf { it.isNotEmpty() } - ?.let { database.setSwarm(publicKey, it.toSet()) } - ?: dropSnodeFromSwarmIfNeeded(snode, publicKey).also { Log.d("Loki", "Invalidating swarm for: $publicKey.") } - } - 404 -> { - Log.d("Loki", "404, probably no file found") - throw Error.Generic - } - else -> { - handleBadSnode() - Log.d("Loki", "Unhandled response code: ${statusCode}.") - throw Error.Generic - } - } - }.exceptionOrNull() -} - -// Type Aliases -typealias RawResponse = Map<*, *> -typealias RawResponsePromise = Promise diff --git a/app/src/main/java/org/session/libsession/snode/SnodeClock.kt b/app/src/main/java/org/session/libsession/snode/SnodeClock.kt deleted file mode 100644 index 9896f3d961..0000000000 --- a/app/src/main/java/org/session/libsession/snode/SnodeClock.kt +++ /dev/null @@ -1,120 +0,0 @@ -package org.session.libsession.snode - -import android.os.SystemClock -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import org.session.libsession.snode.utilities.await -import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.dependencies.ManagerScope -import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent -import java.util.Date -import javax.inject.Inject -import javax.inject.Singleton - -/** - * A class that manages the network time by querying the network time from a random snode. The - * primary goal of this class is to provide a time that is not tied to current system time and not - * prone to time changes locally. - * - * Before the first network query is successfully, calling [currentTimeMills] will return the current - * system time. - */ -@Singleton -class SnodeClock @Inject constructor( - @param:ManagerScope private val scope: CoroutineScope -) : OnAppStartupComponent { - private val instantState = MutableStateFlow(null) - private var job: Job? = null - - override fun onPostAppStarted() { - require(job == null) { "Already started" } - - job = scope.launch { - while (true) { - try { - val node = SnodeAPI.getRandomSnode().await() - val requestStarted = SystemClock.elapsedRealtime() - - var networkTime = SnodeAPI.getNetworkTime(node).await().second - val requestEnded = SystemClock.elapsedRealtime() - - // Adjust the network time to account for the time it took to make the request - // so that the network time equals to the time when the request was started - networkTime -= (requestEnded - requestStarted) / 2 - - val inst = Instant(requestStarted, networkTime) - - Log.d("SnodeClock", "Network time: ${Date(inst.now())}, system time: ${Date()}") - - instantState.value = inst - } catch (e: Exception) { - Log.e("SnodeClock", "Failed to get network time. Retrying in a few seconds", e) - } finally { - // Retry frequently if we haven't got any result before - val delayMills = if (instantState.value == null) { - 3_000L - } else { - 3600_000L - } - - delay(delayMills) - } - } - } - } - - /** - * Wait for the network adjusted time to come through. - */ - suspend fun waitForNetworkAdjustedTime(): Long { - return instantState.filterNotNull().first().now() - } - - /** - * Get the current time in milliseconds. If the network time is not available yet, this method - * will return the current system time. - */ - fun currentTimeMills(): Long { - return instantState.value?.now() ?: System.currentTimeMillis() - } - - fun currentTimeSeconds(): Long { - return currentTimeMills() / 1000 - } - - fun currentTime(): java.time.Instant { - return java.time.Instant.ofEpochMilli(currentTimeMills()) - } - - /** - * Delay until the specified instant. If the instant is in the past or now, this method returns - * immediately. - * - * @return true if delayed, false if the instant is in the past - */ - suspend fun delayUntil(instant: java.time.Instant): Boolean { - val now = currentTimeMills() - val target = instant.toEpochMilli() - return if (target > now) { - delay(target - now) - true - } else { - target == now - } - } - - private class Instant( - val systemUptime: Long, - val networkTime: Long, - ) { - fun now(): Long { - val elapsed = SystemClock.elapsedRealtime() - systemUptime - return networkTime + elapsed - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/snode/SnodeMessage.kt b/app/src/main/java/org/session/libsession/snode/SnodeMessage.kt deleted file mode 100644 index 8fc22a8303..0000000000 --- a/app/src/main/java/org/session/libsession/snode/SnodeMessage.kt +++ /dev/null @@ -1,38 +0,0 @@ -package org.session.libsession.snode - -data class SnodeMessage( - /** - * The hex encoded public key of the recipient. - */ - val recipient: String, - /** - * The base64 encoded content of the message. - */ - val data: String, - /** - * The time to live for the message in milliseconds. - */ - val ttl: Long, - /** - * When the proof of work was calculated. - * - * **Note:** Expressed as milliseconds since 00:00:00 UTC on 1 January 1970. - */ - val timestamp: Long -) { - internal constructor(): this("", "", -1, -1) - - internal fun toJSON(): Map { - return mapOf( - "pubkey" to recipient, - "data" to data, - "ttl" to ttl.toString(), - "timestamp" to timestamp.toString(), - ) - } - - companion object { - const val CONFIG_TTL: Long = 30 * 24 * 60 * 60 * 1000L // 30 days - const val DEFAULT_TTL: Long = 14 * 24 * 60 * 60 * 1000L // 14 days - } -} diff --git a/app/src/main/java/org/session/libsession/snode/SnodeModule.kt b/app/src/main/java/org/session/libsession/snode/SnodeModule.kt deleted file mode 100644 index 993c73d0b6..0000000000 --- a/app/src/main/java/org/session/libsession/snode/SnodeModule.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.session.libsession.snode - -import android.app.Application -import dagger.Lazy -import org.session.libsession.utilities.Environment -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsignal.database.LokiAPIDatabaseProtocol -import org.session.libsignal.utilities.Broadcaster -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class SnodeModule @Inject constructor( - val storage: LokiAPIDatabaseProtocol, - prefs: TextSecurePreferences, -) { - val environment: Environment = prefs.getEnvironment() - - companion object { - lateinit var sharedLazy: Lazy - - @Deprecated("Use properly DI components instead") - val shared: SnodeModule get() = sharedLazy.get() - } -} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/snode/StorageProtocol.kt b/app/src/main/java/org/session/libsession/snode/StorageProtocol.kt deleted file mode 100644 index b555acddbd..0000000000 --- a/app/src/main/java/org/session/libsession/snode/StorageProtocol.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.session.libsession.snode - -import org.session.libsignal.utilities.Snode - -interface SnodeStorageProtocol { - - fun getSnodePool(): Set - fun setSnodePool(newValue: Set) - fun getOnionRequestPaths(): List> - fun clearOnionRequestPaths() - fun setOnionRequestPaths(newValue: List>) - fun getSwarm(publicKey: String): Set? - fun setSwarm(publicKey: String, newValue: Set) - fun getLastMessageHashValue(snode: Snode, publicKey: String): String? - fun setLastMessageHashValue(snode: Snode, publicKey: String, newValue: String) - fun getReceivedMessageHashValues(publicKey: String): Set? - fun setReceivedMessageHashValues(publicKey: String, newValue: Set) -} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/snode/SwarmAuth.kt b/app/src/main/java/org/session/libsession/snode/SwarmAuth.kt deleted file mode 100644 index 738a0ef8cd..0000000000 --- a/app/src/main/java/org/session/libsession/snode/SwarmAuth.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.session.libsession.snode - -import org.session.libsignal.utilities.AccountId - -/** - * An interface that represents the necessary data to sign a message for accounts. - * - */ -interface SwarmAuth { - /** - * Sign the given data and return the signature JSON structure. - */ - fun sign(data: ByteArray): Map - - val accountId: AccountId - val ed25519PublicKeyHex: String? -} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/snode/model/BatchResponse.kt b/app/src/main/java/org/session/libsession/snode/model/BatchResponse.kt deleted file mode 100644 index d3fa2acd19..0000000000 --- a/app/src/main/java/org/session/libsession/snode/model/BatchResponse.kt +++ /dev/null @@ -1,32 +0,0 @@ -package org.session.libsession.snode.model - -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonElement - -@Serializable - -data class BatchResponse(val results: List, ) { - @Serializable - data class Item( - val code: Int, - val body: JsonElement, - ) { - val isSuccessful: Boolean - get() = code in 200..299 - - val isServerError: Boolean - get() = code in 500..599 - - val isSnodeNoLongerPartOfSwarm: Boolean - get() = code == 421 - } - - data class Error(val item: Item) - : RuntimeException("Batch request failed with code ${item.code}") { - init { - require(!item.isSuccessful) { - "This response item does not represent an error state" - } - } - } -} diff --git a/app/src/main/java/org/session/libsession/snode/model/MessageResponses.kt b/app/src/main/java/org/session/libsession/snode/model/MessageResponses.kt deleted file mode 100644 index 35ec0f25cf..0000000000 --- a/app/src/main/java/org/session/libsession/snode/model/MessageResponses.kt +++ /dev/null @@ -1,45 +0,0 @@ -package org.session.libsession.snode.model - -import android.util.Base64 -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import org.session.libsession.utilities.serializable.InstantAsMillisSerializer -import java.time.Instant - -@Serializable -data class StoreMessageResponse( - val hash: String, - @Serializable(InstantAsMillisSerializer::class) - @SerialName("t") val timestamp: Instant, -) - -@Serializable -data class RetrieveMessageResponse( - val messages: List, -) { - @Serializable - data class Message( - val hash: String, - - // Some messages use "t" as timestamp field - @Serializable(InstantAsMillisSerializer::class) - @SerialName("t") - private val t1: Instant? = null, - - // Some messages use "timestamp" as timestamp field - @Serializable(InstantAsMillisSerializer::class) - @SerialName("timestamp") - private val t2: Instant? = null, - - @SerialName("data") - val dataB64: String? = null, - ) { - val data: ByteArray by lazy { - Base64.decode(dataB64, Base64.DEFAULT) - } - - val timestamp: Instant get() = requireNotNull(t1 ?: t2) { - "Message timestamp is missing" - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/snode/utilities/PromiseUtil.kt b/app/src/main/java/org/session/libsession/snode/utilities/PromiseUtil.kt deleted file mode 100644 index 17e6f696b9..0000000000 --- a/app/src/main/java/org/session/libsession/snode/utilities/PromiseUtil.kt +++ /dev/null @@ -1,73 +0,0 @@ -package org.session.libsession.snode.utilities - -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.deferred -import org.session.libsignal.utilities.Log -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlin.coroutines.suspendCoroutine - -suspend inline fun Promise.await(): T { - return suspendCoroutine { cont -> - success(cont::resume) - fail(cont::resumeWithException) - } -} - -fun Promise.successBackground(callback: (value: V) -> Unit): Promise { - GlobalScope.launch { - try { - callback(this@successBackground.await()) - } catch (e: Exception) { - Log.d("Loki", "Failed to execute task in background: ${e.message}.") - } - } - return this -} - -fun CoroutineScope.asyncPromise(context: CoroutineContext = EmptyCoroutineContext, block: suspend () -> T): Promise { - val defer = deferred() - launch(context) { - try { - defer.resolve(block()) - } catch (e: Exception) { - defer.reject(e) - } - } - - return defer.promise -} - -fun CoroutineScope.retrySuspendAsPromise( - maxRetryCount: Int, - retryIntervalMills: Long = 1_000L, - body: suspend () -> T -): Promise { - return asyncPromise { - var retryCount = 0 - while (true) { - try { - return@asyncPromise body() - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - if (retryCount == maxRetryCount) { - throw e - } else { - retryCount += 1 - delay(retryIntervalMills) - } - } - } - - @Suppress("UNREACHABLE_CODE") - throw IllegalStateException("Unreachable code") - } -} \ No newline at end of file From 2cd727b13deea5a1f31135eb52f743e3b8a137d3 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 12 Dec 2025 11:57:54 +1100 Subject: [PATCH 02/77] wip --- .../libsession/network/SessionNetwork.kt | 307 +++++++++-------- .../libsession/network/model/OnionError.kt | 15 +- .../network/onion/http/HttpOnionTransport.kt | 314 +++++++++--------- 3 files changed, 329 insertions(+), 307 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt index 548474a186..61166482b6 100644 --- a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt +++ b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt @@ -1,195 +1,234 @@ package org.session.libsession.network +import okhttp3.Request import org.session.libsession.network.model.OnionDestination import org.session.libsession.network.model.OnionError import org.session.libsession.network.model.OnionResponse import org.session.libsession.network.model.Path import org.session.libsession.network.onion.OnionTransport -import org.session.libsession.network.onion.Version import org.session.libsession.network.onion.PathManager -import org.session.libsignal.utilities.Snode +import org.session.libsession.network.onion.Version +import org.session.libsession.network.utilities.getBodyForOnionRequest +import org.session.libsession.network.utilities.getHeadersForOnionRequest +import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Snode /** - * High-level façade over onion routing: - * - * - asks PathManager for a path - * - uses OnionTransport to send over that path - * - maps OnionError -> path/node repair via PathManager - * - decides whether to retry once with a new path + * High-level onion request manager. + * It prepares payloads, chooses onion paths, analyzes failures, repairs the path graph, + * implements retry rules, and returns final user-level responses. + * It does not build onion encryption or send anything over the network, that part + * is left to an implementation of an OnionTransport */ class SessionNetwork( private val pathManager: PathManager, private val transport: OnionTransport, + private val maxAttempts: Int = 3 ) { /** - * Main entry point for “send an onion request”. - * - * - destination: Snode or Server (file server, open group, etc.) - * - payload: the already-built request body to wrap in an onion - * - version: V2/V3/V4 onion protocol + * Send an onion request to a *service node* (RPC). */ - suspend fun sendOnionRequest( - destination: OnionDestination, - payload: ByteArray, - version: Version = Version.V4, + suspend fun sendToSnode( + method: Snode.Method, + parameters: Map<*, *>, + snode: Snode, + version: Version = Version.V4 ): Result { - // If the destination is a specific snode, try not to route *through* it - val snodeToExclude: Snode? = when (destination) { - is OnionDestination.SnodeDestination -> destination.snode - is OnionDestination.ServerDestination -> null - } + val payload = JsonUtil.toJson( + mapOf( + "method" to method.rawValue, + "params" to parameters + ) + ).toByteArray() - // 1. Pick a path - val initialPath = try { - pathManager.getPath(exclude = snodeToExclude) - } catch (t: Throwable) { - return Result.failure(t) - } + val destination = OnionDestination.SnodeDestination(snode) - // 2. First attempt - val first = transport.send( - path = initialPath, + // Exclude the snode itself from being in the path (matches old behaviour) + return sendWithRetry( destination = destination, payload = payload, - version = version + version = version, + snodeToExclude = snode ) + } - if (first.isSuccess) { - return first - } + /** + * Send an onion request to an HTTP server via the snode network. + */ + suspend fun sendToServer( + request: Request, + serverBaseUrl: String, + x25519PublicKey: String, + version: Version = Version.V4 + ): Result { + val url = request.url + val payload = generatePayload(request, serverBaseUrl, version) + + val destination = OnionDestination.ServerDestination( + host = url.host, + target = version.value, + x25519PublicKey = x25519PublicKey, + scheme = url.scheme, + port = url.port + ) - val error = first.exceptionOrNull() - if (error !is OnionError) { - // Some unexpected exception coming out of the transport. - Log.w("SessionNetwork", "Non-OnionError failure: $error") - return Result.failure(error ?: IllegalStateException("Unknown failure")) - } + return sendWithRetry( + destination = destination, + payload = payload, + version = version, + snodeToExclude = null + ) + } - // 3. Let PathManager react (drop path / snode) - handleOnionError(initialPath, error) + private suspend fun sendWithRetry( + destination: OnionDestination, + payload: ByteArray, + version: Version, + snodeToExclude: Snode? + ): Result { + var lastError: Throwable? = null - // 4. Decide whether to retry - if (!shouldRetry(error)) { - return Result.failure(error) - } + repeat(maxAttempts) { attempt -> + val path = pathManager.getPath(exclude = snodeToExclude) - // 5. Retry once with a new path - val retryPath = try { - pathManager.getPath(exclude = snodeToExclude) - } catch (t: Throwable) { - // Couldn't even get a new path; keep original onion error - return Result.failure(error) - } + val result = transport.send( + path = path, + destination = destination, + payload = payload, + version = version + ) - val retry = transport.send( - path = retryPath, - destination = destination, - payload = payload, - version = version - ) + if (result.isSuccess) return result - // If second attempt fails with an OnionError, update paths again - val retryErr = retry.exceptionOrNull() - if (retryErr is OnionError) { - handleOnionError(retryPath, retryErr) + val error = result.exceptionOrNull() + if (error !is OnionError) { + // Transport returned some unexpected Throwable + return Result.failure(error ?: IllegalStateException("Unknown transport error")) + } + + Log.w("Onion", "Onion error on attempt ${attempt + 1}/$maxAttempts: $error") + + handleError(path, error) + + if (!mustRetry(error, attempt)) { + return Result.failure(error) + } + + lastError = error } - return retry + return Result.failure(lastError ?: IllegalStateException("Unknown onion error")) } /** - * Map a specific OnionError to PathManager operations (node/path surgery). + * Decide whether to retry based on the error type and current attempt. */ - private fun handleOnionError(path: Path, error: OnionError) { - when (error) { - is OnionError.GuardConnectionFailed -> { - // Guard is the first node in the path. - Log.w("SessionNetwork", "Guard connection failed for ${error.guard}, dropping path") - pathManager.handleBadPath(path) + private fun mustRetry(error: OnionError, attempt: Int): Boolean { + if (attempt + 1 >= maxAttempts) return false + + return when (error) { + is OnionError.DestinationError, + is OnionError.ClockOutOfSync -> { + false + } + is OnionError.GuardConnectionFailed, + is OnionError.GuardProtocolError, + is OnionError.IntermediateNodeFailed, + is OnionError.InvalidResponse, + is OnionError.Unknown -> { + true } + } + } - is OnionError.GuardProtocolError -> { - Log.w( - "SessionNetwork", - "Guard protocol error code=${error.code}, dropping path" - ) + /** + * Map an OnionError into path-level healing operations. + */ + private fun handleError(path: Path, error: OnionError) { + when (error) { + is OnionError.GuardConnectionFailed, + is OnionError.GuardProtocolError, + is OnionError.InvalidResponse, + is OnionError.Unknown -> { + // We don't know which hop is bad; drop the whole path. + Log.w("Onion", "Dropping entire path due to error: $error") pathManager.handleBadPath(path) } is OnionError.IntermediateNodeFailed -> { val failedKey = error.failedPublicKey - if (failedKey != null) { - val badNode = findNodeByEd25519(path, failedKey) - if (badNode != null) { - Log.w("SessionNetwork", "Intermediate node failed: $badNode, repairing path") - pathManager.handleBadSnode(badNode) + if (failedKey == null) { + Log.w("Onion", "Intermediate node failed but no key given; dropping path") + pathManager.handleBadPath(path) + } else { + val bad = path.firstOrNull { it.publicKeySet?.ed25519Key == failedKey } + if (bad != null) { + Log.w("Onion", "Dropping bad snode $bad in path") + pathManager.handleBadSnode(bad) } else { - Log.w( - "SessionNetwork", - "Intermediate node failed; key not found in path. Dropping path." - ) + Log.w("Onion", "Failed node key not in path; dropping path") pathManager.handleBadPath(path) } - } else { - Log.w("SessionNetwork", "Intermediate node failed (no failed key); dropping path") - pathManager.handleBadPath(path) - } - } - - is OnionError.DestinationUnreachable -> { - // Exit node is usually last in the path - val exit = error.exitNode ?: path.lastOrNull() - if (exit != null && path.contains(exit)) { - Log.w("SessionNetwork", "Destination unreachable; marking exit node $exit as bad") - pathManager.handleBadSnode(exit) - } else { - Log.w("SessionNetwork", "Destination unreachable; dropping entire path") - pathManager.handleBadPath(path) } } - is OnionError.DestinationError -> { - // Pure app-level error (404, 401, etc.): path is fine. - Log.i("SessionNetwork", "Destination error ${error.code}, not penalising path") - } - + is OnionError.DestinationError, is OnionError.ClockOutOfSync -> { - // Network is “working” but user must fix their device clock. - Log.w("SessionNetwork", "Clock out of sync: code=${error.code}") - } - - is OnionError.InvalidResponse -> { - Log.w("SessionNetwork", "Invalid onion response, dropping path") - pathManager.handleBadPath(path) - } - - is OnionError.Unknown -> { - Log.w("SessionNetwork", "Unknown onion error, dropping path: ${error.underlying}") - pathManager.handleBadPath(path) + // Path is considered healthy; do not mutate paths. + Log.d("Onion", "Application or clock error; not penalizing path") } } } /** - * Policy: when does it make sense to try again with a new path? + * Equivalent to the old generatePayload() from OnionRequestAPI. */ - private fun shouldRetry(error: OnionError): Boolean = - when (error) { - is OnionError.GuardConnectionFailed -> true // try another guard/path - is OnionError.GuardProtocolError -> true // different guard may succeed - is OnionError.IntermediateNodeFailed -> true // path surgery then retry - is OnionError.DestinationUnreachable -> true // exit/node connectivity - is OnionError.DestinationError -> false // app-level; retrying won’t fix 404/401 - is OnionError.ClockOutOfSync -> false // must fix clock - is OnionError.InvalidResponse -> true // treat as corrupt path - is OnionError.Unknown -> true // conservative + private fun generatePayload(request: Request, server: String, version: Version): ByteArray { + val headers = request.getHeadersForOnionRequest().toMutableMap() + val url = request.url + val urlAsString = url.toString() + val body = request.getBodyForOnionRequest() ?: "null" + + val endpoint = if (server.length < urlAsString.length) { + urlAsString.substringAfter(server) + } else { + "" } - /** - * Find a node in the path by its ed25519 public key. - */ - private fun findNodeByEd25519(path: Path, ed25519: String): Snode? = - path.firstOrNull { it.publicKeySet?.ed25519Key == ed25519 } + return if (version == Version.V4) { + if (request.body != null && + headers.keys.none { it.equals("Content-Type", ignoreCase = true) } + ) { + headers["Content-Type"] = "application/json" + } + + val requestPayload = mapOf( + "endpoint" to endpoint, + "method" to request.method, + "headers" to headers + ) + + val requestData = JsonUtil.toJson(requestPayload).toByteArray() + val prefixData = "l${requestData.size}:".toByteArray(Charsets.US_ASCII) + val suffixData = "e".toByteArray(Charsets.US_ASCII) + + if (request.body != null) { + val bodyData = if (body is ByteArray) body else body.toString().toByteArray() + val bodyLengthData = "${bodyData.size}:".toByteArray(Charsets.US_ASCII) + prefixData + requestData + bodyLengthData + bodyData + suffixData + } else { + prefixData + requestData + suffixData + } + } else { + val payload = mapOf( + "body" to body, + "endpoint" to endpoint.removePrefix("/"), + "method" to request.method, + "headers" to headers + ) + JsonUtil.toJson(payload).toByteArray() + } + } } diff --git a/app/src/main/java/org/session/libsession/network/model/OnionError.kt b/app/src/main/java/org/session/libsession/network/model/OnionError.kt index e7fa7f99d3..afa733b746 100644 --- a/app/src/main/java/org/session/libsession/network/model/OnionError.kt +++ b/app/src/main/java/org/session/libsession/network/model/OnionError.kt @@ -14,14 +14,13 @@ sealed class OnionError(message: String, cause: Throwable? = null) : Exception(m ) : OnionError("Failed to connect to guard ${guard.ip}:${guard.port}", underlying) /** - * Guard responded with a valid HTTP response but rejected the onion request as such. - * E.g. 4xx/5xx from the guard itself, protocol mismatch, overloaded, etc. + * Guard or intermediate nodes - specifically: errors not from the encrypred payload) */ data class GuardProtocolError( val guard: Snode?, val code: Int, val body: String? - ) : OnionError("Guard ${guard?.ip}:${guard?.port} rejected onion request with $code", null) + ) : OnionError("Guard ${guard?.ip}:${guard?.port} error with staatus $code", null) /** * The onion chain broke mid-path: one hop reported that the next node was not found. @@ -32,16 +31,6 @@ sealed class OnionError(message: String, cause: Throwable? = null) : Exception(m val failedPublicKey: String? ) : OnionError("Intermediate node failure (failedPublicKey=$failedPublicKey)", null) - /** - * The exit node tried to reach the destination (server or snode) but failed at the network layer. - * DNS failure, connection refused, timeout, etc. - */ - data class DestinationUnreachable( - val exitNode: Snode?, - val destination: String, - val underlying: Throwable? - ) : OnionError("Exit node could not reach destination $destination", underlying) - /** * The destination (server or snode) responded with a non-success application-level status. * E.g. 404, 401, 500, app-specific error JSON, etc. diff --git a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt index ec6ae873d6..50aa1d5f2e 100644 --- a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt +++ b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt @@ -1,6 +1,5 @@ package org.session.libsession.network.onion.http -import kotlin.text.Charsets import org.session.libsession.network.model.OnionDestination import org.session.libsession.network.model.OnionError import org.session.libsession.network.model.OnionResponse @@ -9,8 +8,6 @@ import org.session.libsession.network.onion.OnionRequestEncryption import org.session.libsession.network.onion.OnionTransport import org.session.libsession.network.onion.Version import org.session.libsession.utilities.AESGCM -import org.session.libsession.utilities.AESGCM.ivSize -import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.ByteArraySlice import org.session.libsignal.utilities.ByteArraySlice.Companion.view import org.session.libsignal.utilities.HTTP @@ -18,7 +15,17 @@ import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Snode import org.session.libsignal.utilities.toHexString -class HttpOnionTransporter : OnionTransport { +private val NON_PENALIZING_STATUSES = setOf(403, 404, 406, 425) +private const val REQUIRE_BLINDING_MESSAGE = + "Invalid authentication: this server requires the use of blinded ids" + +/** + * Builds onion layers, sends them over HTTP to the guard, + * receives and decrypts the onion response, + * and maps low-level protocol/transport errors into onion errors. + * It does not choose paths, retry, or apply healing logic. + */ +class HttpOnionTransport : OnionTransport { override suspend fun send( path: List, @@ -26,83 +33,80 @@ class HttpOnionTransporter : OnionTransport { payload: ByteArray, version: Version ): Result { - return try { - val built = OnionBuilder.build(path, destination, payload, version) - val guard = built.guard - val url = "${guard.address}:${guard.port}/onion_req/v2" + require(path.isNotEmpty()) { "Path must not be empty" } - val params = mapOf( - "ephemeral_key" to built.ephemeralPublicKey.toHexString() - ) + val guard = path.first() + + val built = try { + OnionBuilder.build(path, destination, payload, version) + } catch (t: Throwable) { + return Result.failure(OnionError.Unknown(t)) + } - val body = OnionRequestEncryption.encode( + val url = "${guard.address}:${guard.port}/onion_req/v2" + + val params = mapOf( + "ephemeral_key" to built.ephemeralPublicKey.toHexString() + ) + + val body = try { + OnionRequestEncryption.encode( ciphertext = built.ciphertext, json = params ) + } catch (t: Throwable) { + return Result.failure(OnionError.Unknown(t)) + } - val responseBytes = try { - HTTP.execute(HTTP.Verb.POST, url, body) - } catch (httpEx: HTTP.HTTPRequestFailedException) { - // This is an HTTP-level failure to the guard - return Result.failure(classifyHttpFailure(path, destination, httpEx)) - } catch (t: Throwable) { - return Result.failure( - OnionError.GuardConnectionFailed(guard, t) - ) - } - - val response = decodeResponse(responseBytes, destination, version, built.destinationSymmetricKey) - Result.success(response) - } catch (e: OnionError) { - Result.failure(e) + val responseBytes: ByteArray = try { + HTTP.execute(HTTP.Verb.POST, url, body) + } catch (httpEx: HTTP.HTTPRequestFailedException) { + // HTTP error from guard (we never got an onion-level response) + return Result.failure(mapGuardHttpError(guard, httpEx)) } catch (t: Throwable) { - Result.failure(OnionError.Unknown(t)) + // TCP / DNS / TLS / timeout etc. reaching guard + return Result.failure(OnionError.GuardConnectionFailed(guard, t)) } + + // We have an onion-level response from the guard; decrypt & interpret + return handleResponse( + rawResponse = responseBytes, + destinationSymmetricKey = built.destinationSymmetricKey, + destination = destination, + version = version + ) } /** - * Turn a HTTP.HTTPRequestFailedException from the guard into a structured OnionError. - * - * This is where we replicate the old logic that interpreted "Next node not found", etc. + * Map HTTP errors from the guard (before onion decryption) */ - private fun classifyHttpFailure( - path: List, - destination: OnionDestination, + private fun mapGuardHttpError( + guard: Snode, ex: HTTP.HTTPRequestFailedException ): OnionError { val json = ex.json - val statusCode = ex.statusCode val message = json?.get("result") as? String - val guard = path.firstOrNull() + val statusCode = ex.statusCode + // Special onion path error: "Next node not found: " val prefix = "Next node not found: " if (message != null && message.startsWith(prefix)) { - val failedKey = message.removePrefix(prefix) + val failedPk = message.removePrefix(prefix) return OnionError.IntermediateNodeFailed( reportingNode = guard, - failedPublicKey = failedKey + failedPublicKey = failedPk ) } - // Destination-related 4xx/5xx that we don't want to penalize path for - if (destination is OnionDestination.ServerDestination && - (statusCode in 500..504 || statusCode == 400) && - (ex.body?.contains(destination.host) == true) - ) { - return OnionError.DestinationError(code = statusCode, body = ex.body) - } - - // Special clock out of sync codes from your old logic - if (statusCode == 406 || statusCode == 425) { - return OnionError.ClockOutOfSync(code = statusCode, body = message) - } - - // 404, 403, etc. that are likely application or resource errors - if (statusCode in listOf(400, 401, 403, 404)) { - return OnionError.DestinationError(code = statusCode, body = message) + // Non-penalising codes: treat as destination-level error (path OK) + if (statusCode in NON_PENALIZING_STATUSES || message == "Loki Server error") { + return OnionError.DestinationError( + code = statusCode, + body = message + ) } - // Fallback: treat as guard protocol error + // Otherwise: guard rejected / misbehaved return OnionError.GuardProtocolError( guard = guard, code = statusCode, @@ -110,142 +114,132 @@ class HttpOnionTransporter : OnionTransport { ) } - private fun decodeResponse( - response: ByteArray, + /** + * Handle an onion-encrypted response + */ + private fun handleResponse( + rawResponse: ByteArray, + destinationSymmetricKey: ByteArray, destination: OnionDestination, - version: Version, - destinationSymmetricKey: ByteArray - ): OnionResponse { + version: Version + ): Result { return when (version) { - Version.V4 -> decodeV4(response, destination, destinationSymmetricKey) - Version.V2, Version.V3 -> decodeLegacy(response, destination, destinationSymmetricKey) + Version.V4 -> handleV4Response(rawResponse, destinationSymmetricKey, destination) + Version.V2, Version.V3 -> { + //todo ONION add support for v2/v3 + Result.failure( + OnionError.Unknown( + UnsupportedOperationException("Need to implement - TEMP") + ) + ) + } } } - private fun decodeV4( + private fun handleV4Response( response: ByteArray, - destination: OnionDestination, - destinationSymmetricKey: ByteArray - ): OnionResponse { - if (response.size <= ivSize) throw OnionError.InvalidResponse(response) - - val plaintext = try { - AESGCM.decrypt(response, symmetricKey = destinationSymmetricKey) - } catch (e: Throwable) { - throw OnionError.InvalidResponse(response) - } + destinationSymmetricKey: ByteArray, + destination: OnionDestination + ): Result { + try { + if (response.size <= AESGCM.ivSize) { + return Result.failure(OnionError.InvalidResponse(response)) + } - if (!byteArrayOf(plaintext.first()).contentEquals("l".toByteArray())) { - throw OnionError.InvalidResponse(response) - } + val plaintext = AESGCM.decrypt(response, symmetricKey = destinationSymmetricKey) - val infoSepIdx = plaintext.indexOfFirst { byteArrayOf(it).contentEquals(":".toByteArray()) } - val infoLenSlice = plaintext.slice(1 until infoSepIdx) - val infoLength = infoLenSlice.toByteArray().toString(Charsets.US_ASCII).toIntOrNull() - ?: throw OnionError.InvalidResponse(response) + if (plaintext.isEmpty() || plaintext[0] != 'l'.code.toByte()) { + return Result.failure(OnionError.InvalidResponse(response)) + } - if (infoLenSlice.size <= 1) throw OnionError.InvalidResponse(response) + val infoSepIdx = plaintext.indexOfFirst { it == ':'.code.toByte() } + if (infoSepIdx <= 1) { + return Result.failure(OnionError.InvalidResponse(response)) + } - val infoStartIndex = "l$infoLength".length + 1 - val infoEndIndex = infoStartIndex + infoLength - val info = plaintext.slice(infoStartIndex until infoEndIndex) - val responseInfo = JsonUtil.fromJson(info.toByteArray(), Map::class.java) + val infoLenSlice = plaintext.slice(1 until infoSepIdx) + val infoLength = infoLenSlice + .toByteArray() + .toString(Charsets.US_ASCII) + .toIntOrNull() + ?: return Result.failure(OnionError.InvalidResponse(response)) + + val infoStartIndex = "l$infoLength".length + 1 + val infoEndIndex = infoStartIndex + infoLength + if (infoEndIndex > plaintext.size) { + return Result.failure(OnionError.InvalidResponse(response)) + } + + val infoBytes = plaintext.slice(infoStartIndex until infoEndIndex).toByteArray() + @Suppress("UNCHECKED_CAST") + val responseInfo = JsonUtil.fromJson(infoBytes, Map::class.java) as Map<*, *> - val statusCode = responseInfo["code"].toString().toInt() + val statusCode = responseInfo["code"].toString().toInt() + + // clock out-of-sync special handling + if (statusCode == 406 || statusCode == 425) { + val body = "Your clock is out of sync with the service node network." + return Result.failure( + OnionError.ClockOutOfSync( + code = statusCode, + body = body + ) + ) + } - when (statusCode) { - 406, 425 -> throw OnionError.ClockOutOfSync(statusCode, responseInfo["result"]?.toString()) - !in 200..299 -> { - val responseBody = + if (statusCode !in 200..299) { + // For 400 from server, we might have a body in the second part + val responseBodySlice = if (destination is OnionDestination.ServerDestination && statusCode == 400) { plaintext.getBody(infoLength, infoEndIndex) } else null - val requireBlinding = - "Invalid authentication: this server requires the use of blinded ids" - - if (responseBody != null && responseBody.decodeToString() == requireBlinding) { - // You could introduce a dedicated error subtype if you want. - throw OnionError.DestinationError(400, requireBlinding) - } else { - throw OnionError.DestinationError(statusCode, responseBody?.decodeToString()) + val bodyStr = responseBodySlice?.decodeToString() + val bodyOrMsg = bodyStr ?: (responseInfo["message"]?.toString()) + + // Special case: require blinding message (still treated as destination error) + if (bodyStr == REQUIRE_BLINDING_MESSAGE) { + return Result.failure( + OnionError.DestinationError( + code = statusCode, + body = bodyStr + ) + ) } - } - } - - val responseBody = plaintext.getBody(infoLength, infoEndIndex) - - return if (responseBody.isEmpty()) { - OnionResponse(responseInfo, null) - } else { - OnionResponse(responseInfo, responseBody) - } - } - - private fun decodeLegacy( - response: ByteArray, - destination: OnionDestination, - destinationSymmetricKey: ByteArray - ): OnionResponse { - val json = try { - JsonUtil.fromJson(response, Map::class.java) - } catch (e: Exception) { - mapOf("result" to response.decodeToString()) - } - - val base64EncodedIVAndCiphertext = - json["result"] as? String ?: throw OnionError.InvalidResponse(response) - val ivAndCiphertext = Base64.decode(base64EncodedIVAndCiphertext) - - val plaintext = try { - AESGCM.decrypt(ivAndCiphertext, symmetricKey = destinationSymmetricKey) - } catch (e: Throwable) { - throw OnionError.InvalidResponse(response) - } - - val parsed = try { - JsonUtil.fromJson(plaintext.toString(Charsets.UTF_8), Map::class.java) - } catch (e: Exception) { - throw OnionError.InvalidResponse(plaintext) - } - - val statusCode = parsed["status_code"] as? Int ?: parsed["status"] as Int - - if (statusCode == 406) { - throw OnionError.ClockOutOfSync(statusCode, parsed["result"]?.toString()) - } - - if (parsed["body"] != null) { - @Suppress("UNCHECKED_CAST") - val body = if (parsed["body"] is Map<*, *>) { - parsed["body"] as Map<*, *> - } else { - val bodyAsString = parsed["body"] as String - JsonUtil.fromJson(bodyAsString, Map::class.java) - } - - if (statusCode != 200) { - throw OnionError.DestinationError(statusCode, body.toString()) + return Result.failure( + OnionError.DestinationError( + code = statusCode, + body = bodyOrMsg + ) + ) } - return OnionResponse(body, JsonUtil.toJson(body).toByteArray().view()) - } else { - if (statusCode != 200) { - throw OnionError.DestinationError(statusCode, parsed.toString()) + // 2xx: success. There may or may not be a body. + val responseBody = plaintext.getBody(infoLength, infoEndIndex) + return if (responseBody.isEmpty()) { + Result.success(OnionResponse(info = responseInfo, body = null)) + } else { + Result.success(OnionResponse(info = responseInfo, body = responseBody)) } - - return OnionResponse(parsed, JsonUtil.toJson(parsed).toByteArray().view()) + } catch (t: Throwable) { + return Result.failure(OnionError.InvalidResponse(response)) } } + /** + * V4 layout helper: extracts the optional body part from `lN:json...e`. + */ private fun ByteArray.getBody(infoLength: Int, infoEndIndex: Int): ByteArraySlice { val infoLengthStringLength = infoLength.toString().length + // minimum layout: l:e if (size <= infoLength + infoLengthStringLength + 2 /* l and e */) { return ByteArraySlice.EMPTY } + // There is extra data: parse the second length / body section. val dataSlice = view(infoEndIndex + 1 until size - 1) val dataSepIdx = dataSlice.asList().indexOfFirst { it.toInt() == ':'.code } + if (dataSepIdx == -1) return ByteArraySlice.EMPTY return dataSlice.view(dataSepIdx + 1 until dataSlice.len) } } From 3b6a66e861c6b46011028234275e5c7bf0e42668 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 12 Dec 2025 12:21:57 +1100 Subject: [PATCH 03/77] Error cleanup --- .../libsession/network/SessionNetwork.kt | 8 +++++--- .../libsession/network/model/OnionError.kt | 18 ++++++++++++++---- .../network/onion/http/HttpOnionTransport.kt | 18 ++++++++++-------- 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt index 61166482b6..218208d9e9 100644 --- a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt +++ b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt @@ -134,7 +134,8 @@ class SessionNetwork( false } is OnionError.GuardConnectionFailed, - is OnionError.GuardProtocolError, + is OnionError.PathError, + is OnionError.PathErrorNonPenalizing, is OnionError.IntermediateNodeFailed, is OnionError.InvalidResponse, is OnionError.Unknown -> { @@ -149,7 +150,7 @@ class SessionNetwork( private fun handleError(path: Path, error: OnionError) { when (error) { is OnionError.GuardConnectionFailed, - is OnionError.GuardProtocolError, + is OnionError.PathError, is OnionError.InvalidResponse, is OnionError.Unknown -> { // We don't know which hop is bad; drop the whole path. @@ -174,10 +175,11 @@ class SessionNetwork( } } + is OnionError.PathErrorNonPenalizing, is OnionError.DestinationError, is OnionError.ClockOutOfSync -> { // Path is considered healthy; do not mutate paths. - Log.d("Onion", "Application or clock error; not penalizing path") + Log.d("Onion", "Non penalizing error: $error") } } } diff --git a/app/src/main/java/org/session/libsession/network/model/OnionError.kt b/app/src/main/java/org/session/libsession/network/model/OnionError.kt index afa733b746..03ae16d50a 100644 --- a/app/src/main/java/org/session/libsession/network/model/OnionError.kt +++ b/app/src/main/java/org/session/libsession/network/model/OnionError.kt @@ -14,13 +14,23 @@ sealed class OnionError(message: String, cause: Throwable? = null) : Exception(m ) : OnionError("Failed to connect to guard ${guard.ip}:${guard.port}", underlying) /** - * Guard or intermediate nodes - specifically: errors not from the encrypred payload) + * Guard or intermediate nodes - specifically: errors not from the encrypted payload) */ - data class GuardProtocolError( - val guard: Snode?, + data class PathError( + val node: Snode?, val code: Int, val body: String? - ) : OnionError("Guard ${guard?.ip}:${guard?.port} error with staatus $code", null) + ) : OnionError("Node ${node?.ip}:${node?.port} error with status $code - Path penalizing", null) + + /** + * Guard or intermediate nodes - specifically: errors not from the encrypted payload) + * These errors should not penalize the path + */ + data class PathErrorNonPenalizing( + val node: Snode?, + val code: Int, + val body: String? + ) : OnionError("Node ${node?.ip}:${node?.port} error with status $code - NON Path penalizing", null) /** * The onion chain broke mid-path: one hop reported that the next node was not found. diff --git a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt index 50aa1d5f2e..7e5d6f225d 100644 --- a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt +++ b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt @@ -62,7 +62,7 @@ class HttpOnionTransport : OnionTransport { HTTP.execute(HTTP.Verb.POST, url, body) } catch (httpEx: HTTP.HTTPRequestFailedException) { // HTTP error from guard (we never got an onion-level response) - return Result.failure(mapGuardHttpError(guard, httpEx)) + return Result.failure(mapPathHttpError(guard, httpEx)) } catch (t: Throwable) { // TCP / DNS / TLS / timeout etc. reaching guard return Result.failure(OnionError.GuardConnectionFailed(guard, t)) @@ -78,10 +78,11 @@ class HttpOnionTransport : OnionTransport { } /** - * Map HTTP errors from the guard (before onion decryption) + * Map HTTP errors from the guard or intermediate nodes, whose errors are not encrypted + * (before onion decryption) */ - private fun mapGuardHttpError( - guard: Snode, + private fun mapPathHttpError( + node: Snode, ex: HTTP.HTTPRequestFailedException ): OnionError { val json = ex.json @@ -93,22 +94,23 @@ class HttpOnionTransport : OnionTransport { if (message != null && message.startsWith(prefix)) { val failedPk = message.removePrefix(prefix) return OnionError.IntermediateNodeFailed( - reportingNode = guard, + reportingNode = node, failedPublicKey = failedPk ) } // Non-penalising codes: treat as destination-level error (path OK) if (statusCode in NON_PENALIZING_STATUSES || message == "Loki Server error") { - return OnionError.DestinationError( + return OnionError.PathErrorNonPenalizing( + node = node, code = statusCode, body = message ) } // Otherwise: guard rejected / misbehaved - return OnionError.GuardProtocolError( - guard = guard, + return OnionError.PathError( + node = node, code = statusCode, body = message ) From 4ad98aeb6d82e8fccc3454024359d9219f4bdbfb Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 15 Dec 2025 11:31:31 +1100 Subject: [PATCH 04/77] Adding back snodeClock --- .../messaging/MessagingModuleConfiguration.kt | 2 +- .../sending_receiving/GroupMessageHandler.kt | 2 +- .../sending_receiving/MessageParser.kt | 2 +- .../MessageRequestResponseHandler.kt | 3 +- .../sending_receiving/MessageSender.kt | 2 +- .../ReceivedMessageHandler.kt | 2 +- .../VisibleMessageHandler.kt | 2 +- .../sending_receiving/pollers/Poller.kt | 2 +- .../libsession/network/SessionNetwork.kt | 15 ++- .../session/libsession/network/SnodeClock.kt | 113 ++++++++++++++++++ .../libsession/network/onion/PathManager.kt | 2 +- .../network/snode/SwarmDirectory.kt | 98 +++++++++++++++ .../securesms/configs/ConfigToDatabaseSync.kt | 5 +- .../securesms/configs/ConfigUploader.kt | 2 +- .../DisappearingMessages.kt | 3 +- .../conversation/v2/ConversationActivityV2.kt | 2 +- .../securesms/database/RecipientRepository.kt | 2 +- .../securesms/database/Storage.kt | 2 +- .../securesms/dependencies/ConfigFactory.kt | 2 +- .../dependencies/OnAppStartupComponents.kt | 2 +- .../securesms/groups/GroupManagerV2Impl.kt | 2 +- .../securesms/groups/GroupPoller.kt | 2 +- .../handler/RemoveGroupMemberHandler.kt | 2 +- .../securesms/home/HomeActivity.kt | 3 +- .../notifications/MarkReadProcessor.kt | 2 +- .../notifications/MarkReadReceiver.kt | 2 +- .../securesms/notifications/PushRegistryV2.kt | 2 +- .../notifications/RemoteReplyReceiver.kt | 3 +- .../prosettings/ProSettingsViewModel.kt | 2 +- .../securesms/pro/FetchProDetailsWorker.kt | 2 +- .../securesms/pro/ProDetailsRepository.kt | 2 +- .../securesms/pro/ProStatusManager.kt | 2 +- .../pro/RevocationListPollingWorker.kt | 2 +- .../securesms/pro/api/GenerateProProof.kt | 2 +- .../securesms/pro/api/GetProDetails.kt | 2 +- .../repository/ConversationRepository.kt | 2 +- .../securesms/reviews/InAppReviewManager.kt | 2 - .../service/ExpiringMessageManager.kt | 2 +- 38 files changed, 256 insertions(+), 47 deletions(-) create mode 100644 app/src/main/java/org/session/libsession/network/SnodeClock.kt create mode 100644 app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt diff --git a/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt b/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt index 7c3565f501..3938eaa3e8 100644 --- a/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt +++ b/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt @@ -9,7 +9,7 @@ import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.messaging.jobs.MessageSendJob import org.session.libsession.messaging.notifications.TokenFetcher -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.Device import org.session.libsession.utilities.TextSecurePreferences diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/GroupMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/GroupMessageHandler.kt index 8c4352c016..df8926bb6c 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/GroupMessageHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/GroupMessageHandler.kt @@ -12,7 +12,7 @@ import org.session.libsession.messaging.utilities.MessageAuthentication.buildDel import org.session.libsession.messaging.utilities.MessageAuthentication.buildGroupInviteSignature import org.session.libsession.messaging.utilities.MessageAuthentication.buildInfoChangeSignature import org.session.libsession.messaging.utilities.MessageAuthentication.buildMemberChangeSignature -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.protos.SessionProtos import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt index 89158d15ab..f5606fa6c3 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt @@ -18,7 +18,7 @@ import org.session.libsession.messaging.messages.control.TypingIndicator import org.session.libsession.messaging.messages.control.UnsendRequest import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.OpenGroupApi -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.TextSecurePreferences diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt index 280fba31e4..ea9fddbbd8 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt @@ -1,13 +1,12 @@ package org.session.libsession.messaging.sending_receiving import network.loki.messenger.libsession_util.protocol.DecodedPro -import network.loki.messenger.libsession_util.util.BitSet import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.ProfileUpdateHandler import org.session.libsession.messaging.messages.control.MessageRequestResponse import org.session.libsession.messaging.messages.signal.IncomingMediaMessage import org.session.libsession.messaging.messages.visible.VisibleMessage -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt index c4c453dc57..7f5bcf2e58 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt @@ -29,7 +29,7 @@ import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupApi.Capability import org.session.libsession.messaging.open_groups.OpenGroupMessage import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.snode.SnodeMessage import org.session.libsession.utilities.Address import org.session.libsession.utilities.ConfigFactoryProtocol diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt index bebad8d658..c2b1b39dbf 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt @@ -48,7 +48,7 @@ import org.session.libsession.messaging.utilities.MessageAuthentication.buildInf import org.session.libsession.messaging.utilities.MessageAuthentication.buildMemberChangeSignature import org.session.libsession.messaging.utilities.WebRtcUtils import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt index ef5bd90cfe..5ea5fc72f2 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt @@ -19,7 +19,7 @@ import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.sending_receiving.attachments.PointerAttachment import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.SSKEnvironment diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt index 27454527ed..2765da49d2 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt @@ -31,7 +31,7 @@ import org.session.libsession.messaging.messages.Message.Companion.senderOrSync import org.session.libsession.messaging.sending_receiving.MessageParser import org.session.libsession.messaging.sending_receiving.ReceivedMessageProcessor import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.snode.model.RetrieveMessageResponse import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address diff --git a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt index 218208d9e9..1be8613c57 100644 --- a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt +++ b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt @@ -112,11 +112,13 @@ class SessionNetwork( handleError(path, error) - if (!mustRetry(error, attempt)) { + if (!shouldRetry(error, attempt)) { return Result.failure(error) } lastError = error + + //todo ONION we might want some backoff/delay logic here } return Result.failure(lastError ?: IllegalStateException("Unknown onion error")) @@ -125,9 +127,10 @@ class SessionNetwork( /** * Decide whether to retry based on the error type and current attempt. */ - private fun mustRetry(error: OnionError, attempt: Int): Boolean { + private fun shouldRetry(error: OnionError, attempt: Int): Boolean { if (attempt + 1 >= maxAttempts) return false + //todo ONION I'm making assumptions here - this is for the low level SessionNetwork reties. Might want to fully define this return when (error) { is OnionError.DestinationError, is OnionError.ClockOutOfSync -> { @@ -176,11 +179,15 @@ class SessionNetwork( } is OnionError.PathErrorNonPenalizing, - is OnionError.DestinationError, - is OnionError.ClockOutOfSync -> { + is OnionError.DestinationError -> { // Path is considered healthy; do not mutate paths. Log.d("Onion", "Non penalizing error: $error") } + + is OnionError.ClockOutOfSync -> { + // todo ONION - should we reset the SnodeClock? + Log.d("Onion", "Clock out of sync (non-penalizing): $error") + } } } diff --git a/app/src/main/java/org/session/libsession/network/SnodeClock.kt b/app/src/main/java/org/session/libsession/network/SnodeClock.kt new file mode 100644 index 0000000000..78f3d67ab8 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/SnodeClock.kt @@ -0,0 +1,113 @@ +@Singleton +class SnodeClock @Inject constructor( + @ManagerScope private val scope: CoroutineScope, + private val snodeDirectory: SnodeDirectory, + private val sessionNetwork: SessionNetwork +) : OnAppStartupComponent { + + private val instantState = MutableStateFlow(null) + private var job: Job? = null + + override fun onPostAppStarted() { + require(job == null) { "Already started" } + + job = scope.launch { + while (true) { + try { + val node = pickRandomSnode() ?: run { + Log.e("SnodeClock", "No snodes available in pool; cannot query network time.") + delay(3_000L) + continue + } + + val requestStarted = SystemClock.elapsedRealtime() + + val networkTime = fetchNetworkTime(node) + + val requestEnded = SystemClock.elapsedRealtime() + var adjustedNetworkTime = networkTime - (requestEnded - requestStarted) / 2 + + val inst = Instant(requestStarted, adjustedNetworkTime) + + Log.d("SnodeClock", "Network time: ${Date(inst.now())}, system time: ${Date()}") + + instantState.value = inst + } catch (e: Exception) { + Log.e("SnodeClock", "Failed to get network time. Retrying in a few seconds", e) + } finally { + val delayMills = if (instantState.value == null) { + 3_000L + } else { + 3_600_000L + } + delay(delayMills) + } + } + } + } + + private fun pickRandomSnode(): Snode? { + val pool = snodeDirectory.getSnodePool() + if (pool.isEmpty()) return null + return pool.random() + } + + private suspend fun fetchNetworkTime(snode: Snode): Long { + val result = sessionNetwork.sendToSnode( + method = Snode.Method.Info, + parameters = emptyMap(), + snode = snode, + version = Version.V4 + ) + + if (result.isFailure) { + throw result.exceptionOrNull() + ?: IllegalStateException("Unknown error getting network time") + } + + val response = result.getOrThrow() + val body = response.body ?: error("Empty body for Info RPC") + + @Suppress("UNCHECKED_CAST") + val json = JsonUtil.fromJson(body, Map::class.java) as Map<*, *> + val timestamp = json["timestamp"] as? Long + ?: throw IllegalStateException("Missing timestamp in Info response") + + return timestamp + } + + // rest of your SnodeClock unchanged... + + suspend fun waitForNetworkAdjustedTime(): Long = + instantState.filterNotNull().first().now() + + fun currentTimeMills(): Long = + instantState.value?.now() ?: System.currentTimeMillis() + + fun currentTimeSeconds(): Long = + currentTimeMills() / 1000 + + fun currentTime(): java.time.Instant = + java.time.Instant.ofEpochMilli(currentTimeMills()) + + suspend fun delayUntil(instant: java.time.Instant): Boolean { + val now = currentTimeMills() + val target = instant.toEpochMilli() + return if (target > now) { + delay(target - now) + true + } else { + target == now + } + } + + private class Instant( + val systemUptime: Long, + val networkTime: Long, + ) { + fun now(): Long { + val elapsed = SystemClock.elapsedRealtime() - systemUptime + return networkTime + elapsed + } + } +} diff --git a/app/src/main/java/org/session/libsession/network/onion/PathManager.kt b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt index 23f0d289f4..96efd07602 100644 --- a/app/src/main/java/org/session/libsession/network/onion/PathManager.kt +++ b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt @@ -23,7 +23,7 @@ import org.session.libsignal.utilities.Snode class PathManager( private val scope: CoroutineScope, private val directory: SnodeDirectory, - private val storage: SnodePathStorage, // mapping of old get/setOnionRequestPaths + private val storage: SnodePathStorage, private val pathSize: Int = 3, private val targetPathCount: Int = 2, ) { diff --git a/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt b/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt new file mode 100644 index 0000000000..46b35f7803 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt @@ -0,0 +1,98 @@ +package org.session.libsession.network.snode + +import org.session.libsession.network.SessionNetwork +import org.session.libsession.network.onion.Version +import org.session.libsignal.utilities.JsonUtil +import org.session.libsignal.utilities.Snode + +class SwarmDirectory( + private val storage: SwarmStorage, + private val snodeDirectory: SnodeDirectory, + private val sessionNetwork: SessionNetwork, + private val minimumSwarmSize: Int = 3 +) { + + suspend fun getSwarm(publicKey: String): Set { + val cached = storage.getSwarm(publicKey) + if (cached != null && cached.size >= minimumSwarmSize) { + return cached + } + + val fresh = fetchSwarm(publicKey) + storage.setSwarm(publicKey, fresh) + return fresh + } + + suspend fun fetchSwarm(publicKey: String): Set { + val pool = snodeDirectory.getSnodePool() + require(pool.isNotEmpty()) { + "Snode pool is empty" + } + + val randomSnode = pool.random() + + val params = mapOf("pubKey" to publicKey) + + val result = sessionNetwork.sendToSnode( + method = Snode.Method.GetSwarm, + parameters = params, + snode = randomSnode, + version = Version.V4 + ) + + if (result.isFailure) { + throw result.exceptionOrNull() ?: IllegalStateException("Unknown swarm error") + } + + val onionResponse = result.getOrThrow() + val body = onionResponse.body ?: error("Empty GetSwarm body") + val json = JsonUtil.fromJson(body, Map::class.java) as Map<*, *> + + return parseSnodes(json).toSet() + } + + fun dropSnodeFromSwarmIfNeeded(snode: Snode, publicKey: String) { + val current = storage.getSwarm(publicKey) ?: return + if (snode !in current) return + + val updated = current - snode + storage.setSwarm(publicKey, updated) + } + + /** + * Expected response shape: + * { "snodes": [ { "ip": "...", "port": "443", "pubkey_ed25519": "...", "pubkey_x25519": "..." }, ... ] } + */ + @Suppress("UNCHECKED_CAST") + private fun parseSnodes(rawResponse: Map<*, *>): List { + val list = rawResponse["snodes"] as? List<*> ?: emptyList() + return list.asSequence() + .mapNotNull { it as? Map<*, *> } + .mapNotNull { raw -> + createSnode( + address = raw["ip"] as? String, + port = (raw["port"] as? String)?.toInt(), + ed25519Key = raw["pubkey_ed25519"] as? String, + x25519Key = raw["pubkey_x25519"] as? String + ) + } + .toList() + } + + private fun createSnode( + address: String?, + port: Int?, + ed25519Key: String?, + x25519Key: String? + ): Snode? { + return Snode( + address?.takeUnless { it == "0.0.0.0" }?.let { "https://$it" } ?: return null, + port ?: return null, + Snode.KeySet( + ed25519Key ?: return null, + x25519Key ?: return null + ), + Snode.Version.ZERO // or parse from response if present + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt index 4abe47990e..30908865db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt @@ -21,12 +21,11 @@ import org.session.libsession.messaging.sending_receiving.notifications.MessageN import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1 import org.session.libsession.snode.OwnedSwarmAuth import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.UserConfigType import org.session.libsession.utilities.allConfigAddresses import org.session.libsession.utilities.getGroup @@ -38,7 +37,6 @@ import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.auth.AuthAwareComponent import org.thoughtcrime.securesms.auth.LoggedInState -import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.database.CommunityDatabase import org.thoughtcrime.securesms.database.DraftDatabase import org.thoughtcrime.securesms.database.GroupDatabase @@ -53,7 +51,6 @@ import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.dependencies.ManagerScope -import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.util.SessionMetaProtocol import org.thoughtcrime.securesms.util.castAwayType diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt index 20892a7aa8..720189d74f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt @@ -23,7 +23,7 @@ import org.session.libsession.database.userAuth import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.OwnedSwarmAuth import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.snode.SnodeMessage import org.session.libsession.snode.SwarmAuth import org.session.libsession.snode.model.StoreMessageResponse diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt index ffdea7cb52..33d31d0938 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt @@ -8,13 +8,12 @@ import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.sending_receiving.MessageSender -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.ExpirationUtil import org.session.libsession.utilities.SSKEnvironment.MessageExpirationManagerProtocol import org.session.libsession.utilities.StringSubstitutionConstants.DISAPPEARING_MESSAGES_TYPE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.isGroupV2 import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.AccountId diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 9a2f871fe5..4020b60a3f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -98,7 +98,7 @@ import org.session.libsession.messaging.sending_receiving.link_preview.LinkPrevi import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.MediaTypes diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt index 2cd4c8c8d9..57ad48133b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt @@ -33,7 +33,7 @@ import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.GroupInfo import org.session.libsession.messaging.open_groups.GroupMemberRole import org.session.libsession.messaging.open_groups.OpenGroupApi -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index a52279598e..511930d215 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -44,7 +44,7 @@ import org.session.libsession.messaging.sending_receiving.notifications.MessageN import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel import org.session.libsession.messaging.utilities.UpdateMessageData import org.session.libsession.snode.OnionRequestAPI -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.Address.Companion.toAddress diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt index 9bf4e762ca..12c77e5a7c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt @@ -16,7 +16,7 @@ import network.loki.messenger.libsession_util.util.ConfigPush import network.loki.messenger.libsession_util.util.MultiEncrypt import org.session.libsession.database.StorageProtocol import org.session.libsession.snode.OwnedSwarmAuth -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.snode.SwarmAuth import org.session.libsession.utilities.Address import org.session.libsession.utilities.ConfigFactoryProtocol diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/OnAppStartupComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/OnAppStartupComponents.kt index 5276e16650..647c928eb8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/OnAppStartupComponents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/OnAppStartupComponents.kt @@ -2,7 +2,7 @@ package org.thoughtcrime.securesms.dependencies import org.session.libsession.messaging.notifications.TokenFetcher import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerManager -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.thoughtcrime.securesms.auth.AuthAwareComponentsHandler import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.disguise.AppDisguiseManager diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt index 03d1ef94c6..4f63b0ca87 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -39,7 +39,7 @@ import org.session.libsession.messaging.utilities.MessageAuthentication.buildMem import org.session.libsession.messaging.utilities.UpdateMessageData import org.session.libsession.snode.OwnedSwarmAuth import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.snode.SnodeMessage import org.session.libsession.snode.model.BatchResponse import org.session.libsession.snode.utilities.await diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt index fd8e8f3e5c..348a490438 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt @@ -23,7 +23,7 @@ import network.loki.messenger.libsession_util.Namespace import org.session.libsession.messaging.sending_receiving.MessageParser import org.session.libsession.messaging.sending_receiving.ReceivedMessageProcessor import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.snode.model.BatchResponse import org.session.libsession.snode.model.RetrieveMessageResponse import org.session.libsession.utilities.Address diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt index 7e598e3229..a14380620f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt @@ -22,7 +22,7 @@ import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.MessageAuthentication import org.session.libsession.snode.OwnedSwarmAuth import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.snode.SnodeMessage import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 2c0b5410f8..063e1facd4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -14,7 +14,6 @@ import androidx.compose.animation.Crossfade import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -50,7 +49,7 @@ import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier import org.session.libsession.snode.OnionRequestAPI -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt index 04cfd04597..8bc7eaad5e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt @@ -9,7 +9,7 @@ import org.session.libsession.database.userAuth import org.session.libsession.messaging.messages.control.ReadReceipt import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled import org.session.libsession.utilities.associateByNotNull import org.session.libsession.utilities.isGroupOrCommunity diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt index 6b1cd80d4d..bc2bce320c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt @@ -8,7 +8,7 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import org.session.libsession.database.StorageProtocol -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsignal.utilities.Log import javax.inject.Inject diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt index a83c0b882d..fdf9dfca90 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt @@ -18,7 +18,7 @@ import org.session.libsession.messaging.sending_receiving.notifications.Subscrip import org.session.libsession.messaging.sending_receiving.notifications.UnsubscribeResponse import org.session.libsession.messaging.sending_receiving.notifications.UnsubscriptionRequest import org.session.libsession.snode.OnionRequestAPI -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.snode.SwarmAuth import org.session.libsession.snode.Version import org.session.libsession.snode.utilities.await diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.kt index 40513eaec5..19da51fe10 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.kt @@ -28,9 +28,8 @@ import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingTextMessage import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.sending_receiving.MessageSender -import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.MmsDatabase diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index 163b35ce7a..5c5abfef6e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -28,7 +28,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.database.StorageProtocol -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.NonTranslatableStringConstants import org.session.libsession.utilities.StringSubstitutionConstants.ACTION_TYPE_KEY diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt index 59e17d357b..b041bbc69a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt @@ -18,7 +18,7 @@ import dagger.assisted.AssistedInject import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.mapNotNull -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.utilities.Log diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDetailsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDetailsRepository.kt index c674d04cbc..a6f9c15dbd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDetailsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDetailsRepository.kt @@ -11,7 +11,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.debugmenu.DebugLogGroup diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt index cc5689cd01..71ea49f4f4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -41,7 +41,7 @@ import network.loki.messenger.libsession_util.util.Conversation import network.loki.messenger.libsession_util.util.Util import network.loki.messenger.libsession_util.util.asSequence import org.session.libsession.messaging.messages.visible.VisibleMessage -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.UserConfigType diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/RevocationListPollingWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/RevocationListPollingWorker.kt index 6c4b238fd6..c4b3fdafd6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/RevocationListPollingWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/RevocationListPollingWorker.kt @@ -13,7 +13,7 @@ import androidx.work.WorkerParameters import androidx.work.await import dagger.assisted.Assisted import dagger.assisted.AssistedInject -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.pro.api.GetProRevocationRequest diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GenerateProProof.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GenerateProProof.kt index bc0c2ae364..7935b251c8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GenerateProProof.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GenerateProProof.kt @@ -6,7 +6,7 @@ import dagger.assisted.AssistedInject import kotlinx.serialization.DeserializationStrategy import network.loki.messenger.libsession_util.pro.BackendRequests import network.loki.messenger.libsession_util.pro.ProProof -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock class GenerateProProofRequest @AssistedInject constructor( @Assisted("master") private val masterPrivateKey: ByteArray, diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetails.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetails.kt index d8a117399c..a1734b07f0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetails.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetails.kt @@ -8,7 +8,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import network.loki.messenger.libsession_util.pro.BackendRequests import network.loki.messenger.libsession_util.pro.PaymentProvider -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.serializable.InstantAsMillisSerializer import java.time.Instant diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index 0e87c37208..dab8940152 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -29,7 +29,7 @@ import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.TextSecurePreferences diff --git a/app/src/main/java/org/thoughtcrime/securesms/reviews/InAppReviewManager.kt b/app/src/main/java/org/thoughtcrime/securesms/reviews/InAppReviewManager.kt index 25df99c30c..b00d41ffbb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reviews/InAppReviewManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/reviews/InAppReviewManager.kt @@ -23,7 +23,6 @@ import kotlinx.coroutines.flow.scan import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.serialization.json.Json -import org.session.libsession.snode.SnodeClock import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.dependencies.ManagerScope @@ -31,7 +30,6 @@ import java.util.EnumSet import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.days -import kotlin.time.Duration.Companion.seconds @OptIn(DelicateCoroutinesApi::class) @Singleton diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt index 696259092b..08480c3e67 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt @@ -10,7 +10,7 @@ import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.messages.signal.IncomingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.Address.Companion.toAddress From 635642f20c93b48c2d824b7449e5e7b860d0a314 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 15 Dec 2025 11:31:51 +1100 Subject: [PATCH 05/77] Old snodeclock --- .../session/libsession/network/SnodeClock.kt | 114 +++++++++--------- 1 file changed, 60 insertions(+), 54 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/SnodeClock.kt b/app/src/main/java/org/session/libsession/network/SnodeClock.kt index 78f3d67ab8..9ca1163dd4 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClock.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClock.kt @@ -1,10 +1,32 @@ +package org.session.libsession.network + +import android.os.SystemClock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import java.util.Date +import javax.inject.Inject +import javax.inject.Singleton + +/** + * A class that manages the network time by querying the network time from a random snode. The + * primary goal of this class is to provide a time that is not tied to current system time and not + * prone to time changes locally. + * + * Before the first network query is successfully, calling [currentTimeMills] will return the current + * system time. + */ @Singleton class SnodeClock @Inject constructor( - @ManagerScope private val scope: CoroutineScope, - private val snodeDirectory: SnodeDirectory, - private val sessionNetwork: SessionNetwork + @param:ManagerScope private val scope: CoroutineScope ) : OnAppStartupComponent { - private val instantState = MutableStateFlow(null) private var job: Job? = null @@ -14,20 +36,17 @@ class SnodeClock @Inject constructor( job = scope.launch { while (true) { try { - val node = pickRandomSnode() ?: run { - Log.e("SnodeClock", "No snodes available in pool; cannot query network time.") - delay(3_000L) - continue - } - + val node = SnodeAPI.getRandomSnode().await() val requestStarted = SystemClock.elapsedRealtime() - val networkTime = fetchNetworkTime(node) - + var networkTime = SnodeAPI.getNetworkTime(node).await().second val requestEnded = SystemClock.elapsedRealtime() - var adjustedNetworkTime = networkTime - (requestEnded - requestStarted) / 2 - val inst = Instant(requestStarted, adjustedNetworkTime) + // Adjust the network time to account for the time it took to make the request + // so that the network time equals to the time when the request was started + networkTime -= (requestEnded - requestStarted) / 2 + + val inst = Instant(requestStarted, networkTime) Log.d("SnodeClock", "Network time: ${Date(inst.now())}, system time: ${Date()}") @@ -35,61 +54,48 @@ class SnodeClock @Inject constructor( } catch (e: Exception) { Log.e("SnodeClock", "Failed to get network time. Retrying in a few seconds", e) } finally { + // Retry frequently if we haven't got any result before val delayMills = if (instantState.value == null) { 3_000L } else { - 3_600_000L + 3600_000L } + delay(delayMills) } } } } - private fun pickRandomSnode(): Snode? { - val pool = snodeDirectory.getSnodePool() - if (pool.isEmpty()) return null - return pool.random() + /** + * Wait for the network adjusted time to come through. + */ + suspend fun waitForNetworkAdjustedTime(): Long { + return instantState.filterNotNull().first().now() } - private suspend fun fetchNetworkTime(snode: Snode): Long { - val result = sessionNetwork.sendToSnode( - method = Snode.Method.Info, - parameters = emptyMap(), - snode = snode, - version = Version.V4 - ) - - if (result.isFailure) { - throw result.exceptionOrNull() - ?: IllegalStateException("Unknown error getting network time") - } - - val response = result.getOrThrow() - val body = response.body ?: error("Empty body for Info RPC") - - @Suppress("UNCHECKED_CAST") - val json = JsonUtil.fromJson(body, Map::class.java) as Map<*, *> - val timestamp = json["timestamp"] as? Long - ?: throw IllegalStateException("Missing timestamp in Info response") - - return timestamp + /** + * Get the current time in milliseconds. If the network time is not available yet, this method + * will return the current system time. + */ + fun currentTimeMills(): Long { + return instantState.value?.now() ?: System.currentTimeMillis() } - // rest of your SnodeClock unchanged... - - suspend fun waitForNetworkAdjustedTime(): Long = - instantState.filterNotNull().first().now() - - fun currentTimeMills(): Long = - instantState.value?.now() ?: System.currentTimeMillis() - - fun currentTimeSeconds(): Long = - currentTimeMills() / 1000 + fun currentTimeSeconds(): Long { + return currentTimeMills() / 1000 + } - fun currentTime(): java.time.Instant = - java.time.Instant.ofEpochMilli(currentTimeMills()) + fun currentTime(): java.time.Instant { + return java.time.Instant.ofEpochMilli(currentTimeMills()) + } + /** + * Delay until the specified instant. If the instant is in the past or now, this method returns + * immediately. + * + * @return true if delayed, false if the instant is in the past + */ suspend fun delayUntil(instant: java.time.Instant): Boolean { val now = currentTimeMills() val target = instant.toEpochMilli() @@ -110,4 +116,4 @@ class SnodeClock @Inject constructor( return networkTime + elapsed } } -} +} \ No newline at end of file From ce89777119622b25aa3ae263aeb016f3cd94253a Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 15 Dec 2025 12:05:07 +1100 Subject: [PATCH 06/77] bootstrap logic in snodedirectory --- .../session/libsession/network/SnodeClock.kt | 20 +-- .../network/snode/SnodeDirectory.kt | 135 +++++++++++++++++- 2 files changed, 145 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/SnodeClock.kt b/app/src/main/java/org/session/libsession/network/SnodeClock.kt index 9ca1163dd4..feaace83a2 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClock.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClock.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import org.session.libsession.network.snode.SnodeDirectory import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent @@ -25,8 +26,10 @@ import javax.inject.Singleton */ @Singleton class SnodeClock @Inject constructor( - @param:ManagerScope private val scope: CoroutineScope + @param:ManagerScope private val scope: CoroutineScope, + private val snodeDirectory: SnodeDirectory, ) : OnAppStartupComponent { + private val instantState = MutableStateFlow(null) private var job: Job? = null @@ -36,29 +39,30 @@ class SnodeClock @Inject constructor( job = scope.launch { while (true) { try { - val node = SnodeAPI.getRandomSnode().await() + val node = snodeDirectory.getRandomSnode() val requestStarted = SystemClock.elapsedRealtime() var networkTime = SnodeAPI.getNetworkTime(node).await().second val requestEnded = SystemClock.elapsedRealtime() - // Adjust the network time to account for the time it took to make the request - // so that the network time equals to the time when the request was started + // Adjust network time to halfway through the request duration networkTime -= (requestEnded - requestStarted) / 2 val inst = Instant(requestStarted, networkTime) - Log.d("SnodeClock", "Network time: ${Date(inst.now())}, system time: ${Date()}") + Log.d( + "SnodeClock", + "Network time: ${Date(inst.now())}, system time: ${Date()}" + ) instantState.value = inst } catch (e: Exception) { Log.e("SnodeClock", "Failed to get network time. Retrying in a few seconds", e) } finally { - // Retry frequently if we haven't got any result before val delayMills = if (instantState.value == null) { 3_000L } else { - 3600_000L + 3_600_000L } delay(delayMills) @@ -116,4 +120,4 @@ class SnodeClock @Inject constructor( return networkTime + elapsed } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt b/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt index 16ddf1010d..feae06cbaf 100644 --- a/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt +++ b/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt @@ -1,19 +1,150 @@ package org.session.libsession.network.snode - -import org.session.libsignal.utilities.Snode +import org.session.libsession.utilities.Environment import org.session.libsignal.crypto.secureRandom +import org.session.libsignal.utilities.HTTP +import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Snode +import org.session.libsignal.utilities.prettifiedDescription class SnodeDirectory( private val storage: SnodePoolStorage, + private val environment: Environment, ) { + + companion object { + // Old SnodeAPI used these defaults + private const val MINIMUM_SNODE_POOL_COUNT = 12 + // Use port 4443 to enforce pinned certificates (same as old seedNodePort) + private const val SEED_NODE_PORT = 4443 + + private const val KEY_IP = "public_ip" + private const val KEY_PORT = "storage_port" + private const val KEY_X25519 = "pubkey_x25519" + private const val KEY_ED25519 = "pubkey_ed25519" + private const val KEY_VERSION = "storage_server_version" + } + + private val seedNodePool: Set = when (environment) { + Environment.DEV_NET -> setOf("http://sesh-net.local:1280") + Environment.TEST_NET -> setOf("http://public.loki.foundation:38157") + Environment.MAIN_NET -> setOf( + "https://seed1.getsession.org:$SEED_NODE_PORT", + "https://seed2.getsession.org:$SEED_NODE_PORT", + "https://seed3.getsession.org:$SEED_NODE_PORT", + ) + } + + private val getRandomSnodeParams: Map = buildMap { + this["method"] = "get_n_service_nodes" + this["params"] = buildMap { + this["active_only"] = true + this["fields"] = sequenceOf(KEY_IP, KEY_PORT, KEY_X25519, KEY_ED25519, KEY_VERSION) + .associateWith { true } + } + } + fun getSnodePool(): Set = storage.getSnodePool() fun updateSnodePool(newPool: Set) { storage.setSnodePool(newPool) } + /** + * Returns a random snode from the generic snode pool. + * + * Behaviour: + * - If the pool has at least [MINIMUM_SNODE_POOL_COUNT] nodes, return a random one. + * - Otherwise, bootstrap the pool from a random seed node (get_n_service_nodes), + * persist it, and return a random snode from the new pool. + * + * Throws if, after bootstrap, the pool is still empty or parsing fails. + */ + suspend fun getRandomSnode(): Snode { + val pool = getSnodePool() + if (pool.size >= MINIMUM_SNODE_POOL_COUNT) { + return pool.secureRandom() + } + + // Pool too small or empty: bootstrap from a seed node. + val target = seedNodePool.random() + Log.d("SnodeDirectory", "Populating snode pool using seed node: $target") + + val url = "$target/json_rpc" + val responseBytes = HTTP.execute( + HTTP.Verb.POST, + url = url, + parameters = getRandomSnodeParams, + useSeedNodeConnection = true + ) + + val json = runCatching { + JsonUtil.fromJson(responseBytes, Map::class.java) + }.getOrNull() ?: buildMap { + this["result"] = responseBytes.toString(Charsets.UTF_8) + } + + @Suppress("UNCHECKED_CAST") + val intermediate = json["result"] as? Map<*, *> + ?: throw IllegalStateException("Failed to update snode pool, 'result' was null.") + .also { Log.d("SnodeDirectory", "Failed to update snode pool, intermediate was null.") } + + @Suppress("UNCHECKED_CAST") + val rawSnodes = intermediate["service_node_states"] as? List<*> + ?: throw IllegalStateException("Failed to update snode pool, 'service_node_states' was null.") + .also { Log.d("SnodeDirectory", "Failed to update snode pool, rawSnodes was null.") } + + val newPool = rawSnodes.asSequence() + .mapNotNull { it as? Map<*, *> } + .mapNotNull { raw -> + createSnode( + address = raw[KEY_IP] as? String, + port = raw[KEY_PORT] as? Int, + ed25519Key = raw[KEY_ED25519] as? String, + x25519Key = raw[KEY_X25519] as? String, + version = (raw[KEY_VERSION] as? List<*>) + ?.filterIsInstance() + ?.let(Snode::Version) + ).also { + if (it == null) { + Log.d( + "SnodeDirectory", + "Failed to parse snode from: ${raw.prettifiedDescription()}." + ) + } + } + } + .toSet() + + if (newPool.isEmpty()) { + throw IllegalStateException("Seed node returned empty snode pool") + } + + Log.d("SnodeDirectory", "Persisting snode pool with ${newPool.size} snodes.") + updateSnodePool(newPool) + + return newPool.secureRandom() + } + + /** + * Shared snode factory used by both seed bootstrap and (later) swarm parsing. + */ + fun createSnode( + address: String?, + port: Int?, + ed25519Key: String?, + x25519Key: String?, + version: Snode.Version? = Snode.Version.ZERO + ): Snode? { + return Snode( + address?.takeUnless { it == "0.0.0.0" }?.let { "https://$it" } ?: return null, + port ?: return null, + Snode.KeySet(ed25519Key ?: return null, x25519Key ?: return null), + version ?: return null + ) + } + fun getGuardSnodes( existingGuards: Set, targetGuardCount: Int From e289b08de8f63b65b627eda2e05e1d67869b6949 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 15 Dec 2025 12:07:30 +1100 Subject: [PATCH 07/77] Separate population of the snode pool --- .../network/snode/SnodeDirectory.kt | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt b/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt index feae06cbaf..8a73bdcda8 100644 --- a/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt +++ b/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt @@ -52,19 +52,20 @@ class SnodeDirectory( } /** - * Returns a random snode from the generic snode pool. + * Ensure the snode pool is populated to at least [minCount] elements. * - * Behaviour: - * - If the pool has at least [MINIMUM_SNODE_POOL_COUNT] nodes, return a random one. - * - Otherwise, bootstrap the pool from a random seed node (get_n_service_nodes), - * persist it, and return a random snode from the new pool. + * - If the current pool is already large enough, returns it unchanged. + * - Otherwise, bootstraps from a random seed node (get_n_service_nodes), + * persists the new pool, and returns it. * - * Throws if, after bootstrap, the pool is still empty or parsing fails. + * Throws if the seed node returns an empty list or parsing fails. */ - suspend fun getRandomSnode(): Snode { - val pool = getSnodePool() - if (pool.size >= MINIMUM_SNODE_POOL_COUNT) { - return pool.secureRandom() + suspend fun ensurePoolPopulated( + minCount: Int = MINIMUM_SNODE_POOL_COUNT + ): Set { + val current = getSnodePool() + if (current.size >= minCount) { + return current } // Pool too small or empty: bootstrap from a seed node. @@ -124,12 +125,21 @@ class SnodeDirectory( Log.d("SnodeDirectory", "Persisting snode pool with ${newPool.size} snodes.") updateSnodePool(newPool) - return newPool.secureRandom() + return newPool } /** - * Shared snode factory used by both seed bootstrap and (later) swarm parsing. + * Returns a random snode from the generic snode pool. + * + * Uses [ensurePoolPopulated] under the hood, so callers get the old semantics: + * lazy bootstrap on first use, but we also expose [ensurePoolPopulated] for + * explicit bootstrap at app startup or before heavy operations. */ + suspend fun getRandomSnode(): Snode { + val pool = ensurePoolPopulated() + return pool.secureRandom() + } + fun createSnode( address: String?, port: Int?, From 07e2e5be3ebfe968d5970318e23d2263d8e02727 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 15 Dec 2025 12:09:28 +1100 Subject: [PATCH 08/77] MAke sure we populate the snode pool on app startup --- .../network/snode/SnodeDirectory.kt | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt b/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt index 8a73bdcda8..b787d7031f 100644 --- a/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt +++ b/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt @@ -1,5 +1,7 @@ package org.session.libsession.network.snode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import org.session.libsession.utilities.Environment import org.session.libsignal.crypto.secureRandom import org.session.libsignal.utilities.HTTP @@ -7,16 +9,20 @@ import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode import org.session.libsignal.utilities.prettifiedDescription +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import javax.inject.Inject +import javax.inject.Singleton -class SnodeDirectory( +@Singleton +class SnodeDirectory @Inject constructor( private val storage: SnodePoolStorage, private val environment: Environment, -) { + @ManagerScope private val scope: CoroutineScope, +) : OnAppStartupComponent { companion object { - // Old SnodeAPI used these defaults private const val MINIMUM_SNODE_POOL_COUNT = 12 - // Use port 4443 to enforce pinned certificates (same as old seedNodePort) private const val SEED_NODE_PORT = 4443 private const val KEY_IP = "public_ip" @@ -45,6 +51,19 @@ class SnodeDirectory( } } + override fun onPostAppStarted() { + // Ensure we have a populated snode pool on launch + scope.launch { + try { + ensurePoolPopulated() + Log.d("SnodeDirectory", "Snode pool populated on startup.") + } catch (e: Exception) { + Log.e("SnodeDirectory", "Failed to populate snode pool on startup", e) + //todo ONION should we have a failsafe here or is it ok ro rely on future call to getRandomSnode? + } + } + } + fun getSnodePool(): Set = storage.getSnodePool() fun updateSnodePool(newPool: Set) { @@ -68,7 +87,6 @@ class SnodeDirectory( return current } - // Pool too small or empty: bootstrap from a seed node. val target = seedNodePool.random() Log.d("SnodeDirectory", "Populating snode pool using seed node: $target") @@ -131,9 +149,8 @@ class SnodeDirectory( /** * Returns a random snode from the generic snode pool. * - * Uses [ensurePoolPopulated] under the hood, so callers get the old semantics: - * lazy bootstrap on first use, but we also expose [ensurePoolPopulated] for - * explicit bootstrap at app startup or before heavy operations. + * Uses [ensurePoolPopulated] under the hood, so you still get lazy bootstrap if + * startup population failed or hasn’t run yet. */ suspend fun getRandomSnode(): Snode { val pool = ensurePoolPopulated() From abba38c979b10571f80b1c3f4468849308468064 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 15 Dec 2025 12:15:47 +1100 Subject: [PATCH 09/77] Making sure we populate the snode pool when building the path(s) --- .../java/org/session/libsession/network/onion/PathManager.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/session/libsession/network/onion/PathManager.kt b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt index 96efd07602..c0c85cafbf 100644 --- a/app/src/main/java/org/session/libsession/network/onion/PathManager.kt +++ b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt @@ -79,6 +79,9 @@ class PathManager( _isBuilding.value = true try { + // Ensure we actually have a usable pool before doing anything + val pool = directory.ensurePoolPopulated() + val safeReusable = sanitizePaths(reusablePaths) val reusableGuards = safeReusable.map { it.first() }.toSet() @@ -87,7 +90,7 @@ class PathManager( targetGuardCount = targetPathCount ) - var unused = directory.getSnodePool() + var unused = pool .minus(guardSnodes) .minus(safeReusable.flatten().toSet()) From 1c36a3377294357b4af92460b32463ff6821bddf Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 15 Dec 2025 15:35:22 +1100 Subject: [PATCH 10/77] SessionClient first step --- .../libsession/network/SessionClient.kt | 239 ++++++++++++++++++ .../libsession/network/model/SnodeMessage.kt | 38 +++ .../libsession/network/model/SwarmAuth.kt | 17 ++ 3 files changed, 294 insertions(+) create mode 100644 app/src/main/java/org/session/libsession/network/SessionClient.kt create mode 100644 app/src/main/java/org/session/libsession/network/model/SnodeMessage.kt create mode 100644 app/src/main/java/org/session/libsession/network/model/SwarmAuth.kt diff --git a/app/src/main/java/org/session/libsession/network/SessionClient.kt b/app/src/main/java/org/session/libsession/network/SessionClient.kt new file mode 100644 index 0000000000..38c4026dd3 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/SessionClient.kt @@ -0,0 +1,239 @@ +package org.session.libsession.network + +import org.session.libsession.network.onion.Version +import org.session.libsession.network.snode.SnodeDirectory +import org.session.libsession.network.snode.SwarmDirectory +import org.session.libsession.snode.SnodeMessage +import org.session.libsession.snode.SwarmAuth +import org.session.libsignal.crypto.shuffledRandom +import org.session.libsignal.utilities.JsonUtil +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Snode +import javax.inject.Inject +import javax.inject.Singleton + +/** + * High-level client for interacting with snodes + */ +@Singleton +class SessionClient @Inject constructor( + private val sessionNetwork: SessionNetwork, + private val swarmDirectory: SwarmDirectory, + private val snodeDirectory: SnodeDirectory, + private val snodeClock: SnodeClock, +) { + + /** + * - Uses onion routing via SessionNetwork. + * - Expects the snode to return a JSON body (storage_rpc style). + * - Returns that JSON as a Map for now. + * + * NOTE: This does *not* do any snode-failure accounting yet; that will be layered + * on later (e.g. path/snode penalisation based on error codes). + */ + suspend fun invoke( + method: Snode.Method, + snode: Snode, + parameters: Map, + version: Version = Version.V4 + ): Map<*, *> { + val result = sessionNetwork.sendToSnode( + method = method, + parameters = parameters, + snode = snode, + version = version + ) + + if (result.isFailure) { + throw result.exceptionOrNull() + ?: IllegalStateException("Unknown error invoking $method on $snode") + } + + val onionResponse = result.getOrThrow() + val body = onionResponse.body + ?: throw IllegalStateException("Empty body from snode for method $method") + + @Suppress("UNCHECKED_CAST") + return JsonUtil.fromJson(body, Map::class.java) as Map<*, *> + } + + // endregion + + // region Swarm target selection + + /** + * Rough equivalent of old getSingleTargetSnode(publicKey). + * + * Picks one snode from the user's swarm for a given account. + * We deliberately randomise to avoid hammering a single node. + */ + private suspend fun getSingleTargetSnode(publicKey: String): Snode { + val swarm = swarmDirectory.getSwarm(publicKey) + require(swarm.isNotEmpty()) { + "Swarm is empty for pubkey=$publicKey" + } + // Old code used shuffledRandom(); we can approximate with shuffled() then random() + return swarm.shuffledRandom().random() + } + + // endregion + + // region Auth helpers (adapted from old SnodeAPI.buildAuthenticatedParameters) + + /** + * Build parameters required to call authenticated storage API. + * + * @param auth The authentication data required to sign the request + * @param namespace The namespace of the messages. Null if not relevant. + * @param verificationData A function that returns the data to be signed. + * It gets the namespace text and timestamp. + * @param timestamp The timestamp to be used in the request. Default is network-adjusted time. + * @param builder Lambda for additional custom parameters. + */ + private fun buildAuthenticatedParameters( + auth: SwarmAuth, + namespace: Int?, + verificationData: ((namespaceText: String, timestamp: Long) -> Any)? = null, + timestamp: Long = snodeClock.currentTimeMills(), + builder: MutableMap.() -> Unit = {} + ): Map { + return buildMap { + // Callers can add their own params first + this.builder() + + if (verificationData != null) { + val namespaceText = when (namespace) { + null, 0 -> "" + else -> namespace.toString() + } + + val verifyData = when (val v = verificationData(namespaceText, timestamp)) { + is String -> v.toByteArray() + is ByteArray -> v + else -> throw IllegalArgumentException("verificationData must return String or ByteArray") + } + + putAll(auth.sign(verifyData)) + put("timestamp", timestamp) + } + + put("pubkey", auth.accountId.hexString) + if (namespace != null && namespace != 0) { + put("namespace", namespace) + } + + auth.ed25519PublicKeyHex?.let { put("pubkey_ed25519", it) } + } + } + + // endregion + + // region sendMessage + + /** + * Rough port of old SnodeAPI.sendMessage, but: + * - No additional "outer" retry layer yet (we rely on SessionNetwork's onion retry). + * - No batching; we send a single SendMessage RPC. + * + * TODO: + * - Wire in higher-level retryWithUniformInterval-style behaviour if needed. + * - Return a strongly typed StoreMessageResponse once the model & serialization are wired. + */ + suspend fun sendMessage( + message: SnodeMessage, + auth: SwarmAuth?, + namespace: Int = 0, + version: Version = Version.V4 + ): Map<*, *> { + val params: Map = if (auth != null) { + check(auth.accountId.hexString == message.recipient) { + "Message sent to ${message.recipient} but authenticated with ${auth.accountId.hexString}" + } + + val timestamp = snodeClock.currentTimeMills() + + buildAuthenticatedParameters( + auth = auth, + namespace = namespace, + verificationData = { ns, t -> + "${Snode.Method.SendMessage.rawValue}$ns$t" + }, + timestamp = timestamp + ) { + put("sig_timestamp", timestamp) + putAll(message.toJSON()) + } + } else { + buildMap { + putAll(message.toJSON()) + if (namespace != 0) { + put("namespace", namespace) + } + } + } + + val target = getSingleTargetSnode(message.recipient) + + Log.d("SessionClient", "Sending message to ${target.address}:${target.port} for ${message.recipient}") + + // In old code this went through batch API; here we do a simple single-RPC SendMessage. + val json = invoke( + method = Snode.Method.SendMessage, + snode = target, + parameters = params, + version = version + ) + + // Later you can map this Map<*, *> into StoreMessageResponse via kotlinx.serialization. + return json + } + + // endregion + + // region deleteMessage + + /** + * Simplified version of old SnodeAPI.deleteMessage. + * + * Differences vs old code: + * - We do NOT (yet) verify the per-snode signatures of deletions. + * - We do a single DeleteMessage RPC; no extra retryWithUniformInterval wrapper for now. + * - We return the raw JSON map so you can layer richer logic on top later. + */ + suspend fun deleteMessage( + publicKey: String, + auth: SwarmAuth, + serverHashes: List, + version: Version = Version.V4 + ): Map<*, *> { + val params = buildAuthenticatedParameters( + auth = auth, + namespace = null, + verificationData = { _, _ -> + buildString { + append(Snode.Method.DeleteMessage.rawValue) + serverHashes.forEach(this::append) + } + } + ) { + this["messages"] = serverHashes + } + + val snode = getSingleTargetSnode(publicKey) + + Log.d("SessionClient", "Deleting messages on ${snode.address}:${snode.port} for $publicKey") + + val json = invoke( + method = Snode.Method.DeleteMessage, + snode = snode, + parameters = params, + version = version + ) + + // Old code walked json["swarm"] and verified ED25519 signatures. + // You can port that verification logic into a separate helper later if you want parity. + return json + } + + // endregion +} diff --git a/app/src/main/java/org/session/libsession/network/model/SnodeMessage.kt b/app/src/main/java/org/session/libsession/network/model/SnodeMessage.kt new file mode 100644 index 0000000000..8fc22a8303 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/model/SnodeMessage.kt @@ -0,0 +1,38 @@ +package org.session.libsession.snode + +data class SnodeMessage( + /** + * The hex encoded public key of the recipient. + */ + val recipient: String, + /** + * The base64 encoded content of the message. + */ + val data: String, + /** + * The time to live for the message in milliseconds. + */ + val ttl: Long, + /** + * When the proof of work was calculated. + * + * **Note:** Expressed as milliseconds since 00:00:00 UTC on 1 January 1970. + */ + val timestamp: Long +) { + internal constructor(): this("", "", -1, -1) + + internal fun toJSON(): Map { + return mapOf( + "pubkey" to recipient, + "data" to data, + "ttl" to ttl.toString(), + "timestamp" to timestamp.toString(), + ) + } + + companion object { + const val CONFIG_TTL: Long = 30 * 24 * 60 * 60 * 1000L // 30 days + const val DEFAULT_TTL: Long = 14 * 24 * 60 * 60 * 1000L // 14 days + } +} diff --git a/app/src/main/java/org/session/libsession/network/model/SwarmAuth.kt b/app/src/main/java/org/session/libsession/network/model/SwarmAuth.kt new file mode 100644 index 0000000000..738a0ef8cd --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/model/SwarmAuth.kt @@ -0,0 +1,17 @@ +package org.session.libsession.snode + +import org.session.libsignal.utilities.AccountId + +/** + * An interface that represents the necessary data to sign a message for accounts. + * + */ +interface SwarmAuth { + /** + * Sign the given data and return the signature JSON structure. + */ + fun sign(data: ByteArray): Map + + val accountId: AccountId + val ed25519PublicKeyHex: String? +} \ No newline at end of file From 6e0d2d26577b764858d2f4bcfab5bbee5f7889b2 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 15 Dec 2025 16:06:30 +1100 Subject: [PATCH 11/77] Adding more from old SnodeAPI --- .../sending_receiving/MessageSender.kt | 6 +- .../libsession/network/SessionClient.kt | 115 +++++++++++++++--- .../session/libsession/network/SnodeClock.kt | 3 +- .../newmessage/NewMessageViewModel.kt | 9 +- 4 files changed, 106 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt index 7f5bcf2e58..fee1d58ee3 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt @@ -28,6 +28,7 @@ import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupApi.Capability import org.session.libsession.messaging.open_groups.OpenGroupMessage +import org.session.libsession.network.SessionClient import org.session.libsession.snode.SnodeAPI import org.session.libsession.network.SnodeClock import org.session.libsession.snode.SnodeMessage @@ -59,6 +60,7 @@ class MessageSender @Inject constructor( private val messageSendJobFactory: MessageSendJob.Factory, private val messageExpirationManager: ExpiringMessageManager, private val snodeClock: SnodeClock, + private val sessionClient: SessionClient, @param:ManagerScope private val scope: CoroutineScope, ) { @@ -243,14 +245,14 @@ class MessageSender @Inject constructor( "Unable to authorize group message send" } - SnodeAPI.sendMessage( + sessionClient.sendMessage( auth = groupAuth, message = snodeMessage, namespace = Namespace.GROUP_MESSAGES(), ) } is Destination.Contact -> { - SnodeAPI.sendMessage(snodeMessage, auth = null, namespace = Namespace.DEFAULT()) + sessionClient.sendMessage(snodeMessage, auth = null, namespace = Namespace.DEFAULT()) } is Destination.OpenGroup, is Destination.OpenGroupInbox -> throw IllegalStateException("Destination should not be an open group.") diff --git a/app/src/main/java/org/session/libsession/network/SessionClient.kt b/app/src/main/java/org/session/libsession/network/SessionClient.kt index 38c4026dd3..2350b1f2c9 100644 --- a/app/src/main/java/org/session/libsession/network/SessionClient.kt +++ b/app/src/main/java/org/session/libsession/network/SessionClient.kt @@ -1,14 +1,19 @@ package org.session.libsession.network +import network.loki.messenger.libsession_util.Hash +import network.loki.messenger.libsession_util.SessionEncrypt import org.session.libsession.network.onion.Version import org.session.libsession.network.snode.SnodeDirectory import org.session.libsession.network.snode.SwarmDirectory import org.session.libsession.snode.SnodeMessage import org.session.libsession.snode.SwarmAuth import org.session.libsignal.crypto.shuffledRandom +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode +import java.util.Locale import javax.inject.Inject import javax.inject.Singleton @@ -46,21 +51,17 @@ class SessionClient @Inject constructor( if (result.isFailure) { throw result.exceptionOrNull() - ?: IllegalStateException("Unknown error invoking $method on $snode") + ?: Error.Generic("Unknown error invoking $method on $snode") } val onionResponse = result.getOrThrow() val body = onionResponse.body - ?: throw IllegalStateException("Empty body from snode for method $method") + ?: throw Error.Generic("Empty body from snode for method $method") @Suppress("UNCHECKED_CAST") return JsonUtil.fromJson(body, Map::class.java) as Map<*, *> } - // endregion - - // region Swarm target selection - /** * Rough equivalent of old getSingleTargetSnode(publicKey). * @@ -76,10 +77,6 @@ class SessionClient @Inject constructor( return swarm.shuffledRandom().random() } - // endregion - - // region Auth helpers (adapted from old SnodeAPI.buildAuthenticatedParameters) - /** * Build parameters required to call authenticated storage API. * @@ -126,10 +123,6 @@ class SessionClient @Inject constructor( } } - // endregion - - // region sendMessage - /** * Rough port of old SnodeAPI.sendMessage, but: * - No additional "outer" retry layer yet (we rely on SessionNetwork's onion retry). @@ -188,10 +181,6 @@ class SessionClient @Inject constructor( return json } - // endregion - - // region deleteMessage - /** * Simplified version of old SnodeAPI.deleteMessage. * @@ -235,5 +224,93 @@ class SessionClient @Inject constructor( return json } - // endregion + suspend fun getNetworkTime( + snode: Snode, + version: Version = Version.V4 + ): Pair { + val json = invoke( + method = Snode.Method.Info, + snode = snode, + parameters = emptyMap(), + version = version + ) + + val timestamp = json["timestamp"] as? Long + ?: throw Error.Generic("Missing 'timestamp' in Info response") + + return snode to timestamp + } + + /** + * Resolve an ONS name into an account ID (33-byte value as hex string). + * + * Rough port of old SnodeAPI.getAccountID: + * - Lowercases the name. + * - Asks 3 different snodes for the ONS resolution. + * - Decrypts the result and requires all 3 to match. + * + * Throws if validation fails. + */ + suspend fun getAccountID(onsName: String): String { + val validationCount = 3 + val onsNameLower = onsName.lowercase(Locale.US) + + // Build request params for ons_resolve via OxenDaemonRPCCall + val params: Map = buildMap { + this["method"] = "ons_resolve" + this["params"] = buildMap { + this["type"] = 0 // session account type + this["name_hash"] = Base64.encodeBytes( + Hash.hash32(onsNameLower.toByteArray()) + ) + } + } + + // Ask 3 different snodes + val results = mutableListOf() + + repeat(validationCount) { + val snode = snodeDirectory.getRandomSnode() + + val json = invoke( + method = Snode.Method.OxenDaemonRPCCall, + snode = snode, + parameters = params, + version = Version.V4 + ) + + @Suppress("UNCHECKED_CAST") + val intermediate = json["result"] as? Map<*, *> + ?: throw Error.Generic("Invalid ONS response: missing 'result'") + + val hexEncodedCiphertext = intermediate["encrypted_value"] as? String + ?: throw Error.Generic("Invalid ONS response: missing 'encrypted_value'") + + val ciphertext = Hex.fromStringCondensed(hexEncodedCiphertext) + val nonce = (intermediate["nonce"] as? String)?.let(Hex::fromStringCondensed) + + val accountId = SessionEncrypt.decryptOnsResponse( + lowercaseName = onsNameLower, + ciphertext = ciphertext, + nonce = nonce + ) + + results += accountId + } + + // All 3 must be equal for us to trust the result + if (results.size == validationCount && results.toSet().size == 1) { + return results.first() + } else { + throw Error.ValidationFailed + } + } + + // Error + sealed class Error(val description: String) : Exception(description) { + data class Generic(val info: String = "An error occurred.") : Error(info) + + // ONS + object ValidationFailed : Error("ONS name validation failed.") + } } diff --git a/app/src/main/java/org/session/libsession/network/SnodeClock.kt b/app/src/main/java/org/session/libsession/network/SnodeClock.kt index feaace83a2..24c2e4db00 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClock.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClock.kt @@ -28,6 +28,7 @@ import javax.inject.Singleton class SnodeClock @Inject constructor( @param:ManagerScope private val scope: CoroutineScope, private val snodeDirectory: SnodeDirectory, + private val sessionClient: SessionClient ) : OnAppStartupComponent { private val instantState = MutableStateFlow(null) @@ -42,7 +43,7 @@ class SnodeClock @Inject constructor( val node = snodeDirectory.getRandomSnode() val requestStarted = SystemClock.elapsedRealtime() - var networkTime = SnodeAPI.getNetworkTime(node).await().second + var networkTime = sessionClient.getNetworkTime(node).second val requestEnded = SystemClock.elapsedRealtime() // Adjust network time to halfway through the request duration diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt index 9e9b3b017c..25ae792580 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt @@ -15,10 +15,9 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import network.loki.messenger.R -import org.session.libsession.snode.SnodeAPI +import org.session.libsession.network.SessionClient import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress -import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.PublicKeyValidation @@ -29,7 +28,7 @@ import javax.inject.Inject @HiltViewModel class NewMessageViewModel @Inject constructor( private val application: Application, - private val configFactory: ConfigFactoryProtocol, + private val sesionClient: SessionClient, ) : ViewModel(), Callbacks { private val HELP_URL : String = "https://getsession.org/account-ids" @@ -127,7 +126,7 @@ class NewMessageViewModel @Inject constructor( loadOnsJob = viewModelScope.launch { try { val publicKey = withTimeout(30_000L, { - SnodeAPI.getAccountID(ons) + sesionClient.getAccountID(ons) }) onPublicKey(publicKey) } catch (e: Exception) { @@ -170,7 +169,7 @@ class NewMessageViewModel @Inject constructor( } private fun Exception.toMessage() = when (this) { - is SnodeAPI.Error.Generic -> application.getString(R.string.errorUnregisteredOns) + is SessionClient.Error.Generic -> application.getString(R.string.errorUnregisteredOns) else -> Phrase.from(application, R.string.errorNoLookupOns) .put(APP_NAME_KEY, application.getString(R.string.app_name)) .format().toString() From b0dba90e7ff8ddd2bba4cc5fd422bdc8cec296f5 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 15 Dec 2025 18:03:22 +1100 Subject: [PATCH 12/77] tweaks --- .../java/org/session/libsession/network/SessionClient.kt | 5 +++++ .../org/session/libsession/network/snode/SnodeDirectory.kt | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/SessionClient.kt b/app/src/main/java/org/session/libsession/network/SessionClient.kt index 2350b1f2c9..7f752180f5 100644 --- a/app/src/main/java/org/session/libsession/network/SessionClient.kt +++ b/app/src/main/java/org/session/libsession/network/SessionClient.kt @@ -28,6 +28,11 @@ class SessionClient @Inject constructor( private val snodeClock: SnodeClock, ) { + //todo ONION no retry logic atm + //todo ONION missing alterTTL + //todo ONION missing batch logic + //todo ONION missing snode error handling + /** * - Uses onion routing via SessionNetwork. * - Expects the snode to return a JSON body (storage_rpc style). diff --git a/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt b/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt index b787d7031f..bbc61f8ea2 100644 --- a/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt +++ b/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt @@ -172,13 +172,13 @@ class SnodeDirectory @Inject constructor( ) } - fun getGuardSnodes( + suspend fun getGuardSnodes( existingGuards: Set, targetGuardCount: Int ): Set { if (existingGuards.size >= targetGuardCount) return existingGuards - var unused = getSnodePool().minus(existingGuards) + var unused = ensurePoolPopulated().minus(existingGuards) val needed = targetGuardCount - existingGuards.size if (unused.size < needed) { From 57b04950d45a60fa3fdad144a6bd01ebb84765c2 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 16 Dec 2025 13:39:34 +1100 Subject: [PATCH 13/77] todo --- .../session/libsession/network/onion/http/HttpOnionTransport.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt index 7e5d6f225d..a571431090 100644 --- a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt +++ b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt @@ -200,6 +200,7 @@ class HttpOnionTransport : OnionTransport { val bodyOrMsg = bodyStr ?: (responseInfo["message"]?.toString()) // Special case: require blinding message (still treated as destination error) + //todo ONION do we need to make this distinction since it amounts to the same in the end? if (bodyStr == REQUIRE_BLINDING_MESSAGE) { return Result.failure( OnionError.DestinationError( From 38255eb700fbd62c11cfa5ac849ea577ccb61ca4 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 16 Dec 2025 15:32:38 +1100 Subject: [PATCH 14/77] Delegating to the new OnionErrorManager --- .../libsession/network/SessionNetwork.kt | 158 ++++++++---------- .../libsession/network/model/OnionError.kt | 79 ++++----- .../network/onion/OnionErrorManager.kt | 144 ++++++++++++++++ .../network/onion/http/HttpOnionTransport.kt | 129 ++++---------- .../network/snode/SnodeDirectory.kt | 10 ++ .../network/snode/SwarmDirectory.kt | 40 +++-- 6 files changed, 316 insertions(+), 244 deletions(-) create mode 100644 app/src/main/java/org/session/libsession/network/onion/OnionErrorManager.kt diff --git a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt index 1be8613c57..09045efb4d 100644 --- a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt +++ b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt @@ -1,10 +1,14 @@ package org.session.libsession.network import okhttp3.Request +import kotlinx.coroutines.delay import org.session.libsession.network.model.OnionDestination import org.session.libsession.network.model.OnionError import org.session.libsession.network.model.OnionResponse import org.session.libsession.network.model.Path +import org.session.libsession.network.onion.OnionErrorManager +import org.session.libsession.network.onion.OnionFailureContext +import org.session.libsession.network.onion.FailureDecision import org.session.libsession.network.onion.OnionTransport import org.session.libsession.network.onion.PathManager import org.session.libsession.network.onion.Version @@ -13,27 +17,40 @@ import org.session.libsession.network.utilities.getHeadersForOnionRequest import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode +import kotlin.random.Random /** * High-level onion request manager. - * It prepares payloads, chooses onion paths, analyzes failures, repairs the path graph, - * implements retry rules, and returns final user-level responses. - * It does not build onion encryption or send anything over the network, that part - * is left to an implementation of an OnionTransport + * + * Responsibilities: + * - Prepare payloads + * - Choose onion paths + * - Retry loop + (light) retry timing/backoff + * - Delegate all “what do we do with this OnionError?” decisions to OnionErrorManager + * + * Not responsible for: + * - Onion crypto construction or transport I/O (OnionTransport) + * - Policy / healing logic (OnionErrorManager) */ class SessionNetwork( private val pathManager: PathManager, private val transport: OnionTransport, - private val maxAttempts: Int = 3 + private val errorManager: OnionErrorManager, + private val maxAttempts: Int = 2, + private val baseRetryDelayMs: Long = 250L, + private val maxRetryDelayMs: Long = 2_000L ) { /** * Send an onion request to a *service node* (RPC). + * + * @param publicKey Optional: used by OnionErrorManager for swarm-specific handling (e.g. 421). */ suspend fun sendToSnode( method: Snode.Method, parameters: Map<*, *>, snode: Snode, + publicKey: String? = null, version: Version = Version.V4 ): Result { val payload = JsonUtil.toJson( @@ -45,12 +62,14 @@ class SessionNetwork( val destination = OnionDestination.SnodeDestination(snode) - // Exclude the snode itself from being in the path (matches old behaviour) + // Exclude the destination snode itself from being in the path (old behaviour) return sendWithRetry( destination = destination, payload = payload, version = version, - snodeToExclude = snode + snodeToExclude = snode, + targetSnode = snode, + publicKey = publicKey ) } @@ -78,7 +97,9 @@ class SessionNetwork( destination = destination, payload = payload, version = version, - snodeToExclude = null + snodeToExclude = null, + targetSnode = null, + publicKey = null ) } @@ -86,12 +107,18 @@ class SessionNetwork( destination: OnionDestination, payload: ByteArray, version: Version, - snodeToExclude: Snode? + snodeToExclude: Snode?, + targetSnode: Snode?, + publicKey: String? ): Result { var lastError: Throwable? = null - repeat(maxAttempts) { attempt -> - val path = pathManager.getPath(exclude = snodeToExclude) + for (attempt in 1..maxAttempts) { + val path: Path = try { + pathManager.getPath(exclude = snodeToExclude) + } catch (t: Throwable) { + return Result.failure(t) + } val result = transport.send( path = path, @@ -102,97 +129,50 @@ class SessionNetwork( if (result.isSuccess) return result - val error = result.exceptionOrNull() - if (error !is OnionError) { - // Transport returned some unexpected Throwable - return Result.failure(error ?: IllegalStateException("Unknown transport error")) - } + val throwable = result.exceptionOrNull() + ?: IllegalStateException("Unknown onion transport error") - Log.w("Onion", "Onion error on attempt ${attempt + 1}/$maxAttempts: $error") + val onionError = throwable as? OnionError + ?: return Result.failure(throwable) - handleError(path, error) + Log.w("Onion", "Onion error on attempt $attempt/$maxAttempts: $onionError") - if (!shouldRetry(error, attempt)) { - return Result.failure(error) - } + lastError = onionError - lastError = error + // Delegate all handling + retry decision + val decision = errorManager.onFailure( + error = onionError, + ctx = OnionFailureContext( + path = path, + destination = destination, + targetSnode = targetSnode, + publicKey = publicKey + ) + ) - //todo ONION we might want some backoff/delay logic here + when (decision) { + is FailureDecision.Fail -> return Result.failure(decision.throwable) + FailureDecision.Retry -> { + if (attempt >= maxAttempts) break + delay(computeBackoffDelayMs(attempt)) + continue + } + } } return Result.failure(lastError ?: IllegalStateException("Unknown onion error")) } - /** - * Decide whether to retry based on the error type and current attempt. - */ - private fun shouldRetry(error: OnionError, attempt: Int): Boolean { - if (attempt + 1 >= maxAttempts) return false - - //todo ONION I'm making assumptions here - this is for the low level SessionNetwork reties. Might want to fully define this - return when (error) { - is OnionError.DestinationError, - is OnionError.ClockOutOfSync -> { - false - } - is OnionError.GuardConnectionFailed, - is OnionError.PathError, - is OnionError.PathErrorNonPenalizing, - is OnionError.IntermediateNodeFailed, - is OnionError.InvalidResponse, - is OnionError.Unknown -> { - true - } - } - } - - /** - * Map an OnionError into path-level healing operations. - */ - private fun handleError(path: Path, error: OnionError) { - when (error) { - is OnionError.GuardConnectionFailed, - is OnionError.PathError, - is OnionError.InvalidResponse, - is OnionError.Unknown -> { - // We don't know which hop is bad; drop the whole path. - Log.w("Onion", "Dropping entire path due to error: $error") - pathManager.handleBadPath(path) - } - - is OnionError.IntermediateNodeFailed -> { - val failedKey = error.failedPublicKey - if (failedKey == null) { - Log.w("Onion", "Intermediate node failed but no key given; dropping path") - pathManager.handleBadPath(path) - } else { - val bad = path.firstOrNull { it.publicKeySet?.ed25519Key == failedKey } - if (bad != null) { - Log.w("Onion", "Dropping bad snode $bad in path") - pathManager.handleBadSnode(bad) - } else { - Log.w("Onion", "Failed node key not in path; dropping path") - pathManager.handleBadPath(path) - } - } - } - - is OnionError.PathErrorNonPenalizing, - is OnionError.DestinationError -> { - // Path is considered healthy; do not mutate paths. - Log.d("Onion", "Non penalizing error: $error") - } - - is OnionError.ClockOutOfSync -> { - // todo ONION - should we reset the SnodeClock? - Log.d("Onion", "Clock out of sync (non-penalizing): $error") - } - } + private fun computeBackoffDelayMs(attempt: Int): Long { + // Exponential-ish: base * 2^(attempt-1), with jitter, capped + val exp = baseRetryDelayMs * (1L shl (attempt - 1).coerceAtMost(5)) + val capped = exp.coerceAtMost(maxRetryDelayMs) + val jitter = Random.nextLong(0, capped / 3 + 1) + return capped + jitter } /** - * Equivalent to the old generatePayload() from OnionRequestAPI. + * Equivalent to old generatePayload() from OnionRequestAPI. */ private fun generatePayload(request: Request, server: String, version: Version): ByteArray { val headers = request.getHeadersForOnionRequest().toMutableMap() diff --git a/app/src/main/java/org/session/libsession/network/model/OnionError.kt b/app/src/main/java/org/session/libsession/network/model/OnionError.kt index 03ae16d50a..17299d8523 100644 --- a/app/src/main/java/org/session/libsession/network/model/OnionError.kt +++ b/app/src/main/java/org/session/libsession/network/model/OnionError.kt @@ -1,75 +1,62 @@ package org.session.libsession.network.model +import org.session.libsignal.utilities.ByteArraySlice import org.session.libsignal.utilities.Snode -sealed class OnionError(message: String, cause: Throwable? = null) : Exception(message, cause) { +data class ErrorStatus( + val code: Int, + val message: String? = null, + val body: ByteArraySlice? = null +) { + val bodyText: String? + get() = body?.decodeToString() +} - /** - * We couldn't even talk to the guard node. - * Typical causes: offline, DNS failure, TCP connect fails, TLS failure. - */ - data class GuardConnectionFailed( - val guard: Snode, - val underlying: Throwable - ) : OnionError("Failed to connect to guard ${guard.ip}:${guard.port}", underlying) +enum class ErrorOrigin { UNKNOWN, TRANSPORT_TO_GUARD, PATH_HOP, DESTINATION_REPLY } - /** - * Guard or intermediate nodes - specifically: errors not from the encrypted payload) - */ - data class PathError( - val node: Snode?, - val code: Int, - val body: String? - ) : OnionError("Node ${node?.ip}:${node?.port} error with status $code - Path penalizing", null) +sealed class OnionError( + val origin: ErrorOrigin, + val status: ErrorStatus? = null, + cause: Throwable? = null +) : Exception(status?.message ?: "Onion error", cause) { /** - * Guard or intermediate nodes - specifically: errors not from the encrypted payload) - * These errors should not penalize the path + * We couldn't even talk to the guard node. + * Typical causes: offline, DNS failure, TCP connect fails, TLS failure. */ - data class PathErrorNonPenalizing( - val node: Snode?, - val code: Int, - val body: String? - ) : OnionError("Node ${node?.ip}:${node?.port} error with status $code - NON Path penalizing", null) + class GuardUnreachable(val guard: Snode, cause: Throwable) + : OnionError(ErrorOrigin.TRANSPORT_TO_GUARD, cause = cause) /** * The onion chain broke mid-path: one hop reported that the next node was not found. * failedPublicKey is the ed25519 key of the missing snode if known. */ - data class IntermediateNodeFailed( + class IntermediateNodeFailed( val reportingNode: Snode?, val failedPublicKey: String? - ) : OnionError("Intermediate node failure (failedPublicKey=$failedPublicKey)", null) + ) : OnionError(ErrorOrigin.PATH_HOP) /** - * The destination (server or snode) responded with a non-success application-level status. - * E.g. 404, 401, 500, app-specific error JSON, etc. - * This means the path worked; usually we don't penalize the path. + * The error happened, as far as we can tell, along the path on the way to the destination */ - data class DestinationError( - val code: Int, - val body: String? - ) : OnionError("Destination returned error $code", null) + class PathError(val node: Snode?, status: ErrorStatus) + : OnionError(ErrorOrigin.PATH_HOP, status = status) /** - * Clock out of sync with the snode network (your special 406/425 cases). + * The error happened after decrypting a payload form the destination */ - data class ClockOutOfSync( - val code: Int, - val body: String? - ) : OnionError("Clock out of sync with service node network (code=$code)", null) + class DestinationError(status: ErrorStatus) + : OnionError(ErrorOrigin.DESTINATION_REPLY, status = status) /** - * The guard/destination returned something that we couldn't decode as a valid onion response. + * The onion payload returned something that we couldn't decode as a valid onion response. */ - data class InvalidResponse( - val raw: ByteArray - ) : OnionError("Invalid onion response", null) + class InvalidResponse(cause: Throwable? = null) + : OnionError(ErrorOrigin.DESTINATION_REPLY, cause = cause) /** * Fallback for anything we haven't classified yet. */ - data class Unknown( - val underlying: Throwable - ) : OnionError("Unknown onion error", underlying) -} + class Unknown(cause: Throwable) + : OnionError(ErrorOrigin.UNKNOWN, cause = cause) +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/network/onion/OnionErrorManager.kt b/app/src/main/java/org/session/libsession/network/onion/OnionErrorManager.kt new file mode 100644 index 0000000000..f780a5b052 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/onion/OnionErrorManager.kt @@ -0,0 +1,144 @@ +package org.session.libsession.network.onion + +import org.session.libsession.network.SnodeClock +import org.session.libsession.network.model.ErrorOrigin +import org.session.libsession.network.model.OnionDestination +import org.session.libsession.network.model.OnionError +import org.session.libsession.network.model.Path +import org.session.libsession.network.snode.SnodeDirectory +import org.session.libsession.network.snode.SwarmDirectory +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Snode +import javax.inject.Inject +import javax.inject.Singleton + +private const val REQUIRE_BLINDING_MESSAGE = + "Invalid authentication: this server requires the use of blinded ids" + +@Singleton +class OnionErrorManager @Inject constructor( + private val pathManager: PathManager, + private val snodeDirectory: SnodeDirectory, + private val swarmDirectory: SwarmDirectory, + private val snodeClock: SnodeClock, +) { + + suspend fun onFailure(error: OnionError, ctx: OnionFailureContext): FailureDecision { + val status = error.status + val code = status?.code + val bodyText = status?.bodyText + + // -------------------------------------------------------------------- + // 1) "Found anywhere" rules (path OR destination) + // -------------------------------------------------------------------- + + // 406/425: clock out of sync + if (code == 406 || code == 425) { + // Do not penalise path or snode. Reset the clock. Retry if reset succeeded. + val resetOk = runCatching { + //snodeClock.resync() + //todo ONION Can we do some clock reset here? + false + }.getOrDefault(false) + return if (resetOk) FailureDecision.Retry else FailureDecision.Fail(error) + } + + // 400 (except blinding), 403, 404: do not penalise path or snode; retry + if (code == 400 || code == 403 || code == 404) { + // carve-out: destination 400 with blinding message is caller-handled + if (code == 400 && bodyText?.contains(REQUIRE_BLINDING_MESSAGE) == true) { + return FailureDecision.Fail(error) + } + return FailureDecision.Retry + } + + // -------------------------------------------------------------------- + // 2) Errors along the path (not destination) + // -------------------------------------------------------------------- + when (error) { + is OnionError.IntermediateNodeFailed -> { + // Drop snode from pool, rebuild paths without it, penalise path, retry + val failedKey = error.failedPublicKey + if (failedKey != null) { + snodeDirectory.dropSnodeFromPool(failedKey) + } + + // If we can map the failed key to an actual snode in this path, prefer handleBadSnode + val bad = failedKey?.let { pk -> + ctx.path.firstOrNull { it.publicKeySet?.ed25519Key == pk } + } + + if (bad != null) pathManager.handleBadSnode(bad) + else pathManager.handleBadPath(ctx.path) + + return FailureDecision.Retry + } + + is OnionError.PathError -> { + // "Anything else along the path": penalise path; no retries (caller decides) + pathManager.handleBadPath(ctx.path) + return FailureDecision.Fail(error) + } + + is OnionError.GuardUnreachable -> { + // Networky: penalise path; retry + pathManager.handleBadPath(ctx.path) + return FailureDecision.Retry + } + + // InvalidResponse / Unknown: treat as path failure (penalise path; retry) + is OnionError.InvalidResponse, + is OnionError.Unknown -> { + pathManager.handleBadPath(ctx.path) + return FailureDecision.Retry + } + + else -> Unit + } + + // -------------------------------------------------------------------- + // 3) Destination payload rules + // -------------------------------------------------------------------- + if (error is OnionError.DestinationError) { + // 421: snode isn't associated with pubkey anymore -> update swarm / invalidate -> retry + if (code == 421) { + val publicKey = ctx.publicKey + val targetSnode = ctx.targetSnode + + val updated = if (publicKey != null) { + swarmDirectory.tryUpdateSwarmFrom421( + publicKey = publicKey, + body = status.body + ) + } else { + Log.w("Onion", "Got 421 without an associated public key.") + false + } + + if (!updated && publicKey != null && targetSnode != null) { + swarmDirectory.dropSnodeFromSwarmIfNeeded(targetSnode, publicKey) + } + + return FailureDecision.Retry + } + + // Anything else from destination: do not penalise path; no retries + return FailureDecision.Fail(error) + } + + // Default: fail + return FailureDecision.Fail(error) + } +} + +data class OnionFailureContext( + val path: Path, + val destination: OnionDestination, + val targetSnode: Snode? = null, + val publicKey: String? = null +) + +sealed class FailureDecision { + data object Retry : FailureDecision() + data class Fail(val throwable: Throwable) : FailureDecision() +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt index a571431090..5eb6719671 100644 --- a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt +++ b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt @@ -1,5 +1,6 @@ package org.session.libsession.network.onion.http +import org.session.libsession.network.model.ErrorStatus import org.session.libsession.network.model.OnionDestination import org.session.libsession.network.model.OnionError import org.session.libsession.network.model.OnionResponse @@ -15,16 +16,6 @@ import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Snode import org.session.libsignal.utilities.toHexString -private val NON_PENALIZING_STATUSES = setOf(403, 404, 406, 425) -private const val REQUIRE_BLINDING_MESSAGE = - "Invalid authentication: this server requires the use of blinded ids" - -/** - * Builds onion layers, sends them over HTTP to the guard, - * receives and decrypts the onion response, - * and maps low-level protocol/transport errors into onion errors. - * It does not choose paths, retry, or apply healing logic. - */ class HttpOnionTransport : OnionTransport { override suspend fun send( @@ -65,7 +56,7 @@ class HttpOnionTransport : OnionTransport { return Result.failure(mapPathHttpError(guard, httpEx)) } catch (t: Throwable) { // TCP / DNS / TLS / timeout etc. reaching guard - return Result.failure(OnionError.GuardConnectionFailed(guard, t)) + return Result.failure(OnionError.GuardUnreachable(guard, t)) } // We have an onion-level response from the guard; decrypt & interpret @@ -78,15 +69,16 @@ class HttpOnionTransport : OnionTransport { } /** - * Map HTTP errors from the guard or intermediate nodes, whose errors are not encrypted - * (before onion decryption) + * Errors thrown by the guard / path hop BEFORE we get an onion-encrypted reply. */ private fun mapPathHttpError( node: Snode, ex: HTTP.HTTPRequestFailedException ): OnionError { val json = ex.json - val message = json?.get("result") as? String + val message = (json?.get("result") as? String) + ?: (json?.get("message") as? String) + val statusCode = ex.statusCode // Special onion path error: "Next node not found: " @@ -99,26 +91,16 @@ class HttpOnionTransport : OnionTransport { ) } - // Non-penalising codes: treat as destination-level error (path OK) - if (statusCode in NON_PENALIZING_STATUSES || message == "Loki Server error") { - return OnionError.PathErrorNonPenalizing( - node = node, - code = statusCode, - body = message - ) - } - - // Otherwise: guard rejected / misbehaved return OnionError.PathError( node = node, - code = statusCode, - body = message + status = ErrorStatus( + code = statusCode, + message = message, + body = null + ) ) } - /** - * Handle an onion-encrypted response - */ private fun handleResponse( rawResponse: ByteArray, destinationSymmetricKey: ByteArray, @@ -129,11 +111,7 @@ class HttpOnionTransport : OnionTransport { Version.V4 -> handleV4Response(rawResponse, destinationSymmetricKey, destination) Version.V2, Version.V3 -> { //todo ONION add support for v2/v3 - Result.failure( - OnionError.Unknown( - UnsupportedOperationException("Need to implement - TEMP") - ) - ) + Result.failure(OnionError.Unknown(UnsupportedOperationException("Need to implement v2/v3"))) } } } @@ -145,104 +123,69 @@ class HttpOnionTransport : OnionTransport { ): Result { try { if (response.size <= AESGCM.ivSize) { - return Result.failure(OnionError.InvalidResponse(response)) + return Result.failure(OnionError.InvalidResponse()) } - val plaintext = AESGCM.decrypt(response, symmetricKey = destinationSymmetricKey) + val decrypted = AESGCM.decrypt(response, symmetricKey = destinationSymmetricKey) - if (plaintext.isEmpty() || plaintext[0] != 'l'.code.toByte()) { - return Result.failure(OnionError.InvalidResponse(response)) + if (decrypted.isEmpty() || decrypted[0] != 'l'.code.toByte()) { + return Result.failure(OnionError.InvalidResponse()) } - val infoSepIdx = plaintext.indexOfFirst { it == ':'.code.toByte() } - if (infoSepIdx <= 1) { - return Result.failure(OnionError.InvalidResponse(response)) - } + val infoSepIdx = decrypted.indexOfFirst { it == ':'.code.toByte() } + if (infoSepIdx <= 1) return Result.failure(OnionError.InvalidResponse()) - val infoLenSlice = plaintext.slice(1 until infoSepIdx) - val infoLength = infoLenSlice - .toByteArray() - .toString(Charsets.US_ASCII) - .toIntOrNull() - ?: return Result.failure(OnionError.InvalidResponse(response)) + val infoLenSlice = decrypted.slice(1 until infoSepIdx) + val infoLength = infoLenSlice.toByteArray().toString(Charsets.US_ASCII).toIntOrNull() + ?: return Result.failure(OnionError.InvalidResponse()) val infoStartIndex = "l$infoLength".length + 1 val infoEndIndex = infoStartIndex + infoLength - if (infoEndIndex > plaintext.size) { - return Result.failure(OnionError.InvalidResponse(response)) - } + if (infoEndIndex > decrypted.size) return Result.failure(OnionError.InvalidResponse()) - val infoBytes = plaintext.slice(infoStartIndex until infoEndIndex).toByteArray() + val infoBytes = decrypted.slice(infoStartIndex until infoEndIndex).toByteArray() @Suppress("UNCHECKED_CAST") val responseInfo = JsonUtil.fromJson(infoBytes, Map::class.java) as Map<*, *> val statusCode = responseInfo["code"].toString().toInt() - // clock out-of-sync special handling - if (statusCode == 406 || statusCode == 425) { - val body = "Your clock is out of sync with the service node network." - return Result.failure( - OnionError.ClockOutOfSync( - code = statusCode, - body = body - ) - ) - } - if (statusCode !in 200..299) { - // For 400 from server, we might have a body in the second part - val responseBodySlice = + // Optional "body" part for some server errors (notably 400) + val bodySlice = if (destination is OnionDestination.ServerDestination && statusCode == 400) { - plaintext.getBody(infoLength, infoEndIndex) + decrypted.getBody(infoLength, infoEndIndex) } else null - val bodyStr = responseBodySlice?.decodeToString() - val bodyOrMsg = bodyStr ?: (responseInfo["message"]?.toString()) - - // Special case: require blinding message (still treated as destination error) - //todo ONION do we need to make this distinction since it amounts to the same in the end? - if (bodyStr == REQUIRE_BLINDING_MESSAGE) { - return Result.failure( - OnionError.DestinationError( - code = statusCode, - body = bodyStr - ) - ) - } - return Result.failure( OnionError.DestinationError( - code = statusCode, - body = bodyOrMsg + status = ErrorStatus( + code = statusCode, + message = responseInfo["message"]?.toString(), + body = bodySlice + ) ) ) } - // 2xx: success. There may or may not be a body. - val responseBody = plaintext.getBody(infoLength, infoEndIndex) + val responseBody = decrypted.getBody(infoLength, infoEndIndex) return if (responseBody.isEmpty()) { Result.success(OnionResponse(info = responseInfo, body = null)) } else { Result.success(OnionResponse(info = responseInfo, body = responseBody)) } } catch (t: Throwable) { - return Result.failure(OnionError.InvalidResponse(response)) + return Result.failure(OnionError.InvalidResponse(t)) } } - /** - * V4 layout helper: extracts the optional body part from `lN:json...e`. - */ private fun ByteArray.getBody(infoLength: Int, infoEndIndex: Int): ByteArraySlice { val infoLengthStringLength = infoLength.toString().length - // minimum layout: l:e - if (size <= infoLength + infoLengthStringLength + 2 /* l and e */) { - return ByteArraySlice.EMPTY - } - // There is extra data: parse the second length / body section. + if (size <= infoLength + infoLengthStringLength + 2) return ByteArraySlice.EMPTY + val dataSlice = view(infoEndIndex + 1 until size - 1) val dataSepIdx = dataSlice.asList().indexOfFirst { it.toInt() == ':'.code } if (dataSepIdx == -1) return ByteArraySlice.EMPTY + return dataSlice.view(dataSepIdx + 1 until dataSlice.len) } } diff --git a/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt b/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt index bbc61f8ea2..503b5f0103 100644 --- a/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt +++ b/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt @@ -194,4 +194,14 @@ class SnodeDirectory @Inject constructor( return (existingGuards + newGuards).toSet() } + + /** + * Remove a snode from the pool by its ed25519 key. + */ + fun dropSnodeFromPool(ed25519Key: String) { + val current = getSnodePool() + val hit = current.firstOrNull { it.publicKeySet?.ed25519Key == ed25519Key } ?: return + Log.w("SnodeDirectory", "Dropping snode from pool (ed25519=$ed25519Key): $hit") + updateSnodePool(current - hit) + } } diff --git a/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt b/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt index 46b35f7803..e3c18ee649 100644 --- a/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt +++ b/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt @@ -2,6 +2,7 @@ package org.session.libsession.network.snode import org.session.libsession.network.SessionNetwork import org.session.libsession.network.onion.Version +import org.session.libsignal.utilities.ByteArraySlice import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Snode @@ -69,7 +70,7 @@ class SwarmDirectory( return list.asSequence() .mapNotNull { it as? Map<*, *> } .mapNotNull { raw -> - createSnode( + snodeDirectory.createSnode( address = raw["ip"] as? String, port = (raw["port"] as? String)?.toInt(), ed25519Key = raw["pubkey_ed25519"] as? String, @@ -79,20 +80,27 @@ class SwarmDirectory( .toList() } - private fun createSnode( - address: String?, - port: Int?, - ed25519Key: String?, - x25519Key: String? - ): Snode? { - return Snode( - address?.takeUnless { it == "0.0.0.0" }?.let { "https://$it" } ?: return null, - port ?: return null, - Snode.KeySet( - ed25519Key ?: return null, - x25519Key ?: return null - ), - Snode.Version.ZERO // or parse from response if present - ) + /** + * Handles 421: snode says it's no longer associated with this pubkey. + * + * Old behaviour: if response contains snodes -> replace cached swarm. + * Otherwise invalidate (caller may also drop the target snode from cached swarm). + * + * @return true if swarm was updated from body JSON, false otherwise. + */ + fun tryUpdateSwarmFrom421(publicKey: String, body: ByteArraySlice?): Boolean { + if (body == null || body.isEmpty()) return false + + val json: Map<*, *> = try { + JsonUtil.fromJson(body.copyToBytes(), Map::class.java) as Map<*, *> + } catch (_: Throwable) { + return false + } + + val snodes = parseSnodes(json).toSet() + if (snodes.isEmpty()) return false + + storage.setSwarm(publicKey, snodes) + return true } } From e7bac142655c308fb61d7c8206a49e5b280e8032 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 16 Dec 2025 15:54:03 +1100 Subject: [PATCH 15/77] small tweaks --- .../network/onion/OnionErrorManager.kt | 16 +++++++--------- .../libsession/network/snode/SwarmDirectory.kt | 2 +- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/onion/OnionErrorManager.kt b/app/src/main/java/org/session/libsession/network/onion/OnionErrorManager.kt index f780a5b052..526ee976e9 100644 --- a/app/src/main/java/org/session/libsession/network/onion/OnionErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/onion/OnionErrorManager.kt @@ -1,7 +1,6 @@ package org.session.libsession.network.onion import org.session.libsession.network.SnodeClock -import org.session.libsession.network.model.ErrorOrigin import org.session.libsession.network.model.OnionDestination import org.session.libsession.network.model.OnionError import org.session.libsession.network.model.Path @@ -43,13 +42,10 @@ class OnionErrorManager @Inject constructor( return if (resetOk) FailureDecision.Retry else FailureDecision.Fail(error) } - // 400 (except blinding), 403, 404: do not penalise path or snode; retry + // 400, 403, 404: do not penalise path or snode; No retries if (code == 400 || code == 403 || code == 404) { - // carve-out: destination 400 with blinding message is caller-handled - if (code == 400 && bodyText?.contains(REQUIRE_BLINDING_MESSAGE) == true) { - return FailureDecision.Fail(error) - } - return FailureDecision.Retry + //todo ONION need to move the REQUIRE_BLINDING_MESSAGE logic out of here, it should be handled at the calling site, in this case the community poller, to then call /capabilities once + return FailureDecision.Fail(error) } // -------------------------------------------------------------------- @@ -81,7 +77,8 @@ class OnionErrorManager @Inject constructor( } is OnionError.GuardUnreachable -> { - // Networky: penalise path; retry + // penalise path; retry + //todo ONION not sure yet whether we should punish the path here, or even if we should retry as it is likely a "no connection" issue pathManager.handleBadPath(ctx.path) return FailureDecision.Retry } @@ -89,6 +86,7 @@ class OnionErrorManager @Inject constructor( // InvalidResponse / Unknown: treat as path failure (penalise path; retry) is OnionError.InvalidResponse, is OnionError.Unknown -> { + //todo ONION also not sure whether to penalise path and retry here... pathManager.handleBadPath(ctx.path) return FailureDecision.Retry } @@ -106,7 +104,7 @@ class OnionErrorManager @Inject constructor( val targetSnode = ctx.targetSnode val updated = if (publicKey != null) { - swarmDirectory.tryUpdateSwarmFrom421( + swarmDirectory.updateSwarmFromResponse( publicKey = publicKey, body = status.body ) diff --git a/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt b/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt index e3c18ee649..c18ecadca4 100644 --- a/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt +++ b/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt @@ -88,7 +88,7 @@ class SwarmDirectory( * * @return true if swarm was updated from body JSON, false otherwise. */ - fun tryUpdateSwarmFrom421(publicKey: String, body: ByteArraySlice?): Boolean { + fun updateSwarmFromResponse(publicKey: String, body: ByteArraySlice?): Boolean { if (body == null || body.isEmpty()) return false val json: Map<*, *> = try { From 5b06f7bc459214a610e30a30efc59381b945af63 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 17 Dec 2025 08:38:43 +1100 Subject: [PATCH 16/77] fixes --- .../libsession/network/SessionClient.kt | 32 ++++++++++++++----- .../network/snode/SwarmDirectory.kt | 3 +- .../libsignal/utilities/ByteArraySlice.kt | 11 +++++-- 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/SessionClient.kt b/app/src/main/java/org/session/libsession/network/SessionClient.kt index 7f752180f5..c5aac636b7 100644 --- a/app/src/main/java/org/session/libsession/network/SessionClient.kt +++ b/app/src/main/java/org/session/libsession/network/SessionClient.kt @@ -1,7 +1,12 @@ package org.session.libsession.network +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream import network.loki.messenger.libsession_util.Hash import network.loki.messenger.libsession_util.SessionEncrypt +import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.network.onion.Version import org.session.libsession.network.snode.SnodeDirectory import org.session.libsession.network.snode.SwarmDirectory @@ -26,12 +31,13 @@ class SessionClient @Inject constructor( private val swarmDirectory: SwarmDirectory, private val snodeDirectory: SnodeDirectory, private val snodeClock: SnodeClock, + private val json: Json, ) { //todo ONION no retry logic atm //todo ONION missing alterTTL //todo ONION missing batch logic - //todo ONION missing snode error handling + //todo ONION figure out stream logic for invoke - old code had a decodeFromStream path /** * - Uses onion routing via SessionNetwork. @@ -41,16 +47,20 @@ class SessionClient @Inject constructor( * NOTE: This does *not* do any snode-failure accounting yet; that will be layered * on later (e.g. path/snode penalisation based on error codes). */ - suspend fun invoke( + @OptIn(ExperimentalSerializationApi::class) + suspend fun invoke( method: Snode.Method, snode: Snode, parameters: Map, + responseDeserializationStrategy: DeserializationStrategy, + publicKey: String? = null, version: Version = Version.V4 - ): Map<*, *> { + ): Res { val result = sessionNetwork.sendToSnode( method = method, parameters = parameters, snode = snode, + publicKey = publicKey, version = version ) @@ -63,8 +73,12 @@ class SessionClient @Inject constructor( val body = onionResponse.body ?: throw Error.Generic("Empty body from snode for method $method") - @Suppress("UNCHECKED_CAST") - return JsonUtil.fromJson(body, Map::class.java) as Map<*, *> + return body.inputStream().use { inputStream -> + json.decodeFromStream( + deserializer = responseDeserializationStrategy, + stream = inputStream + ) + } } /** @@ -78,7 +92,7 @@ class SessionClient @Inject constructor( require(swarm.isNotEmpty()) { "Swarm is empty for pubkey=$publicKey" } - // Old code used shuffledRandom(); we can approximate with shuffled() then random() + return swarm.shuffledRandom().random() } @@ -179,7 +193,8 @@ class SessionClient @Inject constructor( method = Snode.Method.SendMessage, snode = target, parameters = params, - version = version + version = version, + publicKey = message.recipient ) // Later you can map this Map<*, *> into StoreMessageResponse via kotlinx.serialization. @@ -221,7 +236,8 @@ class SessionClient @Inject constructor( method = Snode.Method.DeleteMessage, snode = snode, parameters = params, - version = version + version = version, + publicKey = publicKey ) // Old code walked json["swarm"] and verified ED25519 signatures. diff --git a/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt b/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt index c18ecadca4..46f3e707df 100644 --- a/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt +++ b/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt @@ -47,7 +47,8 @@ class SwarmDirectory( val onionResponse = result.getOrThrow() val body = onionResponse.body ?: error("Empty GetSwarm body") - val json = JsonUtil.fromJson(body, Map::class.java) as Map<*, *> + //todo ONION double check usage of copytoBytes are ok here and further down + val json = JsonUtil.fromJson(body.copyToBytes(), Map::class.java) as Map<*, *> return parseSnodes(json).toSet() } diff --git a/app/src/main/java/org/session/libsignal/utilities/ByteArraySlice.kt b/app/src/main/java/org/session/libsignal/utilities/ByteArraySlice.kt index 8bb047bbaf..09f32a3b25 100644 --- a/app/src/main/java/org/session/libsignal/utilities/ByteArraySlice.kt +++ b/app/src/main/java/org/session/libsignal/utilities/ByteArraySlice.kt @@ -13,8 +13,15 @@ class ByteArraySlice private constructor( val len: Int, ) { init { - check(offset in 0..data.size) { "Offset $offset is not within [0..${data.size}]" } - check(len in 0..data.size) { "Length $len is not within [0..${data.size}]" } + // Check negatives first + require(offset >= 0 && len >= 0) { + "Offset ($offset) and length ($len) must be non-negative" + } + + // Check bounds using subtraction to avoid overflow + require(offset <= data.size - len) { + "Slice [$offset..${offset + len}) is out of bounds for size ${data.size}" + } } fun view(range: IntRange): ByteArraySlice { From 8681f6d67df32440e4239a4a5968692686cce8db Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 17 Dec 2025 09:44:22 +1100 Subject: [PATCH 17/77] invoke overlaods --- .../libsession/network/SessionClient.kt | 86 +++++++++++-------- 1 file changed, 50 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/SessionClient.kt b/app/src/main/java/org/session/libsession/network/SessionClient.kt index c5aac636b7..e6e33f95a2 100644 --- a/app/src/main/java/org/session/libsession/network/SessionClient.kt +++ b/app/src/main/java/org/session/libsession/network/SessionClient.kt @@ -6,7 +6,6 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream import network.loki.messenger.libsession_util.Hash import network.loki.messenger.libsession_util.SessionEncrypt -import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.network.onion.Version import org.session.libsession.network.snode.SnodeDirectory import org.session.libsession.network.snode.SwarmDirectory @@ -14,6 +13,7 @@ import org.session.libsession.snode.SnodeMessage import org.session.libsession.snode.SwarmAuth import org.session.libsignal.crypto.shuffledRandom import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.ByteArraySlice import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log @@ -21,9 +21,10 @@ import org.session.libsignal.utilities.Snode import java.util.Locale import javax.inject.Inject import javax.inject.Singleton +import kotlin.collections.get /** - * High-level client for interacting with snodes + * High-level client for interacting with snodes. */ @Singleton class SessionClient @Inject constructor( @@ -37,25 +38,20 @@ class SessionClient @Inject constructor( //todo ONION no retry logic atm //todo ONION missing alterTTL //todo ONION missing batch logic - //todo ONION figure out stream logic for invoke - old code had a decodeFromStream path /** - * - Uses onion routing via SessionNetwork. - * - Expects the snode to return a JSON body (storage_rpc style). - * - Returns that JSON as a Map for now. + * Single source of truth for RPC invocation. * - * NOTE: This does *not* do any snode-failure accounting yet; that will be layered - * on later (e.g. path/snode penalisation based on error codes). + * - Uses onion routing via SessionNetwork. + * - Returns the raw response body as a ByteArraySlice. */ - @OptIn(ExperimentalSerializationApi::class) - suspend fun invoke( + private suspend fun invokeRaw( method: Snode.Method, snode: Snode, parameters: Map, - responseDeserializationStrategy: DeserializationStrategy, publicKey: String? = null, version: Version = Version.V4 - ): Res { + ): ByteArraySlice { val result = sessionNetwork.sendToSnode( method = method, parameters = parameters, @@ -70,8 +66,26 @@ class SessionClient @Inject constructor( } val onionResponse = result.getOrThrow() - val body = onionResponse.body + return onionResponse.body ?: throw Error.Generic("Empty body from snode for method $method") + } + + @OptIn(ExperimentalSerializationApi::class) + suspend fun invokeTyped( + method: Snode.Method, + snode: Snode, + parameters: Map, + responseDeserializationStrategy: DeserializationStrategy, + publicKey: String? = null, + version: Version = Version.V4 + ): Res { + val body = invokeRaw( + method = method, + snode = snode, + parameters = parameters, + publicKey = publicKey, + version = version + ) return body.inputStream().use { inputStream -> json.decodeFromStream( @@ -81,18 +95,31 @@ class SessionClient @Inject constructor( } } + suspend fun invoke( + method: Snode.Method, + snode: Snode, + parameters: Map, + publicKey: String? = null, + version: Version = Version.V4 + ): Map<*, *> { + val body = invokeRaw( + method = method, + snode = snode, + parameters = parameters, + publicKey = publicKey, + version = version + ) + + return JsonUtil.fromJson(body.decodeToString(), Map::class.java) as Map<*, *> + } + /** - * Rough equivalent of old getSingleTargetSnode(publicKey). - * * Picks one snode from the user's swarm for a given account. * We deliberately randomise to avoid hammering a single node. */ private suspend fun getSingleTargetSnode(publicKey: String): Snode { val swarm = swarmDirectory.getSwarm(publicKey) - require(swarm.isNotEmpty()) { - "Swarm is empty for pubkey=$publicKey" - } - + require(swarm.isNotEmpty()) { "Swarm is empty for pubkey=$publicKey" } return swarm.shuffledRandom().random() } @@ -146,10 +173,6 @@ class SessionClient @Inject constructor( * Rough port of old SnodeAPI.sendMessage, but: * - No additional "outer" retry layer yet (we rely on SessionNetwork's onion retry). * - No batching; we send a single SendMessage RPC. - * - * TODO: - * - Wire in higher-level retryWithUniformInterval-style behaviour if needed. - * - Return a strongly typed StoreMessageResponse once the model & serialization are wired. */ suspend fun sendMessage( message: SnodeMessage, @@ -189,16 +212,13 @@ class SessionClient @Inject constructor( Log.d("SessionClient", "Sending message to ${target.address}:${target.port} for ${message.recipient}") // In old code this went through batch API; here we do a simple single-RPC SendMessage. - val json = invoke( + return invoke( method = Snode.Method.SendMessage, snode = target, parameters = params, version = version, publicKey = message.recipient ) - - // Later you can map this Map<*, *> into StoreMessageResponse via kotlinx.serialization. - return json } /** @@ -232,17 +252,13 @@ class SessionClient @Inject constructor( Log.d("SessionClient", "Deleting messages on ${snode.address}:${snode.port} for $publicKey") - val json = invoke( + return invoke( method = Snode.Method.DeleteMessage, snode = snode, parameters = params, version = version, publicKey = publicKey ) - - // Old code walked json["swarm"] and verified ED25519 signatures. - // You can port that verification logic into a separate helper later if you want parity. - return json } suspend fun getNetworkTime( @@ -255,10 +271,8 @@ class SessionClient @Inject constructor( parameters = emptyMap(), version = version ) - - val timestamp = json["timestamp"] as? Long - ?: throw Error.Generic("Missing 'timestamp' in Info response") - + + val timestamp = json["timestamp"] as? Long ?: -1 return snode to timestamp } From 832076f070ad35a04fac1dd224b71009705ed9f7 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 17 Dec 2025 10:38:34 +1100 Subject: [PATCH 18/77] better deserialization --- .../org/session/libsession/network/SessionClient.kt | 13 +++++++++---- .../network/onion/http/HttpOnionTransport.kt | 5 ++--- .../libsession/network/snode/SwarmDirectory.kt | 5 ++--- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/SessionClient.kt b/app/src/main/java/org/session/libsession/network/SessionClient.kt index e6e33f95a2..cc98f9d766 100644 --- a/app/src/main/java/org/session/libsession/network/SessionClient.kt +++ b/app/src/main/java/org/session/libsession/network/SessionClient.kt @@ -21,7 +21,6 @@ import org.session.libsignal.utilities.Snode import java.util.Locale import javax.inject.Inject import javax.inject.Singleton -import kotlin.collections.get /** * High-level client for interacting with snodes. @@ -110,7 +109,7 @@ class SessionClient @Inject constructor( version = version ) - return JsonUtil.fromJson(body.decodeToString(), Map::class.java) as Map<*, *> + return JsonUtil.fromJson(body, Map::class.java) as Map<*, *> } /** @@ -271,8 +270,14 @@ class SessionClient @Inject constructor( parameters = emptyMap(), version = version ) - - val timestamp = json["timestamp"] as? Long ?: -1 + + val timestamp = when (val t = json["timestamp"]) { + is Long -> t + is Int -> t.toLong() + is Double -> t.toLong() + else -> -1 + } + return snode to timestamp } diff --git a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt index 5eb6719671..1cdeae99f2 100644 --- a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt +++ b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt @@ -143,9 +143,8 @@ class HttpOnionTransport : OnionTransport { val infoEndIndex = infoStartIndex + infoLength if (infoEndIndex > decrypted.size) return Result.failure(OnionError.InvalidResponse()) - val infoBytes = decrypted.slice(infoStartIndex until infoEndIndex).toByteArray() - @Suppress("UNCHECKED_CAST") - val responseInfo = JsonUtil.fromJson(infoBytes, Map::class.java) as Map<*, *> + val infoSlice = decrypted.view(infoStartIndex until infoEndIndex) + val responseInfo = JsonUtil.fromJson(infoSlice, Map::class.java) as Map<*, *> val statusCode = responseInfo["code"].toString().toInt() diff --git a/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt b/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt index 46f3e707df..4cec139f65 100644 --- a/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt +++ b/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt @@ -47,8 +47,7 @@ class SwarmDirectory( val onionResponse = result.getOrThrow() val body = onionResponse.body ?: error("Empty GetSwarm body") - //todo ONION double check usage of copytoBytes are ok here and further down - val json = JsonUtil.fromJson(body.copyToBytes(), Map::class.java) as Map<*, *> + val json = JsonUtil.fromJson(body, Map::class.java) as Map<*, *> return parseSnodes(json).toSet() } @@ -93,7 +92,7 @@ class SwarmDirectory( if (body == null || body.isEmpty()) return false val json: Map<*, *> = try { - JsonUtil.fromJson(body.copyToBytes(), Map::class.java) as Map<*, *> + JsonUtil.fromJson(body, Map::class.java) as Map<*, *> } catch (_: Throwable) { return false } From 24b3452241aa0a9abfd76a759b46d597c4eb7227 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 17 Dec 2025 14:49:20 +1100 Subject: [PATCH 19/77] Batching from old code --- .../libsession/network/SessionClient.kt | 666 +++++++++++++++--- .../libsession/network/model/BatchResponse.kt | 32 + .../network/model/MessageResponses.kt | 45 ++ .../network/model/OwnedSwarmAuth.kt | 34 + 4 files changed, 670 insertions(+), 107 deletions(-) create mode 100644 app/src/main/java/org/session/libsession/network/model/BatchResponse.kt create mode 100644 app/src/main/java/org/session/libsession/network/model/MessageResponses.kt create mode 100644 app/src/main/java/org/session/libsession/network/model/OwnedSwarmAuth.kt diff --git a/app/src/main/java/org/session/libsession/network/SessionClient.kt b/app/src/main/java/org/session/libsession/network/SessionClient.kt index cc98f9d766..94a2bf7f50 100644 --- a/app/src/main/java/org/session/libsession/network/SessionClient.kt +++ b/app/src/main/java/org/session/libsession/network/SessionClient.kt @@ -1,19 +1,43 @@ package org.session.libsession.network +import android.os.SystemClock +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.launch +import kotlinx.coroutines.selects.onTimeout +import kotlinx.coroutines.selects.select import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.decodeFromStream +import network.loki.messenger.libsession_util.ED25519 import network.loki.messenger.libsession_util.Hash import network.loki.messenger.libsession_util.SessionEncrypt +import org.session.libsession.network.model.ErrorStatus +import org.session.libsession.network.model.OnionDestination +import org.session.libsession.network.model.OnionError +import org.session.libsession.network.onion.FailureDecision +import org.session.libsession.network.onion.OnionErrorManager +import org.session.libsession.network.onion.OnionFailureContext import org.session.libsession.network.onion.Version import org.session.libsession.network.snode.SnodeDirectory import org.session.libsession.network.snode.SwarmDirectory import org.session.libsession.snode.SnodeMessage import org.session.libsession.snode.SwarmAuth +import org.session.libsession.snode.model.BatchResponse +import org.session.libsession.snode.model.RetrieveMessageResponse +import org.session.libsession.snode.model.StoreMessageResponse +import org.session.libsession.utilities.mapValuesNotNull +import org.session.libsession.utilities.toByteArray import org.session.libsignal.crypto.shuffledRandom import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.ByteArraySlice +import org.session.libsignal.utilities.ByteArraySlice.Companion.view import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log @@ -21,6 +45,9 @@ import org.session.libsignal.utilities.Snode import java.util.Locale import javax.inject.Inject import javax.inject.Singleton +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.get /** * High-level client for interacting with snodes. @@ -32,18 +59,103 @@ class SessionClient @Inject constructor( private val snodeDirectory: SnodeDirectory, private val snodeClock: SnodeClock, private val json: Json, + private val errorManager: OnionErrorManager ) { - //todo ONION no retry logic atm - //todo ONION missing alterTTL - //todo ONION missing batch logic + //todo ONION missing retry strategies + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val batchedRequestsSender: SendChannel + + init { + val batchRequests = Channel(capacity = Channel.UNLIMITED) + batchedRequestsSender = batchRequests + + val batchWindowMs = 100L + + data class BatchKey(val snodeAddress: String, val snodePort: Int, val publicKey: String, val sequence: Boolean, val version: Version) + + scope.launch { + val batches = hashMapOf>() + + while (true) { + val batch: List? = select { + // If we receive a request, add it to the batch + batchRequests.onReceive { req -> + val key = BatchKey(req.snode.address, req.snode.port, req.publicKey, req.sequence, req.version) + batches.getOrPut(key) { mutableListOf() }.add(req) + null + } + + // If we have anything in the batch, look for the one that is about to expire + // and wait for it to expire, remove it from the batches and send it for + // processing. + if (batches.isNotEmpty()) { + val earliestBatch = batches.minBy { it.value.first().requestTimeMs } + val deadline = earliestBatch.value.first().requestTimeMs + batchWindowMs + onTimeout((deadline - SystemClock.elapsedRealtime()).coerceAtLeast(0)) { + batches.remove(earliestBatch.key) + } + } + } + + if (batch == null) continue + + scope.launch { + val snode = batch.first().snode + val pubKey = batch.first().publicKey + val sequence = batch.first().sequence + val version = batch.first().version + + val batchResponse: BatchResponse = try { + getBatchResponse( + snode = snode, + publicKey = pubKey, + requests = batch.map { it.request }, + sequence = sequence, + version = version + ) + } catch (e: Throwable) { + for (req in batch) runCatching { req.callback.send(Result.failure(e)) } + for (req in batch) req.callback.close() + return@launch + } + + val items = batchResponse.results + val count = minOf(batch.size, items.size) + + for (i in 0 until count) { + val req = batch[i] + val item = items[i] + + val result: Result = runCatching { + if (!item.isSuccessful) throw BatchResponse.Error(item) + + // Decode each sub-response body into expected type + @Suppress("UNCHECKED_CAST") + json.decodeFromJsonElement( + deserializer = req.responseType as DeserializationStrategy, + element = item.body + ) + } + + runCatching { req.callback.send(result) } + } + + // If mismatch, fail remaining + if (items.size < batch.size) { + val err = Error.Generic("Batch response contained ${items.size} items for ${batch.size} requests") + for (i in items.size until batch.size) { + runCatching { batch[i].callback.send(Result.failure(err)) } + } + } + + for (req in batch) req.callback.close() + } + } + } + } - /** - * Single source of truth for RPC invocation. - * - * - Uses onion routing via SessionNetwork. - * - Returns the raw response body as a ByteArraySlice. - */ private suspend fun invokeRaw( method: Snode.Method, snode: Snode, @@ -109,76 +221,22 @@ class SessionClient @Inject constructor( version = version ) + @Suppress("UNCHECKED_CAST") return JsonUtil.fromJson(body, Map::class.java) as Map<*, *> } - /** - * Picks one snode from the user's swarm for a given account. - * We deliberately randomise to avoid hammering a single node. - */ - private suspend fun getSingleTargetSnode(publicKey: String): Snode { - val swarm = swarmDirectory.getSwarm(publicKey) - require(swarm.isNotEmpty()) { "Swarm is empty for pubkey=$publicKey" } - return swarm.shuffledRandom().random() - } + // Client methods /** - * Build parameters required to call authenticated storage API. - * - * @param auth The authentication data required to sign the request - * @param namespace The namespace of the messages. Null if not relevant. - * @param verificationData A function that returns the data to be signed. - * It gets the namespace text and timestamp. - * @param timestamp The timestamp to be used in the request. Default is network-adjusted time. - * @param builder Lambda for additional custom parameters. - */ - private fun buildAuthenticatedParameters( - auth: SwarmAuth, - namespace: Int?, - verificationData: ((namespaceText: String, timestamp: Long) -> Any)? = null, - timestamp: Long = snodeClock.currentTimeMills(), - builder: MutableMap.() -> Unit = {} - ): Map { - return buildMap { - // Callers can add their own params first - this.builder() - - if (verificationData != null) { - val namespaceText = when (namespace) { - null, 0 -> "" - else -> namespace.toString() - } - - val verifyData = when (val v = verificationData(namespaceText, timestamp)) { - is String -> v.toByteArray() - is ByteArray -> v - else -> throw IllegalArgumentException("verificationData must return String or ByteArray") - } - - putAll(auth.sign(verifyData)) - put("timestamp", timestamp) - } - - put("pubkey", auth.accountId.hexString) - if (namespace != null && namespace != 0) { - put("namespace", namespace) - } - - auth.ed25519PublicKeyHex?.let { put("pubkey_ed25519", it) } - } - } - - /** - * Rough port of old SnodeAPI.sendMessage, but: - * - No additional "outer" retry layer yet (we rely on SessionNetwork's onion retry). - * - No batching; we send a single SendMessage RPC. + * Note: After this method returns, [auth] will not be used by any of async calls and it's afe + * for the caller to clean up the associated resources if needed. */ suspend fun sendMessage( message: SnodeMessage, auth: SwarmAuth?, namespace: Int = 0, version: Version = Version.V4 - ): Map<*, *> { + ): StoreMessageResponse { val params: Map = if (auth != null) { check(auth.accountId.hexString == message.recipient) { "Message sent to ${message.recipient} but authenticated with ${auth.accountId.hexString}" @@ -189,9 +247,7 @@ class SessionClient @Inject constructor( buildAuthenticatedParameters( auth = auth, namespace = namespace, - verificationData = { ns, t -> - "${Snode.Method.SendMessage.rawValue}$ns$t" - }, + verificationData = { ns, t -> "${Snode.Method.SendMessage.rawValue}$ns$t" }, timestamp = timestamp ) { put("sig_timestamp", timestamp) @@ -200,34 +256,26 @@ class SessionClient @Inject constructor( } else { buildMap { putAll(message.toJSON()) - if (namespace != 0) { - put("namespace", namespace) - } + if (namespace != 0) put("namespace", namespace) } } val target = getSingleTargetSnode(message.recipient) - Log.d("SessionClient", "Sending message to ${target.address}:${target.port} for ${message.recipient}") - - // In old code this went through batch API; here we do a simple single-RPC SendMessage. - return invoke( - method = Snode.Method.SendMessage, + return sendBatchRequest( snode = target, - parameters = params, - version = version, - publicKey = message.recipient + publicKey = message.recipient, + request = SnodeBatchRequestInfo( + method = Snode.Method.SendMessage.rawValue, + params = params, + namespace = namespace + ), + responseType = StoreMessageResponse.serializer(), + sequence = false, + version = version ) } - /** - * Simplified version of old SnodeAPI.deleteMessage. - * - * Differences vs old code: - * - We do NOT (yet) verify the per-snode signatures of deletions. - * - We do a single DeleteMessage RPC; no extra retryWithUniformInterval wrapper for now. - * - We return the raw JSON map so you can layer richer logic on top later. - */ suspend fun deleteMessage( publicKey: String, auth: SwarmAuth, @@ -260,6 +308,62 @@ class SessionClient @Inject constructor( ) } + suspend fun deleteAllMessages( + auth: SwarmAuth, + version: Version = Version.V4 + ): Map { + val publicKey = auth.accountId.hexString + val snode = getSingleTargetSnode(publicKey) + + // Prefer network-adjusted time for signature compatibility + val timestamp = snodeClock.waitForNetworkAdjustedTime() + + val params = buildAuthenticatedParameters( + auth = auth, + namespace = null, + verificationData = { _, t -> "${Snode.Method.DeleteAll.rawValue}all$t" }, + timestamp = timestamp + ) { + put("namespace", "all") + } + + val raw = invoke( + method = Snode.Method.DeleteAll, + snode = snode, + parameters = params, + publicKey = publicKey, + version = version + ) + + return parseDeletions( + userPublicKey = publicKey, + timestamp = timestamp, + rawResponse = raw + ) + } + + @Suppress("UNCHECKED_CAST") + private fun parseDeletions(userPublicKey: String, timestamp: Long, rawResponse: Map<*, *>): Map = + (rawResponse["swarm"] as? Map)?.mapValuesNotNull { (hexSnodePublicKey, rawJSON) -> + val json = rawJSON as? Map ?: return@mapValuesNotNull null + if (json["failed"] as? Boolean == true) { + val reason = json["reason"] as? String + val statusCode = json["code"]?.toString() + Log.e("Loki", "Failed to delete all messages from: $hexSnodePublicKey due to error: $reason ($statusCode).") + false + } else { + val hashes = (json["deleted"] as Map>).flatMap { (_, hashes) -> hashes }.sorted() // Hashes of deleted messages + val signature = json["signature"] as String + // The signature looks like ( PUBKEY_HEX || TIMESTAMP || DELETEDHASH[0] || ... || DELETEDHASH[N] ) + val message = sequenceOf(userPublicKey, "$timestamp").plus(hashes).toByteArray() + ED25519.verify( + ed25519PublicKey = Hex.fromStringCondensed(hexSnodePublicKey), + signature = Base64.decode(signature), + message = message, + ) + } + } ?: mapOf() + suspend fun getNetworkTime( snode: Snode, version: Version = Version.V4 @@ -281,28 +385,15 @@ class SessionClient @Inject constructor( return snode to timestamp } - /** - * Resolve an ONS name into an account ID (33-byte value as hex string). - * - * Rough port of old SnodeAPI.getAccountID: - * - Lowercases the name. - * - Asks 3 different snodes for the ONS resolution. - * - Decrypts the result and requires all 3 to match. - * - * Throws if validation fails. - */ suspend fun getAccountID(onsName: String): String { val validationCount = 3 val onsNameLower = onsName.lowercase(Locale.US) - // Build request params for ons_resolve via OxenDaemonRPCCall val params: Map = buildMap { this["method"] = "ons_resolve" this["params"] = buildMap { - this["type"] = 0 // session account type - this["name_hash"] = Base64.encodeBytes( - Hash.hash32(onsNameLower.toByteArray()) - ) + this["type"] = 0 + this["name_hash"] = Base64.encodeBytes(Hash.hash32(onsNameLower.toByteArray())) } } @@ -346,11 +437,372 @@ class SessionClient @Inject constructor( } } + suspend fun alterTtl( + auth: SwarmAuth, + messageHashes: List, + newExpiry: Long, + shorten: Boolean = false, + extend: Boolean = false, + version: Version = Version.V4 + ): Map<*, *> { + val snode = getSingleTargetSnode(auth.accountId.hexString) + val params = buildAlterTtlParams(auth, messageHashes, newExpiry, shorten, extend) + + return invoke( + method = Snode.Method.Expire, + snode = snode, + parameters = params, + publicKey = auth.accountId.hexString, + version = version + ) + } + + + // Batch logic + + /** + * Picks one snode from the user's swarm for a given account. + * We deliberately randomise to avoid hammering a single node. + */ + private suspend fun getSingleTargetSnode(publicKey: String): Snode { + val swarm = swarmDirectory.getSwarm(publicKey) + require(swarm.isNotEmpty()) { "Swarm is empty for pubkey=$publicKey" } + return swarm.shuffledRandom().random() + } + + private fun buildAuthenticatedParameters( + auth: SwarmAuth, + namespace: Int?, + verificationData: ((namespaceText: String, timestamp: Long) -> Any)? = null, + timestamp: Long = snodeClock.currentTimeMills(), + builder: MutableMap.() -> Unit = {} + ): Map { + return buildMap { + this.builder() + + if (verificationData != null) { + val namespaceText = when (namespace) { + null, 0 -> "" + else -> namespace.toString() + } + + val verifyData = when (val v = verificationData(namespaceText, timestamp)) { + is String -> v.toByteArray() + is ByteArray -> v + else -> throw IllegalArgumentException("verificationData must return String or ByteArray") + } + + putAll(auth.sign(verifyData)) + put("timestamp", timestamp) + } + + put("pubkey", auth.accountId.hexString) + if (namespace != null && namespace != 0) put("namespace", namespace) + auth.ed25519PublicKeyHex?.let { put("pubkey_ed25519", it) } + } + } + + /** + * Typed batch/sequence response envelope. + */ + suspend fun getBatchResponse( + snode: Snode, + publicKey: String, + requests: List, + sequence: Boolean = false, + version: Version = Version.V4 + ): BatchResponse { + val method = if (sequence) Snode.Method.Sequence else Snode.Method.Batch + val response = invokeTyped( + method = method, + snode = snode, + parameters = mapOf("requests" to requests), + responseDeserializationStrategy = BatchResponse.serializer(), + publicKey = publicKey, + version = version + ) + + // IMPORTANT: batch subresponse failures do not go through OnionErrorManager + // because the outer response is usually 200. + val firstFailed = response.results.firstOrNull { !it.isSuccessful } + if (firstFailed != null) { + handleBatchItemFailure( + targetSnode = snode, + publicKey = publicKey, + item = firstFailed + ) + } + + return response + } + + private suspend fun handleBatchItemFailure( + item: BatchResponse.Item, + targetSnode: Snode, + publicKey: String?, + ) : FailureDecision { + //todo ONION can we think of a better way to integrate batching with error handling? Right now this is a temporary way to fit it into our system + // we might be missing things like the path or the message + + val bodySlice = item.body.toString().toByteArray(Charsets.UTF_8).view() + + // we synthesise a DestinationError since what we get at this point is from the destination's response + val err = OnionError.DestinationError( + ErrorStatus(code = item.code, message = null, body = bodySlice) + ) + + return errorManager.onFailure( + error = err, + ctx = OnionFailureContext( + path = listOf(targetSnode), + destination = OnionDestination.SnodeDestination(targetSnode), + targetSnode = targetSnode, + publicKey = publicKey + ) + ) + } + + /** + * Convenience: single-request batching (coalesced for ~100ms). + */ + @Suppress("UNCHECKED_CAST") + suspend fun sendBatchRequest( + snode: Snode, + publicKey: String, + request: SnodeBatchRequestInfo, + responseType: DeserializationStrategy, + sequence: Boolean = false, + version: Version = Version.V4 + ): T { + val callback = Channel>(capacity = 1) + + batchedRequestsSender.send( + RequestInfo( + snode = snode, + publicKey = publicKey, + request = request, + responseType = responseType, + callback = callback, + sequence = sequence, + version = version + ) + ) + + try { + return callback.receive().getOrThrow() as T + } catch (e: CancellationException) { + // Close the channel if the coroutine is cancelled, so the batch processing won't + // handle this one (best effort only) + callback.close() + throw e + } + } + + suspend fun sendBatchRequest( + snode: Snode, + publicKey: String, + request: SnodeBatchRequestInfo, + sequence: Boolean = false, + version: Version = Version.V4 + ): JsonElement { + return sendBatchRequest( + snode = snode, + publicKey = publicKey, + request = request, + responseType = JsonElement.serializer(), + sequence = sequence, + version = version + ) + } + + fun buildAuthenticatedAlterTtlBatchRequest( + auth: SwarmAuth, + messageHashes: List, + newExpiry: Long, + shorten: Boolean = false, + extend: Boolean = false + ): SnodeBatchRequestInfo { + val params = buildAlterTtlParams(auth, messageHashes, newExpiry, shorten, extend) + return SnodeBatchRequestInfo( + method = Snode.Method.Expire.rawValue, + params = params, + namespace = null + ) + } + + private fun buildAlterTtlParams( + auth: SwarmAuth, + messageHashes: List, + newExpiry: Long, + shorten: Boolean, + extend: Boolean + ): Map { + val modifier = when { + extend -> "extend" + shorten -> "shorten" + else -> "" + } + + return buildAuthenticatedParameters( + auth = auth, + namespace = null, + verificationData = { _, _ -> + buildString { + append(Snode.Method.Expire.rawValue) + append(modifier) + append(newExpiry.toString()) + messageHashes.forEach(this::append) + } + } + ) { + put("expiry", newExpiry) + put("messages", messageHashes) + when { + extend -> put("extend", true) + shorten -> put("shorten", true) + } + } + } + + fun buildAuthenticatedStoreBatchInfo( + namespace: Int, + message: SnodeMessage, + auth: SwarmAuth, + ): SnodeBatchRequestInfo { + check(message.recipient == auth.accountId.hexString) { + "Message sent to ${message.recipient} but authenticated with ${auth.accountId.hexString}" + } + + val params = buildAuthenticatedParameters( + namespace = namespace, + auth = auth, + verificationData = { ns, t -> "${Snode.Method.SendMessage.rawValue}$ns$t" }, + ) { + putAll(message.toJSON()) + } + + return SnodeBatchRequestInfo( + method = Snode.Method.SendMessage.rawValue, + params = params, + namespace = namespace + ) + } + + fun buildAuthenticatedRetrieveBatchRequest( + auth: SwarmAuth, + lastHash: String?, + namespace: Int = 0, + maxSize: Int? = null + ): SnodeBatchRequestInfo { + val params = buildAuthenticatedParameters( + namespace = namespace, + auth = auth, + verificationData = { ns, t -> "${Snode.Method.Retrieve.rawValue}$ns$t" }, + ) { + put("last_hash", lastHash.orEmpty()) + if (maxSize != null) put("max_size", maxSize) + } + + return SnodeBatchRequestInfo( + method = Snode.Method.Retrieve.rawValue, + params = params, + namespace = namespace + ) + } + + fun buildAuthenticatedDeleteBatchInfo( + auth: SwarmAuth, + messageHashes: List, + required: Boolean = false + ): SnodeBatchRequestInfo { + val params = buildAuthenticatedParameters( + namespace = null, + auth = auth, + verificationData = { _, _ -> + buildString { + append(Snode.Method.DeleteMessage.rawValue) + messageHashes.forEach(this::append) + } + } + ) { + put("messages", messageHashes) + put("required", required) + } + + return SnodeBatchRequestInfo( + method = Snode.Method.DeleteMessage.rawValue, + params = params, + namespace = null + ) + } + + fun buildAuthenticatedUnrevokeSubKeyBatchRequest( + groupAdminAuth: SwarmAuth, + subAccountTokens: List, + ): SnodeBatchRequestInfo { + val params = buildAuthenticatedParameters( + namespace = null, + auth = groupAdminAuth, + verificationData = { _, t -> + subAccountTokens.fold( + "${Snode.Method.UnrevokeSubAccount.rawValue}$t".toByteArray() + ) { acc, subAccount -> acc + subAccount } + } + ) { + put("unrevoke", subAccountTokens.map(Base64::encodeBytes)) + } + + return SnodeBatchRequestInfo( + method = Snode.Method.UnrevokeSubAccount.rawValue, + params = params, + namespace = null + ) + } + + fun buildAuthenticatedRevokeSubKeyBatchRequest( + groupAdminAuth: SwarmAuth, + subAccountTokens: List, + ): SnodeBatchRequestInfo { + val params = buildAuthenticatedParameters( + namespace = null, + auth = groupAdminAuth, + verificationData = { _, t -> + subAccountTokens.fold( + "${Snode.Method.RevokeSubAccount.rawValue}$t".toByteArray() + ) { acc, subAccount -> acc + subAccount } + } + ) { + put("revoke", subAccountTokens.map(Base64::encodeBytes)) + } + + return SnodeBatchRequestInfo( + method = Snode.Method.RevokeSubAccount.rawValue, + params = params, + namespace = null + ) + } + + + data class SnodeBatchRequestInfo( + val method: String, + val params: Map, + @Transient val namespace: Int?, + ) + + private data class RequestInfo( + val snode: Snode, + val publicKey: String, + val request: SnodeBatchRequestInfo, + val responseType: DeserializationStrategy<*>, + val callback: SendChannel>, + val requestTimeMs: Long = SystemClock.elapsedRealtime(), + val sequence: Boolean = false, + val version: Version = Version.V4, + ) + // Error sealed class Error(val description: String) : Exception(description) { data class Generic(val info: String = "An error occurred.") : Error(info) - - // ONS object ValidationFailed : Error("ONS name validation failed.") } } diff --git a/app/src/main/java/org/session/libsession/network/model/BatchResponse.kt b/app/src/main/java/org/session/libsession/network/model/BatchResponse.kt new file mode 100644 index 0000000000..d3fa2acd19 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/model/BatchResponse.kt @@ -0,0 +1,32 @@ +package org.session.libsession.snode.model + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable + +data class BatchResponse(val results: List, ) { + @Serializable + data class Item( + val code: Int, + val body: JsonElement, + ) { + val isSuccessful: Boolean + get() = code in 200..299 + + val isServerError: Boolean + get() = code in 500..599 + + val isSnodeNoLongerPartOfSwarm: Boolean + get() = code == 421 + } + + data class Error(val item: Item) + : RuntimeException("Batch request failed with code ${item.code}") { + init { + require(!item.isSuccessful) { + "This response item does not represent an error state" + } + } + } +} diff --git a/app/src/main/java/org/session/libsession/network/model/MessageResponses.kt b/app/src/main/java/org/session/libsession/network/model/MessageResponses.kt new file mode 100644 index 0000000000..35ec0f25cf --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/model/MessageResponses.kt @@ -0,0 +1,45 @@ +package org.session.libsession.snode.model + +import android.util.Base64 +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.session.libsession.utilities.serializable.InstantAsMillisSerializer +import java.time.Instant + +@Serializable +data class StoreMessageResponse( + val hash: String, + @Serializable(InstantAsMillisSerializer::class) + @SerialName("t") val timestamp: Instant, +) + +@Serializable +data class RetrieveMessageResponse( + val messages: List, +) { + @Serializable + data class Message( + val hash: String, + + // Some messages use "t" as timestamp field + @Serializable(InstantAsMillisSerializer::class) + @SerialName("t") + private val t1: Instant? = null, + + // Some messages use "timestamp" as timestamp field + @Serializable(InstantAsMillisSerializer::class) + @SerialName("timestamp") + private val t2: Instant? = null, + + @SerialName("data") + val dataB64: String? = null, + ) { + val data: ByteArray by lazy { + Base64.decode(dataB64, Base64.DEFAULT) + } + + val timestamp: Instant get() = requireNotNull(t1 ?: t2) { + "Message timestamp is missing" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/network/model/OwnedSwarmAuth.kt b/app/src/main/java/org/session/libsession/network/model/OwnedSwarmAuth.kt new file mode 100644 index 0000000000..fa906fc259 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/model/OwnedSwarmAuth.kt @@ -0,0 +1,34 @@ +package org.session.libsession.snode + +import network.loki.messenger.libsession_util.ED25519 +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Base64 + +/** + * A [SwarmAuth] that signs message using a single ED25519 private key. + * + * This should be used for the owner of an account, like a user or a group admin. + */ +class OwnedSwarmAuth( + override val accountId: AccountId, + override val ed25519PublicKeyHex: String?, + val ed25519PrivateKey: ByteArray, +) : SwarmAuth { + override fun sign(data: ByteArray): Map { + val signature = Base64.encodeBytes(ED25519.sign(ed25519PrivateKey = ed25519PrivateKey, message = data)) + + return buildMap { + put("signature", signature) + } + } + + companion object { + fun ofClosedGroup(groupAccountId: AccountId, adminKey: ByteArray): OwnedSwarmAuth { + return OwnedSwarmAuth( + accountId = groupAccountId, + ed25519PublicKeyHex = null, + ed25519PrivateKey = adminKey + ) + } + } +} \ No newline at end of file From 00a8c71a2c18d1e256611dab0badebfbd9f8c5b9 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 17 Dec 2025 14:59:48 +1100 Subject: [PATCH 20/77] Missing methods --- .../libsession/network/SessionClient.kt | 55 ++++++++++++++++--- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/SessionClient.kt b/app/src/main/java/org/session/libsession/network/SessionClient.kt index 94a2bf7f50..193555f479 100644 --- a/app/src/main/java/org/session/libsession/network/SessionClient.kt +++ b/app/src/main/java/org/session/libsession/network/SessionClient.kt @@ -225,6 +225,8 @@ class SessionClient @Inject constructor( return JsonUtil.fromJson(body, Map::class.java) as Map<*, *> } + //todo ONION the methods below haven't been fully refactored - This is part of the next step of this refactor + // Client methods /** @@ -276,12 +278,15 @@ class SessionClient @Inject constructor( ) } + @Suppress("UNCHECKED_CAST") suspend fun deleteMessage( publicKey: String, auth: SwarmAuth, serverHashes: List, version: Version = Version.V4 ): Map<*, *> { + val snode = getSingleTargetSnode(publicKey) + val params = buildAuthenticatedParameters( auth = auth, namespace = null, @@ -295,19 +300,55 @@ class SessionClient @Inject constructor( this["messages"] = serverHashes } - val snode = getSingleTargetSnode(publicKey) - - Log.d("SessionClient", "Deleting messages on ${snode.address}:${snode.port} for $publicKey") - - return invoke( + val rawResponse = invoke( method = Snode.Method.DeleteMessage, snode = snode, parameters = params, - version = version, - publicKey = publicKey + publicKey = publicKey, + version = version ) + + val swarms = rawResponse["swarm"] as? Map ?: throw Error.Generic("Missing swarm in delete response") + + val deletedMessages: Map = swarms.mapValuesNotNull { (hexSnodePublicKey, rawJSON) -> + val json = rawJSON as? Map ?: return@mapValuesNotNull null + + val isFailed = json["failed"] as? Boolean ?: false + val statusCode = json["code"]?.toString() + val reason = json["reason"] as? String + + if (isFailed) { + Log.d("SessionClient", "DeleteMessage failed on $hexSnodePublicKey: $reason ($statusCode)") + false + } else { + val hashes = (json["deleted"] as? List<*>)?.filterIsInstance() + ?: return@mapValuesNotNull false + + val signature = json["signature"] as? String + ?: return@mapValuesNotNull false + + // Signature: ( PUBKEY_HEX || RMSG[0]..RMSG[N] || DMSG[0]..DMSG[M] ) + val message = sequenceOf(auth.accountId.hexString) + .plus(serverHashes) + .plus(hashes) + .toByteArray() + + ED25519.verify( + ed25519PublicKey = Hex.fromStringCondensed(hexSnodePublicKey), + signature = Base64.decode(signature), + message = message + ) + } + } + + if (deletedMessages.entries.all { !it.value }) { + throw Error.Generic("DeleteMessage did not succeed on any swarm member") + } + + return rawResponse } + suspend fun deleteAllMessages( auth: SwarmAuth, version: Version = Version.V4 From 47d8808b2758ad0c7e20748ab22291c3d92c30e6 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 17 Dec 2025 15:08:21 +1100 Subject: [PATCH 21/77] cleaned path manager --- .../libsession/network/onion/PathManager.kt | 156 ++++++++++-------- 1 file changed, 91 insertions(+), 65 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/onion/PathManager.kt b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt index c0c85cafbf..9d89629f82 100644 --- a/app/src/main/java/org/session/libsession/network/onion/PathManager.kt +++ b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt @@ -11,7 +11,10 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.session.libsession.network.model.Path import org.session.libsession.network.model.PathStatus import org.session.libsession.network.snode.SnodeDirectory @@ -33,6 +36,9 @@ class PathManager( ) val paths: StateFlow> = _paths.asStateFlow() + // Used for synchronization + private val buildMutex = Mutex() + private val _isBuilding = MutableStateFlow(false) @OptIn(FlowPreview::class) @@ -44,12 +50,12 @@ class PathManager( else -> PathStatus.READY } } - .debounce(250) - .stateIn( - scope, - SharingStarted.Eagerly, - if (_paths.value.isEmpty()) PathStatus.ERROR else PathStatus.READY - ) + .debounce(250) + .stateIn( + scope, + SharingStarted.Eagerly, + if (_paths.value.isEmpty()) PathStatus.ERROR else PathStatus.READY + ) init { // persist to DB whenever paths change @@ -67,85 +73,105 @@ class PathManager( return selectPath(current, exclude) } - // Need to (re)build paths + // Wait for rebuild to finish if one is happening, or start one rebuildPaths(reusablePaths = current) + val rebuilt = _paths.value if (rebuilt.isEmpty()) throw IllegalStateException("No paths after rebuild") return selectPath(rebuilt, exclude) } suspend fun rebuildPaths(reusablePaths: List) { - if (_isBuilding.value) return - - _isBuilding.value = true - try { - // Ensure we actually have a usable pool before doing anything - val pool = directory.ensurePoolPopulated() - - val safeReusable = sanitizePaths(reusablePaths) - val reusableGuards = safeReusable.map { it.first() }.toSet() - - val guardSnodes = directory.getGuardSnodes( - existingGuards = reusableGuards, - targetGuardCount = targetPathCount - ) + // This ensures callers wait their turn rather than skipping immediately + buildMutex.withLock { + // Double-check: Did someone populate paths while we were waiting for the lock? + // If yes, we can skip building. + val freshPaths = _paths.value + if (freshPaths.size >= targetPathCount && arePathsDisjoint(freshPaths)) { + return + } - var unused = pool - .minus(guardSnodes) - .minus(safeReusable.flatten().toSet()) - - val newPaths = guardSnodes - .minus(reusableGuards) - .map { guard -> - val rest = (0 until pathSize - 1).map { - val next = unused.secureRandom() - unused = unused - next - next + _isBuilding.value = true + try { + // Ensure we actually have a usable pool before doing anything + val pool = directory.ensurePoolPopulated() + + val safeReusable = sanitizePaths(reusablePaths) + val reusableGuards = safeReusable.map { it.first() }.toSet() + + val guardSnodes = directory.getGuardSnodes( + existingGuards = reusableGuards, + targetGuardCount = targetPathCount + ) + + var unused = pool + .minus(guardSnodes) + .minus(safeReusable.flatten().toSet()) + + val newPaths = guardSnodes + .minus(reusableGuards) + .map { guard -> + val rest = (0 until pathSize - 1).map { + val next = unused.secureRandom() + unused = unused - next + next + } + listOf(guard) + rest } - listOf(guard) + rest - } - - val allPaths = (safeReusable + newPaths).take(targetPathCount) - val sanitized = sanitizePaths(allPaths) - _paths.value = sanitized - } finally { - _isBuilding.value = false + + val allPaths = (safeReusable + newPaths).take(targetPathCount) + val sanitized = sanitizePaths(allPaths) + _paths.value = sanitized + } finally { + _isBuilding.value = false + } } } /** Called when we know a specific snode is bad. */ fun handleBadSnode(snode: Snode) { - val current = _paths.value.toMutableList() - val pathIndex = current.indexOfFirst { it.contains(snode) } - if (pathIndex == -1) return - - val path = current[pathIndex].toMutableList() - path.remove(snode) - - val unused = directory.getSnodePool().minus(current.flatten().toSet()) - if (unused.isEmpty()) { - Log.w("Onion", "No unused snodes to repair path, dropping path entirely") - current.removeAt(pathIndex) - _paths.value = current - return - } + _paths.update { currentList -> + // Locate the bad path in the *current* snapshot + val pathIndex = currentList.indexOfFirst { it.contains(snode) } + + // If the node isn't found (e.g., paths were just rebuilt), do nothing + if (pathIndex == -1) return@update currentList + + // Prepare mutable copies for modification + // We copy the outer list so we don't mutate the 'currentList' which might be needed for a CAS retry + val newPathsList = currentList.toMutableList() + val pathParams = newPathsList[pathIndex].toMutableList() + + // Remove the bad node + pathParams.remove(snode) + + // Find a replacement + val usedSnodes = newPathsList.flatten().toSet() + val pool = directory.getSnodePool() + val unused = pool.minus(usedSnodes) + + if (unused.isEmpty()) { + Log.w("Onion", "No unused snodes to repair path, dropping path entirely") + newPathsList.removeAt(pathIndex) + } else { + val replacement = unused.secureRandom() + pathParams.add(replacement) + newPathsList[pathIndex] = pathParams + } - val replacement = unused.secureRandom() - path.add(replacement) - current[pathIndex] = path - _paths.value = sanitizePaths(current) + // Return the new clean list + sanitizePaths(newPathsList) + } } /** Called when an entire path is considered unreliable. */ fun handleBadPath(path: Path) { - val current = _paths.value.toMutableList() - current.remove(path) - _paths.value = current - // Next call to getPath() will trigger rebuild if needed + _paths.update { currentList -> + // Filter returns a new list, so this is safe and atomic + currentList.filter { it != path } + } } - // --- helpers --- - private fun selectPath(paths: List, exclude: Snode?): Path { val candidates = if (exclude != null) { paths.filter { !it.contains(exclude) } @@ -170,4 +196,4 @@ class PathManager( val all = paths.flatten() return all.size == all.toSet().size } -} +} \ No newline at end of file From a886beb4df5ef0e53ca0b37abd2a4def3f886643 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 17 Dec 2025 16:00:36 +1100 Subject: [PATCH 22/77] Removing access to SnodeAPI part1 - moved getSingleTargetSnode in swarmDirectory. --- .../messaging/MessagingModuleConfiguration.kt | 3 +++ .../messaging/jobs/InviteContactsJob.kt | 9 ++++---- .../messaging/open_groups/OpenGroupApi.kt | 21 ++++++++++++------- .../sending_receiving/MessageReceiver.kt | 9 ++++---- .../sending_receiving/MessageSender.kt | 1 - .../ReceivedMessageHandler.kt | 9 ++++---- .../ReceivedMessageProcessor.kt | 5 +++-- .../sending_receiving/pollers/Poller.kt | 20 ++++++++++-------- .../libsession/network/SessionClient.kt | 18 ++++------------ .../libsession/network/SessionNetwork.kt | 4 ++++ .../libsession/network/snode/SnodeStorage.kt | 2 +- .../network/snode/SwarmDirectory.kt | 20 +++++++++++++++--- .../securesms/MediaPreviewActivity.kt | 9 +++++--- .../securesms/configs/ConfigToDatabaseSync.kt | 7 ++++--- .../securesms/configs/ConfigUploader.kt | 8 ++++--- 15 files changed, 86 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt b/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt index 3938eaa3e8..4abdf4763b 100644 --- a/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt +++ b/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt @@ -9,6 +9,7 @@ import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.messaging.jobs.MessageSendJob import org.session.libsession.messaging.notifications.TokenFetcher +import org.session.libsession.network.SessionNetwork import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.Device @@ -36,6 +37,8 @@ class MessagingModuleConfiguration @Inject constructor( val proStatusManager: ProStatusManager, val messageSendJobFactory: MessageSendJob.Factory, val json: Json, + val snodeClock: SnodeClock, + val sessionNetwork: SessionNetwork ) { companion object { diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt index 2dd53a2864..77b2c031f4 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt @@ -18,13 +18,13 @@ import org.session.libsession.messaging.messages.control.GroupUpdated import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.Data import org.session.libsession.messaging.utilities.MessageAuthentication.buildGroupInviteSignature -import org.session.libsession.snode.SnodeAPI +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.getGroup -import org.session.protos.SessionProtos.GroupUpdateInviteMessage -import org.session.protos.SessionProtos.GroupUpdateMessage import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log +import org.session.protos.SessionProtos.GroupUpdateInviteMessage +import org.session.protos.SessionProtos.GroupUpdateMessage class InviteContactsJob @AssistedInject constructor( @Assisted val groupSessionId: String, @@ -32,6 +32,7 @@ class InviteContactsJob @AssistedInject constructor( @Assisted val isReinvite: Boolean, private val configFactory: ConfigFactoryProtocol, private val messageSender: MessageSender, + private val snodeClock: SnodeClock ) : Job { @@ -69,7 +70,7 @@ class InviteContactsJob @AssistedInject constructor( configs.groupInfo.getName() to configs.groupKeys.makeSubAccount(memberSessionId) } - val timestamp = SnodeAPI.nowWithOffset + val timestamp = snodeClock.currentTimeMills() val signature = ED25519.sign( ed25519PrivateKey = adminKey.data, message = buildGroupInviteSignature(memberId, timestamp), diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt index 3d650db223..3525f2d85c 100644 --- a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt @@ -18,10 +18,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.snode.OnionRequestAPI -import org.session.libsession.snode.OnionResponse -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.utilities.await +import org.session.libsession.network.model.OnionResponse import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64.encodeBytes import org.session.libsignal.utilities.ByteArraySlice @@ -339,7 +336,7 @@ object OpenGroupApi { val headers = request.headers.toMutableMap() val nonce = ByteArray(16).also { SecureRandom().nextBytes(it) } - val timestamp = TimeUnit.MILLISECONDS.toSeconds(SnodeAPI.nowWithOffset) + val timestamp = TimeUnit.MILLISECONDS.toSeconds(MessagingModuleConfiguration.shared.snodeClock.currentTimeMills()) val bodyHash = if (request.parameters != null) { val parameterBytes = JsonUtil.toJson(request.parameters).toByteArray() Hash.hash64(parameterBytes) @@ -409,14 +406,22 @@ object OpenGroupApi { if (!request.room.isNullOrEmpty()) { requestBuilder.header("Room", request.room) } - return if (request.useOnionRouting) { - OnionRequestAPI.sendOnionRequest(requestBuilder.build(), request.server, serverPublicKey).fail { e -> + if (request.useOnionRouting) { + val result = MessagingModuleConfiguration.shared.sessionNetwork.sendToServer( + requestBuilder.build(), + request.server, + serverPublicKey + ) + + result.onFailure { e -> when (e) { // No need for the stack trace for HTTP errors is HTTP.HTTPRequestFailedException -> Log.e("SOGS", "Failed onion request: ${e.message}") else -> Log.e("SOGS", "Failed onion request", e) } - }.await() + } + + return result.getOrThrow() } else { throw IllegalStateException("It's currently not allowed to send non onion routed requests.") } diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt index a8387dc055..8954728841 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt @@ -13,14 +13,14 @@ import org.session.libsession.messaging.messages.control.ReadReceipt import org.session.libsession.messaging.messages.control.TypingIndicator import org.session.libsession.messaging.messages.control.UnsendRequest import org.session.libsession.messaging.messages.visible.VisibleMessage -import org.session.libsession.snode.SnodeAPI +import org.session.libsession.network.SnodeClock import org.session.libsignal.crypto.PushTransportDetails -import org.session.protos.SessionProtos -import org.session.protos.SessionProtos.Envelope import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log +import org.session.protos.SessionProtos +import org.session.protos.SessionProtos.Envelope import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton @@ -30,6 +30,7 @@ import kotlin.math.abs @Singleton class MessageReceiver @Inject constructor( private val storage: StorageProtocol, + private val snodeClock: SnodeClock ) { internal sealed class Error(message: String) : Exception(message) { @@ -201,7 +202,7 @@ class MessageReceiver @Inject constructor( message.sender = sender message.recipient = userPublicKey message.sentTimestamp = envelope.timestamp - message.receivedTimestamp = if (envelope.hasServerTimestamp()) envelope.serverTimestamp else SnodeAPI.nowWithOffset + message.receivedTimestamp = if (envelope.hasServerTimestamp()) envelope.serverTimestamp else snodeClock.currentTimeMills() message.groupPublicKey = groupPublicKey message.openGroupServerMessageID = openGroupServerID // Validate diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt index fee1d58ee3..6369aaab29 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt @@ -29,7 +29,6 @@ import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupApi.Capability import org.session.libsession.messaging.open_groups.OpenGroupMessage import org.session.libsession.network.SessionClient -import org.session.libsession.snode.SnodeAPI import org.session.libsession.network.SnodeClock import org.session.libsession.snode.SnodeMessage import org.session.libsession.utilities.Address diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt index c2b1b39dbf..5b79acb086 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt @@ -10,9 +10,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import network.loki.messenger.R +import network.loki.messenger.libsession_util.ED25519 import network.loki.messenger.libsession_util.PRIORITY_HIDDEN import network.loki.messenger.libsession_util.PRIORITY_VISIBLE -import network.loki.messenger.libsession_util.ED25519 import network.loki.messenger.libsession_util.util.BaseCommunityInfo import network.loki.messenger.libsession_util.util.BlindKeyAPI import network.loki.messenger.libsession_util.util.ExpiryMode @@ -47,7 +47,7 @@ import org.session.libsession.messaging.utilities.MessageAuthentication.buildGro import org.session.libsession.messaging.utilities.MessageAuthentication.buildInfoChangeSignature import org.session.libsession.messaging.utilities.MessageAuthentication.buildMemberChangeSignature import org.session.libsession.messaging.utilities.WebRtcUtils -import org.session.libsession.snode.SnodeAPI +import org.session.libsession.network.SessionClient import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress @@ -62,12 +62,12 @@ import org.session.libsession.utilities.recipients.RecipientData import org.session.libsession.utilities.recipients.getType import org.session.libsession.utilities.updateContact import org.session.libsession.utilities.upsertContact -import org.session.protos.SessionProtos import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.guava.Optional +import org.session.protos.SessionProtos import org.thoughtcrime.securesms.database.ConfigDatabase import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.database.model.MessageId @@ -106,6 +106,7 @@ class ReceivedMessageHandler @Inject constructor( private val messageRequestResponseHandler: Provider, private val prefs: TextSecurePreferences, private val clock: SnodeClock, + private val sessionClient: SessionClient ) { suspend fun handle( @@ -265,7 +266,7 @@ class ReceivedMessageHandler @Inject constructor( messageDataProvider.getServerHashForMessage(messageIdToDelete)?.let { serverHash -> scope.launch(Dispatchers.IO) { // using scope as we are slowly migrating to coroutines but we can't migrate everything at once try { - SnodeAPI.deleteMessage(author, userAuth, listOf(serverHash)) + sessionClient.deleteMessage(author, userAuth, listOf(serverHash)) } catch (e: Exception) { Log.e("Loki", "Failed to delete message", e) } diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt index bae87cf23b..ea802eb21f 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt @@ -29,7 +29,7 @@ import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier import org.session.libsession.messaging.utilities.WebRtcUtils -import org.session.libsession.snode.SnodeAPI +import org.session.libsession.network.SessionClient import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol @@ -76,6 +76,7 @@ class ReceivedMessageProcessor @Inject constructor( private val visibleMessageHandler: Provider, private val blindMappingRepository: BlindMappingRepository, private val messageParser: MessageParser, + private val sessionClient: SessionClient ) { private val threadMutexes = ConcurrentHashMap() @@ -452,7 +453,7 @@ class ReceivedMessageProcessor @Inject constructor( messageDataProvider.getServerHashForMessage(messageIdToDelete)?.let { serverHash -> scope.launch(Dispatchers.IO) { // using scope as we are slowly migrating to coroutines but we can't migrate everything at once try { - SnodeAPI.deleteMessage(author, userAuth, listOf(serverHash)) + sessionClient.deleteMessage(author, userAuth, listOf(serverHash)) } catch (e: Exception) { Log.e("Loki", "Failed to delete message", e) } diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt index 2765da49d2..4e50781678 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt @@ -30,10 +30,10 @@ import org.session.libsession.database.userAuth import org.session.libsession.messaging.messages.Message.Companion.senderOrSync import org.session.libsession.messaging.sending_receiving.MessageParser import org.session.libsession.messaging.sending_receiving.ReceivedMessageProcessor -import org.session.libsession.snode.SnodeAPI +import org.session.libsession.network.SessionClient import org.session.libsession.network.SnodeClock +import org.session.libsession.network.snode.SwarmStorage import org.session.libsession.snode.model.RetrieveMessageResponse -import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol @@ -63,6 +63,8 @@ class Poller @AssistedInject constructor( private val receivedMessageHashDatabase: ReceivedMessageHashDatabase, private val processor: ReceivedMessageProcessor, private val messageParser: MessageParser, + private val sessionClient: SessionClient, + private val swarmStorage: SwarmStorage, @Assisted scope: CoroutineScope ) { private val userPublicKey: String @@ -174,7 +176,7 @@ class Poller @AssistedInject constructor( // check if the polling pool is empty if (pollPool.isEmpty()) { // if it is empty, fill it with the snodes from our swarm - pollPool.addAll(SnodeAPI.getSwarm(userPublicKey).await()) + pollPool.addAll(swarmStorage.getSwarm(userPublicKey)) } // randomly get a snode from the pool @@ -302,7 +304,7 @@ class Poller @AssistedInject constructor( // Get messages call wrapped in an async val fetchMessageTask = if (!pollOnlyUserProfileConfig) { - val request = SnodeAPI.buildAuthenticatedRetrieveBatchRequest( + val request = sessionClient.buildAuthenticatedRetrieveBatchRequest( lastHash = lokiApiDatabase.getLastMessageHashValue( snode = snode, publicKey = userAuth.accountId.hexString, @@ -313,7 +315,7 @@ class Poller @AssistedInject constructor( this.async { runCatching { - SnodeAPI.sendBatchRequest( + sessionClient.sendBatchRequest( snode = snode, publicKey = userPublicKey, request = request, @@ -338,7 +340,7 @@ class Poller @AssistedInject constructor( .map { type -> val config = configs.getConfig(type) hashesToExtend += config.activeHashes() - val request = SnodeAPI.buildAuthenticatedRetrieveBatchRequest( + val request = sessionClient.buildAuthenticatedRetrieveBatchRequest( lastHash = lokiApiDatabase.getLastMessageHashValue( snode = snode, publicKey = userAuth.accountId.hexString, @@ -351,7 +353,7 @@ class Poller @AssistedInject constructor( this.async { type to runCatching { - SnodeAPI.sendBatchRequest( + sessionClient.sendBatchRequest( snode = snode, publicKey = userPublicKey, request = request, @@ -365,10 +367,10 @@ class Poller @AssistedInject constructor( if (hashesToExtend.isNotEmpty()) { launch { try { - SnodeAPI.sendBatchRequest( + sessionClient.sendBatchRequest( snode, userPublicKey, - SnodeAPI.buildAuthenticatedAlterTtlBatchRequest( + sessionClient.buildAuthenticatedAlterTtlBatchRequest( messageHashes = hashesToExtend.toList(), auth = userAuth, newExpiry = snodeClock.currentTimeMills() + 14.days.inWholeMilliseconds, diff --git a/app/src/main/java/org/session/libsession/network/SessionClient.kt b/app/src/main/java/org/session/libsession/network/SessionClient.kt index 193555f479..d09ffbbc01 100644 --- a/app/src/main/java/org/session/libsession/network/SessionClient.kt +++ b/app/src/main/java/org/session/libsession/network/SessionClient.kt @@ -262,7 +262,7 @@ class SessionClient @Inject constructor( } } - val target = getSingleTargetSnode(message.recipient) + val target = swarmDirectory.getSingleTargetSnode(message.recipient) return sendBatchRequest( snode = target, @@ -285,7 +285,7 @@ class SessionClient @Inject constructor( serverHashes: List, version: Version = Version.V4 ): Map<*, *> { - val snode = getSingleTargetSnode(publicKey) + val snode = swarmDirectory.getSingleTargetSnode(publicKey) val params = buildAuthenticatedParameters( auth = auth, @@ -354,7 +354,7 @@ class SessionClient @Inject constructor( version: Version = Version.V4 ): Map { val publicKey = auth.accountId.hexString - val snode = getSingleTargetSnode(publicKey) + val snode = swarmDirectory.getSingleTargetSnode(publicKey) // Prefer network-adjusted time for signature compatibility val timestamp = snodeClock.waitForNetworkAdjustedTime() @@ -486,7 +486,7 @@ class SessionClient @Inject constructor( extend: Boolean = false, version: Version = Version.V4 ): Map<*, *> { - val snode = getSingleTargetSnode(auth.accountId.hexString) + val snode = swarmDirectory.getSingleTargetSnode(auth.accountId.hexString) val params = buildAlterTtlParams(auth, messageHashes, newExpiry, shorten, extend) return invoke( @@ -501,16 +501,6 @@ class SessionClient @Inject constructor( // Batch logic - /** - * Picks one snode from the user's swarm for a given account. - * We deliberately randomise to avoid hammering a single node. - */ - private suspend fun getSingleTargetSnode(publicKey: String): Snode { - val swarm = swarmDirectory.getSwarm(publicKey) - require(swarm.isNotEmpty()) { "Swarm is empty for pubkey=$publicKey" } - return swarm.shuffledRandom().random() - } - private fun buildAuthenticatedParameters( auth: SwarmAuth, namespace: Int?, diff --git a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt index 09045efb4d..0040ca91ac 100644 --- a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt +++ b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt @@ -41,6 +41,10 @@ class SessionNetwork( private val maxRetryDelayMs: Long = 2_000L ) { + //todo ONION we now have a few places in the app calling SesisonNetwork directly to use + // sendToSnode or sendToServer. Should this be abstracted away in sessionClient instead? + // Is there a better way to discern the two? + /** * Send an onion request to a *service node* (RPC). * diff --git a/app/src/main/java/org/session/libsession/network/snode/SnodeStorage.kt b/app/src/main/java/org/session/libsession/network/snode/SnodeStorage.kt index 566b3a2c25..e0c58c8b15 100644 --- a/app/src/main/java/org/session/libsession/network/snode/SnodeStorage.kt +++ b/app/src/main/java/org/session/libsession/network/snode/SnodeStorage.kt @@ -11,7 +11,7 @@ interface SnodePathStorage { } interface SwarmStorage { - fun getSwarm(publicKey: String): Set? + fun getSwarm(publicKey: String): Set fun setSwarm(publicKey: String, swarm: Set) } diff --git a/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt b/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt index 4cec139f65..d37e94ddc2 100644 --- a/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt +++ b/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt @@ -2,11 +2,15 @@ package org.session.libsession.network.snode import org.session.libsession.network.SessionNetwork import org.session.libsession.network.onion.Version +import org.session.libsignal.crypto.shuffledRandom import org.session.libsignal.utilities.ByteArraySlice import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Snode +import javax.inject.Inject +import javax.inject.Singleton -class SwarmDirectory( +@Singleton +class SwarmDirectory @Inject constructor( private val storage: SwarmStorage, private val snodeDirectory: SnodeDirectory, private val sessionNetwork: SessionNetwork, @@ -15,7 +19,7 @@ class SwarmDirectory( suspend fun getSwarm(publicKey: String): Set { val cached = storage.getSwarm(publicKey) - if (cached != null && cached.size >= minimumSwarmSize) { + if (cached.size >= minimumSwarmSize) { return cached } @@ -52,8 +56,18 @@ class SwarmDirectory( return parseSnodes(json).toSet() } + /** + * Picks one snode from the user's swarm for a given account. + * We deliberately randomise to avoid hammering a single node. + */ + suspend fun getSingleTargetSnode(publicKey: String): Snode { + val swarm = getSwarm(publicKey) + require(swarm.isNotEmpty()) { "Swarm is empty for pubkey=$publicKey" } + return swarm.shuffledRandom().random() + } + fun dropSnodeFromSwarmIfNeeded(snode: Snode, publicKey: String) { - val current = storage.getSwarm(publicKey) ?: return + val current = storage.getSwarm(publicKey) if (snode !in current) return val updated = current - snode diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt index a3444934fc..4e55f94bd7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt @@ -73,7 +73,7 @@ import org.session.libsession.messaging.messages.control.DataExtractionNotificat import org.session.libsession.messaging.messages.control.DataExtractionNotification.Kind.MediaSaved import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment -import org.session.libsession.snode.SnodeAPI.nowWithOffset +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.getColorFromAttr @@ -139,6 +139,9 @@ class MediaPreviewActivity : ScreenLockActionBarActivity(), @Inject lateinit var messageSender: MessageSender + @Inject + lateinit var snodeClock: SnodeClock + override val applyDefaultWindowInsets: Boolean get() = false @@ -521,7 +524,7 @@ class MediaPreviewActivity : ScreenLockActionBarActivity(), } .onAllGranted { val saveTask = SaveAttachmentTask(this@MediaPreviewActivity) - val saveDate = if (mediaItem.date > 0) mediaItem.date else nowWithOffset + val saveDate = if (mediaItem.date > 0) mediaItem.date else snodeClock.currentTimeMills() saveTask.executeOnExecutor( AsyncTask.THREAD_POOL_EXECUTOR, SaveAttachmentTask.Attachment( @@ -552,7 +555,7 @@ class MediaPreviewActivity : ScreenLockActionBarActivity(), if (conversationAddress == null || conversationAddress?.isGroupOrCommunity == true) return val message = DataExtractionNotification( MediaSaved( - nowWithOffset + snodeClock.currentTimeMills() ) ) messageSender.send(message, conversationAddress!!) diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt index 30908865db..98431b0d1b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt @@ -19,9 +19,9 @@ import org.session.libsession.avatars.AvatarCacheCleaner import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1 -import org.session.libsession.snode.OwnedSwarmAuth -import org.session.libsession.snode.SnodeAPI +import org.session.libsession.network.SessionClient import org.session.libsession.network.SnodeClock +import org.session.libsession.snode.OwnedSwarmAuth import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.Address.Companion.toAddress @@ -86,6 +86,7 @@ class ConfigToDatabaseSync @Inject constructor( private val messageNotifier: MessageNotifier, private val recipientSettingsDatabase: RecipientSettingsDatabase, private val avatarCacheCleaner: AvatarCacheCleaner, + private val sessionClient: SessionClient, @param:ManagerScope private val scope: CoroutineScope, ) : AuthAwareComponent { override suspend fun doWhileLoggedIn(loggedInState: LoggedInState) { @@ -315,7 +316,7 @@ class ConfigToDatabaseSync @Inject constructor( scope.launch(Dispatchers.Default) { val cleanedHashes: List = messages.asSequence().map { it.second }.filter { !it.isNullOrEmpty() }.filterNotNull().toList() - if (cleanedHashes.isNotEmpty()) SnodeAPI.deleteMessage( + if (cleanedHashes.isNotEmpty()) sessionClient.deleteMessage( groupInfoConfig.id.hexString, groupAdminAuth, cleanedHashes diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt index 720189d74f..ffe701cf23 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt @@ -20,6 +20,7 @@ import network.loki.messenger.libsession_util.Namespace import network.loki.messenger.libsession_util.util.ConfigPush import org.session.libsession.database.StorageProtocol import org.session.libsession.database.userAuth +import org.session.libsession.network.SessionClient import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.OwnedSwarmAuth import org.session.libsession.snode.SnodeAPI @@ -63,6 +64,7 @@ class ConfigUploader @Inject constructor( private val storageProtocol: StorageProtocol, private val clock: SnodeClock, private val networkConnectivity: NetworkConnectivity, + private val sessionClient: SessionClient ) : AuthAwareComponent { /** * A flow that only emits when @@ -196,16 +198,16 @@ class ConfigUploader @Inject constructor( Log.d(TAG, "Pushing group configs") - val snode = SnodeAPI.getSingleTargetSnode(groupId.hexString).await() + val snode = sessionClient.getSingleTargetSnode(groupId.hexString) val auth = OwnedSwarmAuth.ofClosedGroup(groupId, adminKey) // Keys push is different: it doesn't have the delete call so we don't call pushConfig. // Keys must be pushed first because the other configs depend on it. val keysPushResult = keysPush?.let { push -> - SnodeAPI.sendBatchRequest( + sessionClient.sendBatchRequest( snode = snode, publicKey = auth.accountId.hexString, - request = SnodeAPI.buildAuthenticatedStoreBatchInfo( + request = sessionClient.buildAuthenticatedStoreBatchInfo( Namespace.GROUP_KEYS(), SnodeMessage( auth.accountId.hexString, From 1ffd8504ae94b7220cb45cfc1be85e735659f767 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 17 Dec 2025 16:05:15 +1100 Subject: [PATCH 23/77] SnodeAPI pt2 --- .../libsession/network/onion/PathManager.kt | 5 +++- .../securesms/configs/ConfigUploader.kt | 26 ++++++++++--------- .../conversation/v2/ConversationActivityV2.kt | 12 ++++----- 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/onion/PathManager.kt b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt index 9d89629f82..7290314265 100644 --- a/app/src/main/java/org/session/libsession/network/onion/PathManager.kt +++ b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt @@ -22,8 +22,11 @@ import org.session.libsession.network.snode.SnodePathStorage import org.session.libsignal.crypto.secureRandom import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode +import javax.inject.Inject +import javax.inject.Singleton -class PathManager( +@Singleton +class PathManager @Inject constructor( private val scope: CoroutineScope, private val directory: SnodeDirectory, private val storage: SnodePathStorage, diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt index ffe701cf23..6bbaaec189 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt @@ -21,14 +21,14 @@ import network.loki.messenger.libsession_util.util.ConfigPush import org.session.libsession.database.StorageProtocol import org.session.libsession.database.userAuth import org.session.libsession.network.SessionClient -import org.session.libsession.snode.OnionRequestAPI -import org.session.libsession.snode.OwnedSwarmAuth -import org.session.libsession.snode.SnodeAPI import org.session.libsession.network.SnodeClock +import org.session.libsession.network.model.PathStatus +import org.session.libsession.network.onion.PathManager +import org.session.libsession.network.snode.SwarmDirectory +import org.session.libsession.snode.OwnedSwarmAuth import org.session.libsession.snode.SnodeMessage import org.session.libsession.snode.SwarmAuth import org.session.libsession.snode.model.StoreMessageResponse -import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigPushResult import org.session.libsession.utilities.ConfigUpdateNotification @@ -64,7 +64,9 @@ class ConfigUploader @Inject constructor( private val storageProtocol: StorageProtocol, private val clock: SnodeClock, private val networkConnectivity: NetworkConnectivity, - private val sessionClient: SessionClient + private val swarmDirectory: SwarmDirectory, + private val sessionClient: SessionClient, + private val pathManager: PathManager ) : AuthAwareComponent { /** * A flow that only emits when @@ -77,7 +79,7 @@ class ConfigUploader @Inject constructor( private fun pathBecomesAvailable(): Flow<*> = networkConnectivity.networkAvailable .flatMapLatest { hasNetwork -> if (hasNetwork) { - OnionRequestAPI.pathStatus.filter { it == OnionRequestAPI.PathStatus.READY } + pathManager.status.filter { it == PathStatus.READY } } else { emptyFlow() } @@ -198,7 +200,7 @@ class ConfigUploader @Inject constructor( Log.d(TAG, "Pushing group configs") - val snode = sessionClient.getSingleTargetSnode(groupId.hexString) + val snode = swarmDirectory.getSingleTargetSnode(groupId.hexString) val auth = OwnedSwarmAuth.ofClosedGroup(groupId, adminKey) // Keys push is different: it doesn't have the delete call so we don't call pushConfig. @@ -283,10 +285,10 @@ class ConfigUploader @Inject constructor( push.messages .map { message -> async { - SnodeAPI.sendBatchRequest( + sessionClient.sendBatchRequest( snode = snode, publicKey = auth.accountId.hexString, - request = SnodeAPI.buildAuthenticatedStoreBatchInfo( + request = sessionClient.buildAuthenticatedStoreBatchInfo( namespace, SnodeMessage( auth.accountId.hexString, @@ -304,10 +306,10 @@ class ConfigUploader @Inject constructor( } if (push.obsoleteHashes.isNotEmpty()) { - SnodeAPI.sendBatchRequest( + sessionClient.sendBatchRequest( snode = snode, publicKey = auth.accountId.hexString, - request = SnodeAPI.buildAuthenticatedDeleteBatchInfo(auth, push.obsoleteHashes) + request = sessionClient.buildAuthenticatedDeleteBatchInfo(auth, push.obsoleteHashes) ) } @@ -343,7 +345,7 @@ class ConfigUploader @Inject constructor( Log.d(TAG, "Pushing ${pushes.size} user configs") - val snode = SnodeAPI.getSingleTargetSnode(userAuth.accountId.hexString).await() + val snode = swarmDirectory.getSingleTargetSnode(userAuth.accountId.hexString) val pushTasks = pushes.map { (configType, configPush) -> async { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 4020b60a3f..5961dd2c1b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -97,7 +97,6 @@ import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel -import org.session.libsession.snode.SnodeAPI import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized @@ -265,6 +264,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, @Inject lateinit var resendMessageUtilities: ResendMessageUtilities @Inject lateinit var messageNotifier: MessageNotifier @Inject lateinit var proStatusManager: ProStatusManager + @Inject lateinit var snodeClock: SnodeClock @Inject @ManagerScope lateinit var scope: CoroutineScope @@ -1786,7 +1786,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, // Create the message val recipient = viewModel.recipient val reactionMessage = VisibleMessage() - val emojiTimestamp = SnodeAPI.nowWithOffset + val emojiTimestamp = snodeClock.currentTimeMills() reactionMessage.sentTimestamp = emojiTimestamp val author = loginStateRepository.getLocalNumber() @@ -1854,7 +1854,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, private fun sendEmojiRemoval(emoji: String, originalMessage: MessageRecord) { val recipient = viewModel.recipient val message = VisibleMessage() - val emojiTimestamp = SnodeAPI.nowWithOffset + val emojiTimestamp = snodeClock.currentTimeMills() message.sentTimestamp = emojiTimestamp val author = loginStateRepository.getLocalNumber() @@ -2149,7 +2149,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, private fun sendTextOnlyMessage(hasPermissionToSendSeed: Boolean = false): Pair? { val recipient = viewModel.recipient - val sentTimestamp = SnodeAPI.nowWithOffset + val sentTimestamp = snodeClock.currentTimeMills() viewModel.implicitlyApproveRecipient()?.let { conversationApprovalJob = it } val text = getMessageBody() val isNoteToSelf = recipient.isLocalNumber @@ -2208,7 +2208,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, deleteAttachmentFilesAfterSave: Boolean = false, ): Pair? { val recipient = viewModel.recipient - val sentTimestamp = SnodeAPI.nowWithOffset + val sentTimestamp = snodeClock.currentTimeMills() viewModel.implicitlyApproveRecipient()?.let { conversationApprovalJob = it } // Create the message @@ -2794,7 +2794,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, private fun sendMediaSavedNotification() { val recipient = viewModel.recipient if (recipient.isGroupOrCommunityRecipient) { return } - val timestamp = SnodeAPI.nowWithOffset + val timestamp = snodeClock.currentTimeMills() val kind = DataExtractionNotification.Kind.MediaSaved(timestamp) val message = DataExtractionNotification(kind) messageSender.send(message, recipient.address) From 01881f0f246a18a27804a264e67855b48d1bc09c Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 17 Dec 2025 16:33:33 +1100 Subject: [PATCH 24/77] Cleanup of SnodeAPI pt3 --- .../messaging/MessagingModuleConfiguration.kt | 1 - .../session/libsession/network/SnodeClock.kt | 3 ++ .../securesms/configs/ConfigToDatabaseSync.kt | 2 +- .../v2/ConversationReactionOverlay.kt | 6 ++-- .../v2/components/ExpirationTimerView.kt | 4 +-- .../securesms/database/MmsDatabase.kt | 5 +-- .../securesms/database/SmsDatabase.java | 11 +++--- .../securesms/database/ThreadDatabase.java | 9 +++-- .../securesms/groups/GroupManagerV2Impl.kt | 36 ++++++++++--------- .../securesms/groups/GroupPoller.kt | 23 ++++++------ .../handler/RemoveGroupMemberHandler.kt | 22 ++++++------ .../DefaultConversationRepository.kt | 9 ++--- .../securesms/webrtc/CallManager.kt | 9 +++-- 13 files changed, 76 insertions(+), 64 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt b/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt index 4abdf4763b..bd827eeb2e 100644 --- a/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt +++ b/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt @@ -29,7 +29,6 @@ class MessagingModuleConfiguration @Inject constructor( val configFactory: ConfigFactoryProtocol, val tokenFetcher: TokenFetcher, val groupManagerV2: GroupManagerV2, - val clock: SnodeClock, val preferences: TextSecurePreferences, val deprecationManager: LegacyGroupDeprecationManager, val recipientRepository: RecipientRepository, diff --git a/app/src/main/java/org/session/libsession/network/SnodeClock.kt b/app/src/main/java/org/session/libsession/network/SnodeClock.kt index 24c2e4db00..ed0a9e6d73 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClock.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClock.kt @@ -31,6 +31,9 @@ class SnodeClock @Inject constructor( private val sessionClient: SessionClient ) : OnAppStartupComponent { + //todo ONION we have a lot of calls to MessagingModuleConfiguration.shared.snodeClock.currentTimeMills() + // can this be improved? + private val instantState = MutableStateFlow(null) private var job: Job? = null diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt index 107bc7d6e8..2ebe1adc13 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt @@ -315,7 +315,7 @@ class ConfigToDatabaseSync @Inject constructor( OwnedSwarmAuth.ofClosedGroup(groupInfoConfig.id, it) } ?: return - // remove messages from swarm SnodeAPI.deleteMessage + // remove messages from swarm deleteMessage scope.launch(Dispatchers.Default) { val cleanedHashes: List = messages.asSequence().map { it.second }.filter { !it.isNullOrEmpty() }.filterNotNull().toList() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt index 5b483b41a2..03e94b8b13 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt @@ -36,8 +36,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.LocalisedTimeUtil.toShortTwoPartString +import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager -import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address import org.session.libsession.utilities.StringSubstitutionConstants.TIME_LARGE_KEY import org.session.libsession.utilities.ThemeUtil @@ -53,7 +53,6 @@ import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord -import org.thoughtcrime.securesms.database.model.ReactionRecord import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.util.AnimationCompleteListener @@ -805,7 +804,8 @@ private val MessageRecord.subtitle: ((Context) -> CharSequence?)? get() = if (expiresIn <= 0 || expireStarted <= 0) { null } else { context -> - (expiresIn - (SnodeAPI.nowWithOffset - expireStarted)) + //todo ONION is there a better way? + (expiresIn - (MessagingModuleConfiguration.shared.snodeClock.currentTimeMills() - expireStarted)) .coerceAtLeast(0L) .milliseconds .toShortTwoPartString() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.kt index c17a4bc114..74843c8333 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.kt @@ -6,7 +6,7 @@ import android.util.AttributeSet import androidx.appcompat.widget.AppCompatImageView import androidx.core.content.ContextCompat import network.loki.messenger.R -import org.session.libsession.snode.SnodeAPI.nowWithOffset +import org.session.libsession.messaging.MessagingModuleConfiguration import kotlin.math.round class ExpirationTimerView @JvmOverloads constructor( @@ -46,7 +46,7 @@ class ExpirationTimerView @JvmOverloads constructor( return } - val elapsedTime = nowWithOffset - startedAt + val elapsedTime = MessagingModuleConfiguration.shared.snodeClock.currentTimeMills() - startedAt val remainingTime = expiresIn - elapsedTime val remainingPercent = (remainingTime / expiresIn.toFloat()).coerceIn(0f, 1f) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt index 70a4e50947..82e0dc5e5d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -34,7 +34,7 @@ import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview -import org.session.libsession.snode.SnodeAPI +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled @@ -79,6 +79,7 @@ class MmsDatabase @Inject constructor( private val reactionDatabase: ReactionDatabase, private val mmsSmsDatabase: Lazy, private val groupDatabase: GroupDatabase, + private val snodeClock: SnodeClock ) : MessagingDatabase(context, databaseHelper) { private val earlyDeliveryReceiptCache = EarlyReceiptCache() private val earlyReadReceiptCache = EarlyReceiptCache() @@ -585,7 +586,7 @@ class MmsDatabase @Inject constructor( // In open groups messages should be sorted by their server timestamp var receivedTimestamp = serverTimestamp if (serverTimestamp == 0L) { - receivedTimestamp = SnodeAPI.nowWithOffset + receivedTimestamp = snodeClock.currentTimeMills() } contentValues.put(DATE_RECEIVED, receivedTimestamp) contentValues.put(EXPIRES_IN, message.expiresInMillis) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index 7a6204dfe6..c4b2a97a13 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -36,7 +36,7 @@ import org.session.libsession.messaging.calls.CallMessageType; import org.session.libsession.messaging.messages.signal.IncomingTextMessage; import org.session.libsession.messaging.messages.signal.OutgoingTextMessage; -import org.session.libsession.snode.SnodeAPI; +import org.session.libsession.network.SnodeClock; import org.session.libsession.utilities.Address; import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.recipients.Recipient; @@ -55,7 +55,6 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; -import java.util.Set; import javax.inject.Inject; import javax.inject.Provider; @@ -64,9 +63,6 @@ import dagger.Lazy; import dagger.hilt.android.qualifiers.ApplicationContext; import network.loki.messenger.libsession_util.protocol.ProFeature; -import network.loki.messenger.libsession_util.protocol.ProMessageFeature; -import network.loki.messenger.libsession_util.protocol.ProProfileFeature; -import network.loki.messenger.libsession_util.util.BitSet; /** * Database for storage of SMS messages. @@ -146,6 +142,7 @@ public static void addProFeatureColumns(SupportSQLiteDatabase db) { private static final EarlyReceiptCache earlyReadReceiptCache = new EarlyReceiptCache(); private final RecipientRepository recipientRepository; + private final SnodeClock snodeClock; private final Lazy<@NonNull ThreadDatabase> threadDatabase; private final Lazy<@NonNull ReactionDatabase> reactionDatabase; @@ -153,10 +150,12 @@ public static void addProFeatureColumns(SupportSQLiteDatabase db) { public SmsDatabase(@ApplicationContext Context context, Provider databaseHelper, RecipientRepository recipientRepository, + SnodeClock snodeClock, Lazy<@NonNull ThreadDatabase> threadDatabase, Lazy<@NonNull ReactionDatabase> reactionDatabase) { super(context, databaseHelper); this.recipientRepository = recipientRepository; + this.snodeClock = snodeClock; this.threadDatabase = threadDatabase; this.reactionDatabase = reactionDatabase; } @@ -549,7 +548,7 @@ public long insertMessageOutbox(long threadId, OutgoingTextMessage message, contentValues.put(ADDRESS, address.toString()); contentValues.put(THREAD_ID, threadId); contentValues.put(BODY, message.getMessage()); - contentValues.put(DATE_RECEIVED, SnodeAPI.getNowWithOffset()); + contentValues.put(DATE_RECEIVED, snodeClock.currentTimeMills()); contentValues.put(DATE_SENT, message.getSentTimestampMillis()); contentValues.put(READ, 1); contentValues.put(TYPE, type); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index 5d8bd51a14..5d06140349 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -33,7 +33,7 @@ import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier; -import org.session.libsession.snode.SnodeAPI; +import org.session.libsession.network.SnodeClock; import org.session.libsession.utilities.Address; import org.session.libsession.utilities.AddressKt; import org.session.libsession.utilities.ConfigFactoryProtocol; @@ -231,6 +231,7 @@ public static void migrateLegacyCommunityAddresses(final SQLiteDatabase db) { private final MutableSharedFlow updateNotifications = SharedFlowKt.MutableSharedFlow(0, 256, BufferOverflow.DROP_OLDEST); private final Json json; private final TextSecurePreferences prefs; + private final SnodeClock snodeClock; private final Lazy<@NonNull RecipientRepository> recipientRepository; private final Lazy<@NonNull MmsSmsDatabase> mmsSmsDatabase; @@ -251,6 +252,7 @@ public ThreadDatabase(@dagger.hilt.android.qualifiers.ApplicationContext Context Lazy<@NonNull SmsDatabase> smsDatabase, Lazy<@NonNull MarkReadProcessor> markReadProcessor, TextSecurePreferences prefs, + SnodeClock snodeClock, Json json) { super(context, databaseHelper); this.recipientRepository = recipientRepository; @@ -260,6 +262,7 @@ public ThreadDatabase(@dagger.hilt.android.qualifiers.ApplicationContext Context this.mmsDatabase = mmsDatabase; this.smsDatabase = smsDatabase; this.markReadProcessor = markReadProcessor; + this.snodeClock = snodeClock; this.json = json; this.prefs = prefs; @@ -443,7 +446,7 @@ public List setRead(long threadId, boolean lastSeen) { contentValues.put(UNREAD_MENTION_COUNT, 0); if (lastSeen) { - contentValues.put(LAST_SEEN, SnodeAPI.getNowWithOffset()); + contentValues.put(LAST_SEEN, snodeClock.currentTimeMills()); } SQLiteDatabase db = getWritableDatabase(); @@ -552,7 +555,7 @@ public boolean setLastSeen(long threadId, long timestamp) { SQLiteDatabase db = getWritableDatabase(); ContentValues contentValues = new ContentValues(1); - long lastSeenTime = timestamp == -1 ? SnodeAPI.getNowWithOffset() : timestamp; + long lastSeenTime = timestamp == -1 ? snodeClock.currentTimeMills() : timestamp; contentValues.put(LAST_SEEN, lastSeenTime); db.beginTransaction(); db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(threadId)}); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt index 0d7c7f03da..6f9af255a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -37,12 +37,12 @@ import org.session.libsession.messaging.utilities.MessageAuthentication.buildDel import org.session.libsession.messaging.utilities.MessageAuthentication.buildInfoChangeSignature import org.session.libsession.messaging.utilities.MessageAuthentication.buildMemberChangeSignature import org.session.libsession.messaging.utilities.UpdateMessageData -import org.session.libsession.snode.OwnedSwarmAuth -import org.session.libsession.snode.SnodeAPI +import org.session.libsession.network.SessionClient import org.session.libsession.network.SnodeClock +import org.session.libsession.network.snode.SwarmDirectory +import org.session.libsession.snode.OwnedSwarmAuth import org.session.libsession.snode.SnodeMessage import org.session.libsession.snode.model.BatchResponse -import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY import org.session.libsession.utilities.getGroup @@ -98,6 +98,8 @@ class GroupManagerV2Impl @Inject constructor( private val recipientRepository: RecipientRepository, private val messageSender: MessageSender, private val inviteContactJobFactory: InviteContactsJob.Factory, + private val sessionClient: SessionClient, + private val swarmDirectory: SwarmDirectory ) : GroupManagerV2 { private val dispatcher = Dispatchers.Default @@ -260,7 +262,7 @@ class GroupManagerV2Impl @Inject constructor( val adminKey = requireAdminAccess(group) val groupAuth = OwnedSwarmAuth.ofClosedGroup(group, adminKey) - val batchRequests = mutableListOf() + val batchRequests = mutableListOf() val subAccountTokens = configFactory.withMutableGroupConfigs(group) { configs -> val shareHistoryHexes = mutableListOf() @@ -291,7 +293,7 @@ class GroupManagerV2Impl @Inject constructor( if (shareHistoryHexes.isNotEmpty()) { val memberKey = configs.groupKeys.supplementFor(shareHistoryHexes) batchRequests.add( - SnodeAPI.buildAuthenticatedStoreBatchInfo( + sessionClient.buildAuthenticatedStoreBatchInfo( namespace = Namespace.GROUP_KEYS(), message = SnodeMessage( recipient = group.hexString, @@ -309,15 +311,15 @@ class GroupManagerV2Impl @Inject constructor( } // Call un-revocate API on new members, in case they have been removed before - batchRequests += SnodeAPI.buildAuthenticatedUnrevokeSubKeyBatchRequest( + batchRequests += sessionClient.buildAuthenticatedUnrevokeSubKeyBatchRequest( groupAdminAuth = groupAuth, subAccountTokens = subAccountTokens ) // Call the API try { - val swarmNode = SnodeAPI.getSingleTargetSnode(group.hexString).await() - val response = SnodeAPI.getBatchResponse(swarmNode, group.hexString, batchRequests) + val swarmNode = swarmDirectory.getSingleTargetSnode(group.hexString) + val response = sessionClient.getBatchResponse(swarmNode, group.hexString, batchRequests) // Make sure every request is successful response.requireAllRequestsSuccessful("Failed to invite members") @@ -460,7 +462,7 @@ class GroupManagerV2Impl @Inject constructor( OwnedSwarmAuth.ofClosedGroup(groupAccountId, it) } ?: return@launchAndWait - SnodeAPI.deleteMessage(groupAccountId.hexString, groupAdminAuth, messagesToDelete) + sessionClient.deleteMessage(groupAccountId.hexString, groupAdminAuth, messagesToDelete) } override suspend fun clearAllMessagesForEveryone(groupAccountId: AccountId, deletedHashes: List) { @@ -474,9 +476,9 @@ class GroupManagerV2Impl @Inject constructor( configs.groupInfo.setDeleteBefore(clock.currentTimeSeconds()) } - // remove messages from swarm SnodeAPI.deleteMessage + // remove messages from swarm sessionClient.deleteMessage val cleanedHashes: List = deletedHashes.filter { !it.isNullOrEmpty() }.filterNotNull() - if(cleanedHashes.isNotEmpty()) SnodeAPI.deleteMessage(groupAccountId.hexString, groupAdminAuth, cleanedHashes) + if(cleanedHashes.isNotEmpty()) sessionClient.deleteMessage(groupAccountId.hexString, groupAdminAuth, cleanedHashes) } override suspend fun handleMemberLeftMessage(memberId: AccountId, group: AccountId) = scope.launchAndWait(group, "Handle member left message") { @@ -663,7 +665,7 @@ class GroupManagerV2Impl @Inject constructor( if (groupInviteMessageHash != null) { val auth = requireNotNull(storage.userAuth) - SnodeAPI.deleteMessage( + sessionClient.deleteMessage( publicKey = auth.accountId.hexString, swarmAuth = auth, serverHashes = listOf(groupInviteMessageHash) @@ -736,7 +738,7 @@ class GroupManagerV2Impl @Inject constructor( // Delete the invite once we have approved if (inviteMessageHash != null) { val auth = requireNotNull(storage.userAuth) - SnodeAPI.deleteMessage( + sessionClient.deleteMessage( publicKey = auth.accountId.hexString, swarmAuth = auth, serverHashes = listOf(inviteMessageHash) @@ -816,7 +818,7 @@ class GroupManagerV2Impl @Inject constructor( } // Delete the promotion message remotely - SnodeAPI.deleteMessage( + sessionClient.deleteMessage( userAuth.accountId.hexString, userAuth, listOf(promoteMessageHash) @@ -1023,7 +1025,7 @@ class GroupManagerV2Impl @Inject constructor( // If we are admin, we can delete the messages from the group swarm group.adminKey?.data?.let { adminKey -> - SnodeAPI.deleteMessage( + sessionClient.deleteMessage( publicKey = groupId.hexString, swarmAuth = OwnedSwarmAuth.ofClosedGroup(groupId, adminKey), serverHashes = messageHashes.toList() @@ -1121,7 +1123,7 @@ class GroupManagerV2Impl @Inject constructor( sender = sender.hexString, closedGroupId = groupId.hexString)) ) { - SnodeAPI.deleteMessage( + sessionClient.deleteMessage( groupId.hexString, OwnedSwarmAuth.ofClosedGroup(groupId, adminKey), hashes @@ -1136,7 +1138,7 @@ class GroupManagerV2Impl @Inject constructor( } if (userMessageHashes.isNotEmpty()) { - SnodeAPI.deleteMessage( + sessionClient.deleteMessage( groupId.hexString, OwnedSwarmAuth.ofClosedGroup(groupId, adminKey), userMessageHashes diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt index c768e1ec25..5ad695e801 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt @@ -22,8 +22,9 @@ import kotlinx.coroutines.sync.withPermit import network.loki.messenger.libsession_util.Namespace import org.session.libsession.messaging.sending_receiving.MessageParser import org.session.libsession.messaging.sending_receiving.ReceivedMessageProcessor -import org.session.libsession.snode.SnodeAPI +import org.session.libsession.network.SessionClient import org.session.libsession.network.SnodeClock +import org.session.libsession.network.snode.SwarmDirectory import org.session.libsession.snode.model.BatchResponse import org.session.libsession.snode.model.RetrieveMessageResponse import org.session.libsession.utilities.Address @@ -55,6 +56,8 @@ class GroupPoller @AssistedInject constructor( private val receivedMessageHashDatabase: ReceivedMessageHashDatabase, private val messageParser: MessageParser, private val receivedMessageProcessor: ReceivedMessageProcessor, + private val swarmDirectory: SwarmDirectory, + private val sessionClient: SessionClient, ) { companion object { private const val POLL_INTERVAL = 3_000L @@ -195,7 +198,7 @@ class GroupPoller @AssistedInject constructor( // Fetch snodes if we don't have any val swarmNodes = if (pollState.shouldFetchSwarmNodes()) { Log.d(TAG, "Fetching swarm nodes for $groupId") - val fetched = SnodeAPI.fetchSwarmNodes(groupId.hexString).toSet() + val fetched = swarmDirectory.fetchSwarm(groupId.hexString).toSet() pollState.swarmNodes = fetched fetched } else { @@ -244,10 +247,10 @@ class GroupPoller @AssistedInject constructor( val pollingTasks = mutableListOf>>() val receiveRevokeMessage = async { - SnodeAPI.sendBatchRequest( + sessionClient.sendBatchRequest( snode, groupId.hexString, - SnodeAPI.buildAuthenticatedRetrieveBatchRequest( + sessionClient.buildAuthenticatedRetrieveBatchRequest( lastHash = lokiApiDatabase.getLastMessageHashValue( snode, groupId.hexString, @@ -263,10 +266,10 @@ class GroupPoller @AssistedInject constructor( if (configHashesToExtends.isNotEmpty() && adminKey != null) { pollingTasks += "extending group config TTL" to async { - SnodeAPI.sendBatchRequest( + sessionClient.sendBatchRequest( snode, groupId.hexString, - SnodeAPI.buildAuthenticatedAlterTtlBatchRequest( + sessionClient.buildAuthenticatedAlterTtlBatchRequest( messageHashes = configHashesToExtends.toList(), auth = groupAuth, newExpiry = clock.currentTimeMills() + 14.days.inWholeMilliseconds, @@ -284,10 +287,10 @@ class GroupPoller @AssistedInject constructor( ).orEmpty() - SnodeAPI.sendBatchRequest( + sessionClient.sendBatchRequest( snode = snode, publicKey = groupId.hexString, - request = SnodeAPI.buildAuthenticatedRetrieveBatchRequest( + request = sessionClient.buildAuthenticatedRetrieveBatchRequest( lastHash = lastHash, auth = groupAuth, namespace = Namespace.GROUP_MESSAGES(), @@ -303,10 +306,10 @@ class GroupPoller @AssistedInject constructor( Namespace.GROUP_MEMBERS() ).map { ns -> async { - SnodeAPI.sendBatchRequest( + sessionClient.sendBatchRequest( snode = snode, publicKey = groupId.hexString, - request = SnodeAPI.buildAuthenticatedRetrieveBatchRequest( + request = sessionClient.buildAuthenticatedRetrieveBatchRequest( lastHash = lokiApiDatabase.getLastMessageHashValue( snode, groupId.hexString, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt index 9c69c3e9a5..21be3fee8d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt @@ -20,11 +20,11 @@ import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.control.GroupUpdated import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.MessageAuthentication -import org.session.libsession.snode.OwnedSwarmAuth -import org.session.libsession.snode.SnodeAPI +import org.session.libsession.network.SessionClient import org.session.libsession.network.SnodeClock +import org.session.libsession.network.snode.SwarmDirectory +import org.session.libsession.snode.OwnedSwarmAuth import org.session.libsession.snode.SnodeMessage -import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigUpdateNotification @@ -58,6 +58,8 @@ class RemoveGroupMemberHandler @Inject constructor( private val storage: StorageProtocol, private val groupScope: GroupScope, private val messageSender: MessageSender, + private val swarmDirectory: SwarmDirectory, + private val sessionClient: SessionClient, ) : AuthAwareComponent { override suspend fun doWhileLoggedIn(loggedInState: LoggedInState) { configFactory.configUpdateNotifications @@ -97,13 +99,13 @@ class RemoveGroupMemberHandler @Inject constructor( // 2. Send a message to a special namespace on the group to inform the removed members they have been removed // 3. Conditionally, send a `GroupUpdateDeleteMemberContent` to the group so the message deletion // can be performed by everyone in the group. - val calls = ArrayList(3) + val calls = ArrayList(3) val groupAuth = OwnedSwarmAuth.ofClosedGroup(groupAccountId, adminKey) // Call No 1. Revoke sub-key. This call is crucial and must not fail for the rest of the operation to be successful. calls += checkNotNull( - SnodeAPI.buildAuthenticatedRevokeSubKeyBatchRequest( + sessionClient.buildAuthenticatedRevokeSubKeyBatchRequest( groupAdminAuth = groupAuth, subAccountTokens = pendingRemovals.map { (member, _) -> configs.groupKeys.getSubAccountToken(member.accountId()) @@ -112,7 +114,7 @@ class RemoveGroupMemberHandler @Inject constructor( ) { "Fail to create a revoke request" } // Call No 2. Send a "kicked" message to the revoked namespace - calls += SnodeAPI.buildAuthenticatedStoreBatchInfo( + calls += sessionClient.buildAuthenticatedStoreBatchInfo( namespace = Namespace.REVOKED_GROUP_MESSAGES(), message = buildGroupKickMessage( groupAccountId.hexString, @@ -125,7 +127,7 @@ class RemoveGroupMemberHandler @Inject constructor( // Call No 3. Conditionally send the `GroupUpdateDeleteMemberContent` if (pendingRemovals.any { (member, status) -> member.shouldRemoveMessages(status) }) { - calls += SnodeAPI.buildAuthenticatedStoreBatchInfo( + calls += sessionClient.buildAuthenticatedStoreBatchInfo( namespace = Namespace.GROUP_MESSAGES(), message = buildDeleteGroupMemberContentMessage( adminKey = adminKey, @@ -139,16 +141,16 @@ class RemoveGroupMemberHandler @Inject constructor( ) } - pendingRemovals to (calls as List) + pendingRemovals to (calls as List) } if (pendingRemovals.isEmpty() || batchCalls.isEmpty()) { return } - val node = SnodeAPI.getSingleTargetSnode(groupAccountId.hexString).await() + val node = swarmDirectory.getSingleTargetSnode(groupAccountId.hexString) val response = - SnodeAPI.getBatchResponse( + sessionClient.getBatchResponse( node, groupAccountId.hexString, batchCalls, diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/DefaultConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/DefaultConversationRepository.kt index 87ee280ef1..4becf59567 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/DefaultConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/DefaultConversationRepository.kt @@ -24,8 +24,8 @@ import org.session.libsession.messaging.messages.visible.OpenGroupInvitation import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.MessageSender -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SessionClient +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.TextSecurePreferences @@ -79,6 +79,7 @@ class DefaultConversationRepository @Inject constructor( private val messageSender: MessageSender, private val loginStateRepository: LoginStateRepository, private val proStatusManager: ProStatusManager, + private val sessionClient: SessionClient ) : ConversationRepository { override val conversationListAddressesFlow get() = loginStateRepository.flowWithLoggedInState { @@ -354,7 +355,7 @@ class DefaultConversationRepository @Inject constructor( // delete from swarm messageDataProvider.getServerHashForMessage(message.messageId) ?.let { serverHash -> - SnodeAPI.deleteMessage(recipient.address, userAuth, listOf(serverHash)) + sessionClient.deleteMessage(recipient.address, userAuth, listOf(serverHash)) } // send an UnsendRequest to user's swarm @@ -411,7 +412,7 @@ class DefaultConversationRepository @Inject constructor( // delete from swarm messageDataProvider.getServerHashForMessage(message.messageId) ?.let { serverHash -> - SnodeAPI.deleteMessage(recipient.address, userAuth, listOf(serverHash)) + sessionClient.deleteMessage(recipient.address, userAuth, listOf(serverHash)) } // send an UnsendRequest to user's swarm diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt index b6a5285f6e..e658ac5456 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt @@ -18,19 +18,17 @@ import kotlinx.serialization.json.boolean import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put -import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.functional.bind import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.calls.CallMessageType import org.session.libsession.messaging.messages.applyExpiryMode import org.session.libsession.messaging.messages.control.CallMessage import org.session.libsession.messaging.sending_receiving.MessageSender -import org.session.libsession.snode.SnodeAPI +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Debouncer import org.session.libsession.utilities.Util -import org.session.protos.SessionProtos.CallMessage.Type.ICE_CANDIDATES import org.session.libsignal.utilities.Log +import org.session.protos.SessionProtos.CallMessage.Type.ICE_CANDIDATES import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.AudioDeviceUpdate import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.AudioEnabled @@ -75,6 +73,7 @@ class CallManager @Inject constructor( audioManager: AudioManagerCompat, private val storage: StorageProtocol, private val messageSender: MessageSender, + private val snodeClock: SnodeClock ): PeerConnection.Observer, SignalAudioManager.EventListener, CameraEventListener, DataChannel.Observer { @@ -691,7 +690,7 @@ class CallManager @Inject constructor( } } - fun insertCallMessage(threadPublicKey: String, callMessageType: CallMessageType, signal: Boolean = false, sentTimestamp: Long = SnodeAPI.nowWithOffset) { + fun insertCallMessage(threadPublicKey: String, callMessageType: CallMessageType, signal: Boolean = false, sentTimestamp: Long = snodeClock.currentTimeMills()) { storage.insertCallMessage(threadPublicKey, callMessageType, sentTimestamp) } From 13e61b6f7087bb4f0c5146690e16fb9238f7abfd Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 17 Dec 2025 16:45:29 +1100 Subject: [PATCH 25/77] Last part of the SnodeAPI switch --- .../libsession/network/SessionClient.kt | 8 ++--- .../securesms/media/MediaOverviewViewModel.kt | 21 ++++++------- .../notifications/AndroidAutoReplyReceiver.kt | 11 ++++--- .../notifications/MarkReadProcessor.kt | 30 +++++++++++-------- .../preferences/SettingsViewModel.kt | 16 +++++----- .../securesms/tokenpage/TokenPageViewModel.kt | 16 ++++------ .../securesms/webrtc/CallMessageProcessor.kt | 6 ++-- 7 files changed, 54 insertions(+), 54 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/SessionClient.kt b/app/src/main/java/org/session/libsession/network/SessionClient.kt index d09ffbbc01..89e5cdb306 100644 --- a/app/src/main/java/org/session/libsession/network/SessionClient.kt +++ b/app/src/main/java/org/session/libsession/network/SessionClient.kt @@ -30,11 +30,9 @@ import org.session.libsession.network.snode.SwarmDirectory import org.session.libsession.snode.SnodeMessage import org.session.libsession.snode.SwarmAuth import org.session.libsession.snode.model.BatchResponse -import org.session.libsession.snode.model.RetrieveMessageResponse import org.session.libsession.snode.model.StoreMessageResponse import org.session.libsession.utilities.mapValuesNotNull import org.session.libsession.utilities.toByteArray -import org.session.libsignal.crypto.shuffledRandom import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.ByteArraySlice import org.session.libsignal.utilities.ByteArraySlice.Companion.view @@ -281,14 +279,14 @@ class SessionClient @Inject constructor( @Suppress("UNCHECKED_CAST") suspend fun deleteMessage( publicKey: String, - auth: SwarmAuth, + swarmAuth: SwarmAuth, serverHashes: List, version: Version = Version.V4 ): Map<*, *> { val snode = swarmDirectory.getSingleTargetSnode(publicKey) val params = buildAuthenticatedParameters( - auth = auth, + auth = swarmAuth, namespace = null, verificationData = { _, _ -> buildString { @@ -328,7 +326,7 @@ class SessionClient @Inject constructor( ?: return@mapValuesNotNull false // Signature: ( PUBKEY_HEX || RMSG[0]..RMSG[N] || DMSG[0]..DMSG[M] ) - val message = sequenceOf(auth.accountId.hexString) + val message = sequenceOf(swarmAuth.accountId.hexString) .plus(serverHashes) .plus(hashes) .toByteArray() diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt index 6d8c645b3e..a4276808ab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt @@ -9,35 +9,25 @@ import androidx.lifecycle.viewModelScope import dagger.assisted.Assisted import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel -import java.time.Instant -import java.time.LocalDate -import java.time.ZoneId -import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter -import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.messaging.messages.control.DataExtractionNotification import org.session.libsession.messaging.sending_receiving.MessageSender -import org.session.libsession.snode.SnodeAPI +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.isGroupOrCommunity import org.session.libsession.utilities.recipients.displayName import org.thoughtcrime.securesms.MediaPreviewActivity -import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.MediaDatabase import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord import org.thoughtcrime.securesms.database.RecipientRepository @@ -49,6 +39,12 @@ import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.SaveAttachmentTask import org.thoughtcrime.securesms.util.asSequence +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.util.Locale @HiltViewModel(assistedFactory = MediaOverviewViewModel.Factory::class) class MediaOverviewViewModel @AssistedInject constructor( @@ -59,6 +55,7 @@ class MediaOverviewViewModel @AssistedInject constructor( private val dateUtils: DateUtils, recipientRepository: RecipientRepository, private val messageSender: MessageSender, + private val snodeClock: SnodeClock, ) : AndroidViewModel(application) { private val timeBuckets by lazy { FixedTimeBuckets() } @@ -291,7 +288,7 @@ class MediaOverviewViewModel @AssistedInject constructor( successCount > 0 && !address.isGroupOrCommunity) { withContext(Dispatchers.Default) { - val timestamp = SnodeAPI.nowWithOffset + val timestamp = snodeClock.currentTimeMills() val kind = DataExtractionNotification.Kind.MediaSaved(timestamp) val message = DataExtractionNotification(kind) messageSender.send(message, address) diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.kt index f6ba38c84d..daf1c6a0fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.kt @@ -28,9 +28,8 @@ import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingTextMessage import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.sending_receiving.MessageSender -import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier -import org.session.libsession.snode.SnodeAPI.nowWithOffset +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.isGroupOrCommunity import org.session.libsignal.utilities.Log @@ -71,6 +70,10 @@ class AndroidAutoReplyReceiver : BroadcastReceiver() { @Inject lateinit var proStatusManager: ProStatusManager + @Inject + lateinit var snodeClock: SnodeClock + + @SuppressLint("StaticFieldLeak") override fun onReceive(context: Context, intent: Intent) { if (REPLY_ACTION != intent.getAction()) return @@ -97,7 +100,7 @@ class AndroidAutoReplyReceiver : BroadcastReceiver() { val message = VisibleMessage() message.text = responseText.toString() proStatusManager.addProFeatures(message) - message.sentTimestamp = nowWithOffset + message.sentTimestamp = snodeClock.currentTimeMills() messageSender.send(message, address!!) val expiryMode = recipientRepository.getRecipientSync(address).expiryMode val expiresInMillis = expiryMode.expiryMillis @@ -137,7 +140,7 @@ class AndroidAutoReplyReceiver : BroadcastReceiver() { replyThreadId, reply, false, - nowWithOffset, + snodeClock.currentTimeMills(), true ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt index 8bc7eaad5e..62ac4cf526 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt @@ -8,7 +8,7 @@ import org.session.libsession.database.StorageProtocol import org.session.libsession.database.userAuth import org.session.libsession.messaging.messages.control.ReadReceipt import org.session.libsession.messaging.sending_receiving.MessageSender -import org.session.libsession.snode.SnodeAPI +import org.session.libsession.network.SessionClient import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled import org.session.libsession.utilities.associateByNotNull @@ -38,6 +38,7 @@ class MarkReadProcessor @Inject constructor( private val storage: StorageProtocol, private val snodeClock: SnodeClock, private val lokiMessageDatabase: LokiMessageDatabase, + private val sessionClient: SessionClient ) { fun process( markedReadMessages: List @@ -91,18 +92,21 @@ class MarkReadProcessor @Inject constructor( private fun shortenExpiryOfDisappearingAfterRead( hashToMessage: Map ) { - hashToMessage.entries - .groupBy( - keySelector = { it.value.expirationInfo.expiresIn }, - valueTransform = { it.key } - ).forEach { (expiresIn, hashes) -> - SnodeAPI.alterTtl( - messageHashes = hashes, - newExpiry = snodeClock.currentTimeMills() + expiresIn, - auth = checkNotNull(storage.userAuth) { "No authorized user" }, - shorten = true - ) - } + //todo ONION verify move to suspend below + GlobalScope.launch { + hashToMessage.entries + .groupBy( + keySelector = { it.value.expirationInfo.expiresIn }, + valueTransform = { it.key } + ).forEach { (expiresIn, hashes) -> + sessionClient.alterTtl( + messageHashes = hashes, + newExpiry = snodeClock.currentTimeMills() + expiresIn, + auth = checkNotNull(storage.userAuth) { "No authorized user" }, + shorten = true + ) + } + } } private val Recipient.shouldSendReadReceipt: Boolean diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt index 6aded95720..d8c96f2568 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt @@ -34,9 +34,9 @@ import okio.source import org.session.libsession.database.StorageProtocol import org.session.libsession.database.userAuth import org.session.libsession.messaging.open_groups.OpenGroupApi -import org.session.libsession.snode.OnionRequestAPI -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.utilities.await +import org.session.libsession.network.SessionClient +import org.session.libsession.network.model.PathStatus +import org.session.libsession.network.onion.PathManager import org.session.libsession.utilities.StringSubstitutionConstants.VERSION_KEY import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient @@ -87,6 +87,8 @@ class SettingsViewModel @Inject constructor( private val attachmentProcessor: AttachmentProcessor, private val proDetailsRepository: ProDetailsRepository, private val donationManager: DonationManager, + private val pathManager: PathManager, + private val sessionClient: SessionClient ) : ViewModel() { private val TAG = "SettingsViewModel" @@ -98,7 +100,7 @@ class SettingsViewModel @Inject constructor( private val _uiState = MutableStateFlow(UIState( username = "", accountID = selfRecipient.value.address.address, - pathStatus = OnionRequestAPI.PathStatus.BUILDING, + pathStatus = PathStatus.BUILDING, version = getVersionNumber(), recoveryHidden = prefs.getHidePassword(), isPostPro = proStatusManager.isPostPro(), @@ -145,7 +147,7 @@ class SettingsViewModel @Inject constructor( } viewModelScope.launch { - OnionRequestAPI.pathStatus.collect { status -> + pathManager.status.collect { status -> _uiState.update { it.copy(pathStatus = status) } } } @@ -466,7 +468,7 @@ class SettingsViewModel @Inject constructor( }.joinAll() } - SnodeAPI.deleteAllMessages(checkNotNull(storage.userAuth)).await() + sessionClient.deleteAllMessages(checkNotNull(storage.userAuth)) } catch (e: Exception) { Log.e(TAG, "Failed to delete network messages - offering user option to delete local data only.", e) null @@ -659,7 +661,7 @@ class SettingsViewModel @Inject constructor( data class UIState( val username: String, val accountID: String, - val pathStatus: OnionRequestAPI.PathStatus, + val pathStatus: PathStatus, val version: CharSequence = "", val showLoader: Boolean = false, val avatarDialogState: AvatarDialogState = AvatarDialogState.NoAvatar, diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt index b8e9b44582..79cf6fd7b5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenPageViewModel.kt @@ -17,11 +17,9 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R -import nl.komponents.kovenant.Promise import org.session.libsession.LocalisedTimeUtil.toShortSinglePartString -import org.session.libsession.snode.OnionRequestAPI -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.utilities.await +import org.session.libsession.network.onion.PathManager +import org.session.libsession.network.snode.SwarmDirectory import org.session.libsession.utilities.NonTranslatableStringConstants.SESSION_NETWORK_DATA_PRICE import org.session.libsession.utilities.NonTranslatableStringConstants.TOKEN_NAME_SHORT import org.session.libsession.utilities.NonTranslatableStringConstants.USD_NAME_SHORT @@ -29,7 +27,6 @@ import org.session.libsession.utilities.StringSubstitutionConstants.DATE_TIME_KE import org.session.libsession.utilities.StringSubstitutionConstants.RELATIVE_TIME_KEY import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.Snode import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.util.DateUtils @@ -48,6 +45,8 @@ class TokenPageViewModel @Inject constructor( private val dateUtils: DateUtils, private val loginStateRepository: LoginStateRepository, private val conversationRepository: ConversationRepository, + private val swarmDirectory: SwarmDirectory, + private val pathManager: PathManager ) : ViewModel() { private val TAG = "TokenPageVM" @@ -238,13 +237,10 @@ class TokenPageViewModel @Inject constructor( withContext(Dispatchers.Default) { val myPublicKey = loginStateRepository.requireLocalNumber() - val getSwarmSetPromise: Promise, Exception> = - SnodeAPI.getSwarm(myPublicKey) - val numSessionNodesInOurSwarm = try { // Get the count of Session nodes in our swarm (technically in the range 1..10, but // even a new account seems to start with a nodes-in-swarm count of 4). - getSwarmSetPromise.await().size + swarmDirectory.getSwarm(myPublicKey).size } catch (e: Exception) { Log.w(TAG, "Couldn't get nodes in swarm count.", e) 5 // Pick a sane middle-ground should we error for any reason @@ -278,7 +274,7 @@ class TokenPageViewModel @Inject constructor( } // This is hard-coded to 2 on Android but may vary on other platforms - val pathCount = OnionRequestAPI.paths.value.size + val pathCount = pathManager.paths.value.size /* Note: Num session nodes securing you messages formula is: diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallMessageProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallMessageProcessor.kt index f42b57ec54..fcb6fbebde 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallMessageProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallMessageProcessor.kt @@ -2,13 +2,12 @@ package org.thoughtcrime.securesms.webrtc import android.Manifest import android.content.Context -import dagger.Lazy import dagger.hilt.android.qualifiers.ApplicationContext import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.calls.CallMessageType import org.session.libsession.messaging.messages.control.CallMessage import org.session.libsession.messaging.utilities.WebRtcUtils -import org.session.libsession.snode.SnodeAPI +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log @@ -33,6 +32,7 @@ class CallMessageProcessor @Inject constructor( private val storage: StorageProtocol, private val webRtcBridge: WebRtcCallBridge, private val recipientRepository: RecipientRepository, + private val snodeClock: SnodeClock ) : AuthAwareComponent { companion object { @@ -61,7 +61,7 @@ class CallMessageProcessor @Inject constructor( continue } - val isVeryExpired = (nextMessage.sentTimestamp?:0) + VERY_EXPIRED_TIME < SnodeAPI.nowWithOffset + val isVeryExpired = (nextMessage.sentTimestamp?:0) + VERY_EXPIRED_TIME < snodeClock.currentTimeMills() if (isVeryExpired) { Log.e("Loki", "Dropping very expired call message") continue From 02b7159bbf8ec14d950151478217667e9fd97699 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 17 Dec 2025 16:45:37 +1100 Subject: [PATCH 26/77] interface implementations --- .../libsession/network/snode/SnodeStorage.kt | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/app/src/main/java/org/session/libsession/network/snode/SnodeStorage.kt b/app/src/main/java/org/session/libsession/network/snode/SnodeStorage.kt index e0c58c8b15..84348e0486 100644 --- a/app/src/main/java/org/session/libsession/network/snode/SnodeStorage.kt +++ b/app/src/main/java/org/session/libsession/network/snode/SnodeStorage.kt @@ -3,6 +3,9 @@ package org.session.libsession.network.snode import org.session.libsession.network.model.Path import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.database.LokiAPIDatabase + +//todo ONION need a hilt module to inject all of these interface SnodePathStorage { fun getOnionRequestPaths(): List @@ -18,4 +21,39 @@ interface SwarmStorage { interface SnodePoolStorage { fun getSnodePool(): Set fun setSnodePool(newValue: Set) +} + + +class DbSnodePathStorage(private val db: LokiAPIDatabase) : SnodePathStorage { + override fun getOnionRequestPaths(): List { + return db.getOnionRequestPaths() + } + + override fun setOnionRequestPaths(paths: List) { + db.setOnionRequestPaths(paths) + } + + override fun clearOnionRequestPaths() { + db.clearOnionRequestPaths() + } +} + +class DbSwarmStorage(private val db: LokiAPIDatabase) : SwarmStorage { + override fun getSwarm(publicKey: String): Set { + return db.getSwarm(publicKey) ?: emptySet() // Handle potential null return + } + + override fun setSwarm(publicKey: String, swarm: Set) { + db.setSwarm(publicKey, swarm) + } +} + +class DbSnodePoolStorage(private val db: LokiAPIDatabase) : SnodePoolStorage { + override fun getSnodePool(): Set { + return db.getSnodePool() + } + + override fun setSnodePool(newValue: Set) { + db.setSnodePool(newValue) + } } \ No newline at end of file From b7eb26ddecaa32dc50f7ce7cfccf4a55cc963111 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 18 Dec 2025 09:54:43 +1100 Subject: [PATCH 27/77] Hilt inhjection --- .../libsession/network/NetworkModule.kt | 31 +++++++++++++++++++ .../libsession/network/SessionNetwork.kt | 5 ++- .../network/onion/http/HttpOnionTransport.kt | 5 ++- .../network/snode/SnodeDirectory.kt | 5 +-- .../libsession/network/snode/SnodeStorage.kt | 13 +++++--- 5 files changed, 50 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/org/session/libsession/network/NetworkModule.kt diff --git a/app/src/main/java/org/session/libsession/network/NetworkModule.kt b/app/src/main/java/org/session/libsession/network/NetworkModule.kt new file mode 100644 index 0000000000..abddceab48 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/NetworkModule.kt @@ -0,0 +1,31 @@ +package org.session.libsession.network + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.session.libsession.network.onion.http.HttpOnionTransport +import org.session.libsession.network.snode.DbSnodePathStorage +import org.session.libsession.network.snode.DbSnodePoolStorage +import org.session.libsession.network.snode.DbSwarmStorage +import org.session.libsession.network.snode.SnodePathStorage +import org.session.libsession.network.snode.SnodePoolStorage +import org.session.libsession.network.snode.SwarmStorage + +@Module +@InstallIn(SingletonComponent::class) +abstract class NetworkModule { + + @Binds + abstract fun providePathStorage(storage: DbSnodePathStorage): SnodePathStorage + + @Binds + abstract fun provideSwarmStorage(storage: DbSwarmStorage): SwarmStorage + + @Binds + abstract fun provideSnodePoolStorage(storage: DbSnodePoolStorage): SnodePoolStorage + + @Binds + abstract fun provideOnionTransport(transport: HttpOnionTransport): SnodePathStorage + +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt index 0040ca91ac..bdabc81fb9 100644 --- a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt +++ b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt @@ -17,6 +17,8 @@ import org.session.libsession.network.utilities.getHeadersForOnionRequest import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode +import javax.inject.Inject +import javax.inject.Singleton import kotlin.random.Random /** @@ -32,7 +34,8 @@ import kotlin.random.Random * - Onion crypto construction or transport I/O (OnionTransport) * - Policy / healing logic (OnionErrorManager) */ -class SessionNetwork( +@Singleton +class SessionNetwork @Inject constructor( private val pathManager: PathManager, private val transport: OnionTransport, private val errorManager: OnionErrorManager, diff --git a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt index 1cdeae99f2..4fabe9d4e4 100644 --- a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt +++ b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt @@ -15,8 +15,11 @@ import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Snode import org.session.libsignal.utilities.toHexString +import javax.inject.Inject +import javax.inject.Singleton -class HttpOnionTransport : OnionTransport { +@Singleton +class HttpOnionTransport @Inject constructor() : OnionTransport { override suspend fun send( path: List, diff --git a/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt b/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt index 503b5f0103..b8a5a5d864 100644 --- a/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt +++ b/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt @@ -3,6 +3,7 @@ package org.session.libsession.network.snode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.session.libsession.utilities.Environment +import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.crypto.secureRandom import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.JsonUtil @@ -17,7 +18,7 @@ import javax.inject.Singleton @Singleton class SnodeDirectory @Inject constructor( private val storage: SnodePoolStorage, - private val environment: Environment, + private val prefs: TextSecurePreferences, @ManagerScope private val scope: CoroutineScope, ) : OnAppStartupComponent { @@ -32,7 +33,7 @@ class SnodeDirectory @Inject constructor( private const val KEY_VERSION = "storage_server_version" } - private val seedNodePool: Set = when (environment) { + private val seedNodePool: Set = when (prefs.getEnvironment()) { Environment.DEV_NET -> setOf("http://sesh-net.local:1280") Environment.TEST_NET -> setOf("http://public.loki.foundation:38157") Environment.MAIN_NET -> setOf( diff --git a/app/src/main/java/org/session/libsession/network/snode/SnodeStorage.kt b/app/src/main/java/org/session/libsession/network/snode/SnodeStorage.kt index 84348e0486..4bc2cc8a24 100644 --- a/app/src/main/java/org/session/libsession/network/snode/SnodeStorage.kt +++ b/app/src/main/java/org/session/libsession/network/snode/SnodeStorage.kt @@ -4,8 +4,8 @@ package org.session.libsession.network.snode import org.session.libsession.network.model.Path import org.session.libsignal.utilities.Snode import org.thoughtcrime.securesms.database.LokiAPIDatabase - -//todo ONION need a hilt module to inject all of these +import javax.inject.Inject +import javax.inject.Singleton interface SnodePathStorage { fun getOnionRequestPaths(): List @@ -24,7 +24,8 @@ interface SnodePoolStorage { } -class DbSnodePathStorage(private val db: LokiAPIDatabase) : SnodePathStorage { +@Singleton +class DbSnodePathStorage @Inject constructor(private val db: LokiAPIDatabase) : SnodePathStorage { override fun getOnionRequestPaths(): List { return db.getOnionRequestPaths() } @@ -38,7 +39,8 @@ class DbSnodePathStorage(private val db: LokiAPIDatabase) : SnodePathStorage { } } -class DbSwarmStorage(private val db: LokiAPIDatabase) : SwarmStorage { +@Singleton +class DbSwarmStorage @Inject constructor(private val db: LokiAPIDatabase) : SwarmStorage { override fun getSwarm(publicKey: String): Set { return db.getSwarm(publicKey) ?: emptySet() // Handle potential null return } @@ -48,7 +50,8 @@ class DbSwarmStorage(private val db: LokiAPIDatabase) : SwarmStorage { } } -class DbSnodePoolStorage(private val db: LokiAPIDatabase) : SnodePoolStorage { +@Singleton +class DbSnodePoolStorage @Inject constructor(private val db: LokiAPIDatabase) : SnodePoolStorage { override fun getSnodePool(): Set { return db.getSnodePool() } From f99c9d0b566031bf04e795d46928709249aef472 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 18 Dec 2025 11:25:59 +1100 Subject: [PATCH 28/77] Removing Result in favor of a direct OnionResponse --- .../messaging/file_server/FileServerApi.kt | 11 ++- .../libsession/network/NetworkModule.kt | 3 +- .../libsession/network/SessionNetwork.kt | 72 +++++++++---------- .../network/onion/OnionTransport.kt | 4 +- .../network/onion/http/HttpOnionTransport.kt | 38 +++++----- .../securesms/ApplicationContext.kt | 3 - 6 files changed, 61 insertions(+), 70 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt b/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt index e5128232f7..7958b8a427 100644 --- a/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt +++ b/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt @@ -3,7 +3,6 @@ package org.session.libsession.messaging.file_server import android.util.Base64 import kotlinx.coroutines.CancellationException import network.loki.messenger.libsession_util.Curve25519 -import network.loki.messenger.libsession_util.ED25519 import network.loki.messenger.libsession_util.util.BlindKeyAPI import okhttp3.Headers.Companion.toHeaders import okhttp3.HttpUrl @@ -11,8 +10,7 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody import org.session.libsession.database.StorageProtocol -import org.session.libsession.snode.OnionRequestAPI -import org.session.libsession.snode.utilities.await +import org.session.libsession.network.SessionNetwork import org.session.libsignal.utilities.ByteArraySlice import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.Hex @@ -30,6 +28,7 @@ import kotlin.time.Duration.Companion.milliseconds @Singleton class FileServerApi @Inject constructor( private val storage: StorageProtocol, + private val sessionNetwork: SessionNetwork ) { companion object { @@ -95,14 +94,14 @@ class FileServerApi @Inject constructor( } return if (request.useOnionRouting) { try { - val response = OnionRequestAPI.sendOnionRequest( + val response = sessionNetwork.sendToServer( request = requestBuilder.build(), - server = request.fileServer.url.host, + serverBaseUrl = request.fileServer.url.host, x25519PublicKey = Hex.toStringCondensed( Curve25519.pubKeyFromED25519(Hex.fromStringCondensed(request.fileServer.ed25519PublicKeyHex)) ) - ).await() + ) check(response.code in 200..299) { "Error response from file server: ${response.code}" diff --git a/app/src/main/java/org/session/libsession/network/NetworkModule.kt b/app/src/main/java/org/session/libsession/network/NetworkModule.kt index abddceab48..e20bd6d8ae 100644 --- a/app/src/main/java/org/session/libsession/network/NetworkModule.kt +++ b/app/src/main/java/org/session/libsession/network/NetworkModule.kt @@ -4,6 +4,7 @@ import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import org.session.libsession.network.onion.OnionTransport import org.session.libsession.network.onion.http.HttpOnionTransport import org.session.libsession.network.snode.DbSnodePathStorage import org.session.libsession.network.snode.DbSnodePoolStorage @@ -26,6 +27,6 @@ abstract class NetworkModule { abstract fun provideSnodePoolStorage(storage: DbSnodePoolStorage): SnodePoolStorage @Binds - abstract fun provideOnionTransport(transport: HttpOnionTransport): SnodePathStorage + abstract fun provideOnionTransport(transport: HttpOnionTransport): OnionTransport } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt index bdabc81fb9..f78748d9b8 100644 --- a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt +++ b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt @@ -59,7 +59,7 @@ class SessionNetwork @Inject constructor( snode: Snode, publicKey: String? = null, version: Version = Version.V4 - ): Result { + ): OnionResponse { val payload = JsonUtil.toJson( mapOf( "method" to method.rawValue, @@ -88,7 +88,7 @@ class SessionNetwork @Inject constructor( serverBaseUrl: String, x25519PublicKey: String, version: Version = Version.V4 - ): Result { + ): OnionResponse { val url = request.url val payload = generatePayload(request, serverBaseUrl, version) @@ -117,57 +117,51 @@ class SessionNetwork @Inject constructor( snodeToExclude: Snode?, targetSnode: Snode?, publicKey: String? - ): Result { + ): OnionResponse { var lastError: Throwable? = null for (attempt in 1..maxAttempts) { - val path: Path = try { - pathManager.getPath(exclude = snodeToExclude) - } catch (t: Throwable) { - return Result.failure(t) - } - - val result = transport.send( - path = path, - destination = destination, - payload = payload, - version = version - ) + val path: Path = pathManager.getPath(exclude = snodeToExclude) - if (result.isSuccess) return result - - val throwable = result.exceptionOrNull() - ?: IllegalStateException("Unknown onion transport error") + try { + val result = transport.send( + path = path, + destination = destination, + payload = payload, + version = version + ) - val onionError = throwable as? OnionError - ?: return Result.failure(throwable) + return result + } catch (e: Throwable) { + val onionError = e as? OnionError ?: OnionError.Unknown(e) - Log.w("Onion", "Onion error on attempt $attempt/$maxAttempts: $onionError") + Log.w("Onion", "Onion error on attempt $attempt/$maxAttempts: $onionError") - lastError = onionError + lastError = onionError - // Delegate all handling + retry decision - val decision = errorManager.onFailure( - error = onionError, - ctx = OnionFailureContext( - path = path, - destination = destination, - targetSnode = targetSnode, - publicKey = publicKey + // Delegate all handling + retry decision + val decision = errorManager.onFailure( + error = onionError, + ctx = OnionFailureContext( + path = path, + destination = destination, + targetSnode = targetSnode, + publicKey = publicKey + ) ) - ) - when (decision) { - is FailureDecision.Fail -> return Result.failure(decision.throwable) - FailureDecision.Retry -> { - if (attempt >= maxAttempts) break - delay(computeBackoffDelayMs(attempt)) - continue + when (decision) { + is FailureDecision.Fail -> throw decision.throwable + FailureDecision.Retry -> { + if (attempt >= maxAttempts) break + delay(computeBackoffDelayMs(attempt)) + continue + } } } } - return Result.failure(lastError ?: IllegalStateException("Unknown onion error")) + throw lastError ?: IllegalStateException("Unknown onion error") } private fun computeBackoffDelayMs(attempt: Int): Long { diff --git a/app/src/main/java/org/session/libsession/network/onion/OnionTransport.kt b/app/src/main/java/org/session/libsession/network/onion/OnionTransport.kt index 3a68f16291..7fc2d9d634 100644 --- a/app/src/main/java/org/session/libsession/network/onion/OnionTransport.kt +++ b/app/src/main/java/org/session/libsession/network/onion/OnionTransport.kt @@ -8,15 +8,13 @@ interface OnionTransport { /** * Sends an onion request over one path. * - * @return Result.success(response) on success - * Result.failure(OnionError) on onion/path/guard/destination error */ suspend fun send( path: List, destination: OnionDestination, payload: ByteArray, version: Version - ): Result + ): OnionResponse } enum class Version(val value: String) { diff --git a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt index 4fabe9d4e4..31f1194577 100644 --- a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt +++ b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt @@ -26,7 +26,7 @@ class HttpOnionTransport @Inject constructor() : OnionTransport { destination: OnionDestination, payload: ByteArray, version: Version - ): Result { + ): OnionResponse { require(path.isNotEmpty()) { "Path must not be empty" } val guard = path.first() @@ -34,7 +34,7 @@ class HttpOnionTransport @Inject constructor() : OnionTransport { val built = try { OnionBuilder.build(path, destination, payload, version) } catch (t: Throwable) { - return Result.failure(OnionError.Unknown(t)) + throw OnionError.Unknown(t) } val url = "${guard.address}:${guard.port}/onion_req/v2" @@ -49,17 +49,17 @@ class HttpOnionTransport @Inject constructor() : OnionTransport { json = params ) } catch (t: Throwable) { - return Result.failure(OnionError.Unknown(t)) + throw OnionError.Unknown(t) } val responseBytes: ByteArray = try { HTTP.execute(HTTP.Verb.POST, url, body) } catch (httpEx: HTTP.HTTPRequestFailedException) { // HTTP error from guard (we never got an onion-level response) - return Result.failure(mapPathHttpError(guard, httpEx)) + throw mapPathHttpError(guard, httpEx) } catch (t: Throwable) { // TCP / DNS / TLS / timeout etc. reaching guard - return Result.failure(OnionError.GuardUnreachable(guard, t)) + throw OnionError.GuardUnreachable(guard, t) } // We have an onion-level response from the guard; decrypt & interpret @@ -109,12 +109,12 @@ class HttpOnionTransport @Inject constructor() : OnionTransport { destinationSymmetricKey: ByteArray, destination: OnionDestination, version: Version - ): Result { + ): OnionResponse { return when (version) { Version.V4 -> handleV4Response(rawResponse, destinationSymmetricKey, destination) Version.V2, Version.V3 -> { //todo ONION add support for v2/v3 - Result.failure(OnionError.Unknown(UnsupportedOperationException("Need to implement v2/v3"))) + throw OnionError.Unknown(UnsupportedOperationException("Need to implement v2/v3")) } } } @@ -123,28 +123,28 @@ class HttpOnionTransport @Inject constructor() : OnionTransport { response: ByteArray, destinationSymmetricKey: ByteArray, destination: OnionDestination - ): Result { + ): OnionResponse { try { if (response.size <= AESGCM.ivSize) { - return Result.failure(OnionError.InvalidResponse()) + throw OnionError.InvalidResponse() } val decrypted = AESGCM.decrypt(response, symmetricKey = destinationSymmetricKey) if (decrypted.isEmpty() || decrypted[0] != 'l'.code.toByte()) { - return Result.failure(OnionError.InvalidResponse()) + throw OnionError.InvalidResponse() } val infoSepIdx = decrypted.indexOfFirst { it == ':'.code.toByte() } - if (infoSepIdx <= 1) return Result.failure(OnionError.InvalidResponse()) + if (infoSepIdx <= 1) throw OnionError.InvalidResponse() val infoLenSlice = decrypted.slice(1 until infoSepIdx) val infoLength = infoLenSlice.toByteArray().toString(Charsets.US_ASCII).toIntOrNull() - ?: return Result.failure(OnionError.InvalidResponse()) + ?: throw OnionError.InvalidResponse() val infoStartIndex = "l$infoLength".length + 1 val infoEndIndex = infoStartIndex + infoLength - if (infoEndIndex > decrypted.size) return Result.failure(OnionError.InvalidResponse()) + if (infoEndIndex > decrypted.size) throw OnionError.InvalidResponse() val infoSlice = decrypted.view(infoStartIndex until infoEndIndex) val responseInfo = JsonUtil.fromJson(infoSlice, Map::class.java) as Map<*, *> @@ -153,12 +153,13 @@ class HttpOnionTransport @Inject constructor() : OnionTransport { if (statusCode !in 200..299) { // Optional "body" part for some server errors (notably 400) + //todo ONION should we ALWAYS attach the body so that no specific error rules are defined here and/or in case future rules change and the body is needed elsewhere? val bodySlice = if (destination is OnionDestination.ServerDestination && statusCode == 400) { decrypted.getBody(infoLength, infoEndIndex) } else null - return Result.failure( + throw OnionError.DestinationError( status = ErrorStatus( code = statusCode, @@ -166,17 +167,18 @@ class HttpOnionTransport @Inject constructor() : OnionTransport { body = bodySlice ) ) - ) } val responseBody = decrypted.getBody(infoLength, infoEndIndex) return if (responseBody.isEmpty()) { - Result.success(OnionResponse(info = responseInfo, body = null)) + OnionResponse(info = responseInfo, body = null) } else { - Result.success(OnionResponse(info = responseInfo, body = responseBody)) + OnionResponse(info = responseInfo, body = responseBody) } + } catch (e: OnionError) { + throw e } catch (t: Throwable) { - return Result.failure(OnionError.InvalidResponse(t)) + throw OnionError.InvalidResponse(t) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt index 09ff1eec29..c82cdfd2b0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt @@ -43,7 +43,6 @@ import org.conscrypt.Conscrypt import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration.Companion.configure import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier -import org.session.libsession.snode.SnodeModule import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences.Companion.pushSuffix @@ -86,7 +85,6 @@ import kotlin.concurrent.Volatile class ApplicationContext : Application(), DefaultLifecycleObserver, Configuration.Provider, SingletonImageLoader.Factory { @Inject lateinit var messagingModuleConfiguration: Lazy @Inject lateinit var workerFactory: Lazy - @Inject lateinit var snodeModule: Lazy @Inject lateinit var sskEnvironment: Lazy @Inject lateinit var startupComponents: Lazy @@ -146,7 +144,6 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, Configuratio NotificationChannels.create(this) ProcessLifecycleOwner.get().lifecycle.addObserver(this) configureKovenant() - SnodeModule.sharedLazy = snodeModule SSKEnvironment.sharedLazy = sskEnvironment initializeWebRtc() From d9ff02488151d6e9bc79a8893e30e3b10509a0a1 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 18 Dec 2025 17:00:13 +1100 Subject: [PATCH 29/77] Removing OnionRequestAPI usage --- .../messaging/MessagingModuleConfiguration.kt | 4 +- .../messaging/file_server/FileServerApi.kt | 1 + .../messaging/jobs/AttachmentDownloadJob.kt | 4 +- .../messaging/jobs/NotifyPNServerJob.kt | 50 ++++++++++------ .../notifications/PushRegistryV1.kt | 59 ++++++++++--------- .../libsession/network/SessionNetwork.kt | 7 +-- .../org/session/libsignal/utilities/HTTP.kt | 2 +- .../attachments/AvatarDownloadManager.kt | 4 +- .../attachments/AvatarReuploadWorker.kt | 4 +- .../securesms/database/Storage.kt | 6 +- .../securesms/home/HomeActivity.kt | 10 ++-- .../securesms/home/PathActivity.kt | 15 +++-- .../securesms/notifications/PushRegistryV2.kt | 12 ++-- .../securesms/preferences/SettingsScreen.kt | 10 ++-- .../securesms/pro/ProProofGenerationWorker.kt | 4 +- .../securesms/pro/api/ProApiExecutor.kt | 10 ++-- .../securesms/tokenpage/TokenRepository.kt | 10 ++-- .../thoughtcrime/securesms/util/IP2Country.kt | 7 ++- 18 files changed, 116 insertions(+), 103 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt b/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt index bd827eeb2e..69636c30fd 100644 --- a/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt +++ b/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt @@ -11,6 +11,7 @@ import org.session.libsession.messaging.jobs.MessageSendJob import org.session.libsession.messaging.notifications.TokenFetcher import org.session.libsession.network.SessionNetwork import org.session.libsession.network.SnodeClock +import org.session.libsession.network.onion.PathManager import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.Device import org.session.libsession.utilities.TextSecurePreferences @@ -37,7 +38,8 @@ class MessagingModuleConfiguration @Inject constructor( val messageSendJobFactory: MessageSendJob.Factory, val json: Json, val snodeClock: SnodeClock, - val sessionNetwork: SessionNetwork + val sessionNetwork: SessionNetwork, + val pathManager: PathManager ) { companion object { diff --git a/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt b/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt index 7958b8a427..ed7e4e7f18 100644 --- a/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt +++ b/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt @@ -103,6 +103,7 @@ class FileServerApi @Inject constructor( ) ) + //todo ONION in the new architecture, an Onionresponse should always be in 200..299, otherwise an OnionError is thrown check(response.code in 200..299) { "Error response from file server: ${response.code}" } diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt index 46f473e210..932ae81e0a 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt @@ -13,7 +13,7 @@ import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.utilities.Data -import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.network.model.OnionError import org.session.libsession.utilities.Address import org.session.libsession.utilities.DecodedAudio import org.session.libsession.utilities.InputStreamMediaDataSource @@ -94,7 +94,7 @@ class AttachmentDownloadJob @AssistedInject constructor( } else if (exception == Error.NoAttachment || exception == Error.NoThread || exception == Error.NoSender - || (exception is OnionRequestAPI.HTTPRequestFailedAtDestinationException && exception.statusCode == 400) + || (exception is OnionError.DestinationError && exception.status?.code == 400) //todo ONION this is matching old behaviour. Do we want this kind of error handling here? || exception is NonRetryableException) { attachment?.let { id -> Log.d("AttachmentDownloadJob", "Setting attachment state = failed, have attachment") diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt index 62e2fb94bb..1fa4521e60 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt @@ -6,15 +6,17 @@ import com.esotericsoftware.kryo.io.Output import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody +import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.jobs.Job.Companion.MAX_BUFFER_SIZE_BYTES import org.session.libsession.messaging.sending_receiving.notifications.Server import org.session.libsession.messaging.utilities.Data -import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.network.SessionNetwork +import org.session.libsession.network.onion.Version import org.session.libsession.snode.SnodeMessage -import org.session.libsession.snode.Version import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.retryIfNeeded +import org.session.libsignal.utilities.retryWithUniformInterval +import kotlin.coroutines.cancellation.CancellationException class NotifyPNServerJob(val message: SnodeMessage) : Job { override var delegate: JobDelegate? = null @@ -22,6 +24,11 @@ class NotifyPNServerJob(val message: SnodeMessage) : Job { override var failureCount: Int = 0 override val maxFailureCount: Int = 20 + + private val sessionNetwork: SessionNetwork by lazy { + MessagingModuleConfiguration.shared.sessionNetwork + } + companion object { val KEY: String = "NotifyPNServerJob" @@ -31,27 +38,32 @@ class NotifyPNServerJob(val message: SnodeMessage) : Job { override suspend fun execute(dispatcherName: String) { val server = Server.LEGACY - val parameters = mapOf( "data" to message.data, "send_to" to message.recipient ) + val parameters = mapOf("data" to message.data, "send_to" to message.recipient) val url = "${server.url}/notify" val body = RequestBody.create("application/json".toMediaType(), JsonUtil.toJson(parameters)) val request = Request.Builder().url(url).post(body).build() - retryIfNeeded(4) { - OnionRequestAPI.sendOnionRequest( - request, - server.url, - server.publicKey, - Version.V2 - ) success { response -> - when (response.code) { - null, 0 -> Log.d("NotifyPNServerJob", "Couldn't notify PN server due to error: ${response.message}.") - } - } fail { exception -> - Log.d("NotifyPNServerJob", "Couldn't notify PN server due to error: $exception.") + + try { + // High-level application retry (4 attempts) + retryWithUniformInterval(maxRetryCount = 4) { + sessionNetwork.sendToServer( + request = request, + serverBaseUrl = server.url, + x25519PublicKey = server.publicKey, + version = Version.V2 + ) + + // todo ONION the old code was checking the status code on success and if it is null or 0 it would log it as a fail + // the new structure however throws all non 200.299 status as an OnionError } - } success { + handleSuccess(dispatcherName) - } fail { - handleFailure(dispatcherName, it) + + } catch (e: Exception) { + if (e is CancellationException) throw e + + Log.d("NotifyPNServerJob", "Couldn't notify PN server due to error: $e.") + handleFailure(dispatcherName, e) } } diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt index 4a0509049f..56c6627d94 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt @@ -3,18 +3,16 @@ package org.session.libsession.messaging.sending_receiving.notifications import android.annotation.SuppressLint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import nl.komponents.kovenant.Promise import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.snode.OnionRequestAPI -import org.session.libsession.snode.OnionResponse -import org.session.libsession.snode.Version -import org.session.libsession.snode.utilities.asyncPromise -import org.session.libsession.snode.utilities.await +import org.session.libsession.network.onion.Version import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.JsonUtil +import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.emptyPromise import org.session.libsignal.utilities.retryWithUniformInterval @@ -34,45 +32,48 @@ object PushRegistryV1 { closedGroupSessionId: String, isPushEnabled: Boolean = TextSecurePreferences.isPushEnabled(context), publicKey: String = MessagingModuleConfiguration.shared.storage.getUserPublicKey()!! - ) = if (isPushEnabled) { - performGroupOperation("subscribe_closed_group", closedGroupSessionId, publicKey) - } else emptyPromise() + ) { + if (!isPushEnabled) return + scope.launch { + performGroupOperation("subscribe_closed_group", closedGroupSessionId, publicKey) + } + } fun unsubscribeGroup( closedGroupPublicKey: String, isPushEnabled: Boolean = TextSecurePreferences.isPushEnabled(context), publicKey: String = MessagingModuleConfiguration.shared.storage.getUserPublicKey()!! - ) = if (isPushEnabled) { - performGroupOperation("unsubscribe_closed_group", closedGroupPublicKey, publicKey) - } else emptyPromise() + ) { + if (!isPushEnabled) return + scope.launch { + performGroupOperation("unsubscribe_closed_group", closedGroupPublicKey, publicKey) + } + } - private fun performGroupOperation( + private suspend fun performGroupOperation( operation: String, closedGroupPublicKey: String, publicKey: String - ): Promise<*, Exception> = scope.asyncPromise { + ) { val parameters = mapOf("closedGroupPublicKey" to closedGroupPublicKey, "pubKey" to publicKey) val url = "${server.url}/$operation" val body = JsonUtil.toJson(parameters).toRequestBody("application/json".toMediaType()) val request = Request.Builder().url(url).post(body).build() - retryWithUniformInterval(MAX_RETRY_COUNT) { - sendOnionRequest(request) - .await() - .checkError() - } - } + try { + retryWithUniformInterval(MAX_RETRY_COUNT) { + MessagingModuleConfiguration.shared.sessionNetwork.sendToServer( + request = request, + serverBaseUrl = server.url, + x25519PublicKey = server.publicKey, + version = Version.V2 + ) - private fun OnionResponse.checkError() { - check(code != null && code != 0) { - "error: $message." + // todo ONION the old code was checking the status code on success and if it is null or 0 it would log it as a fail + // the new structure however throws all non 200.299 status as an OnionError + } + } catch (e: Exception) { + Log.w("PushRegistryV1", "Failed to perform group operation ($operation): $e") } } - - private fun sendOnionRequest(request: Request): Promise = OnionRequestAPI.sendOnionRequest( - request, - server.url, - server.publicKey, - Version.V2 - ) } diff --git a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt index f78748d9b8..921159e715 100644 --- a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt +++ b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt @@ -49,9 +49,7 @@ class SessionNetwork @Inject constructor( // Is there a better way to discern the two? /** - * Send an onion request to a *service node* (RPC). - * - * @param publicKey Optional: used by OnionErrorManager for swarm-specific handling (e.g. 421). + * Send an onion request to a *service node*. */ suspend fun sendToSnode( method: Snode.Method, @@ -172,9 +170,6 @@ class SessionNetwork @Inject constructor( return capped + jitter } - /** - * Equivalent to old generatePayload() from OnionRequestAPI. - */ private fun generatePayload(request: Request, server: String, version: Version): ByteArray { val headers = request.getHeadersForOnionRequest().toMutableMap() val url = request.url diff --git a/app/src/main/java/org/session/libsignal/utilities/HTTP.kt b/app/src/main/java/org/session/libsignal/utilities/HTTP.kt index 2ffd693c14..42e0462714 100644 --- a/app/src/main/java/org/session/libsignal/utilities/HTTP.kt +++ b/app/src/main/java/org/session/libsignal/utilities/HTTP.kt @@ -148,7 +148,7 @@ object HTTP { if (exception !is HTTPRequestFailedException) { - // Override the actual error so that we can correctly catch failed requests in OnionRequestAPI + // Override the actual error so that we can correctly catch failed requests in networking layer throw HTTPRequestFailedException( statusCode = 0, message = "HTTP request failed due to: ${exception.message}" diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarDownloadManager.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarDownloadManager.kt index a84babfa93..75e50394da 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarDownloadManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarDownloadManager.kt @@ -10,7 +10,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrl import org.session.libsession.avatars.AvatarHelper import org.session.libsession.messaging.file_server.FileServerApi import org.session.libsession.messaging.open_groups.OpenGroupApi -import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.network.model.OnionError import org.session.libsession.utilities.AESGCM import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress @@ -114,7 +114,7 @@ class AvatarDownloadManager @Inject constructor( downloadAndDecryptFile(file) } catch (e: Exception) { if (e.getRootCause() != null || - e.getRootCause()?.statusCode == 404 + e.getRootCause()?.status?.code == 404 //todo ONION does this check still work in the current setup ) { Log.w(TAG, "Download failed permanently for file $file", e) // Write an empty file with a permanent error metadata if the download failed permanently. diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt index 5eb2b07ddb..7f70b27bda 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt @@ -22,7 +22,7 @@ import okio.BufferedSource import okio.buffer import okio.source import org.session.libsession.messaging.file_server.FileServerApi -import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.network.model.OnionError import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.RemoteFile.Companion.toRemoteFile @@ -134,7 +134,7 @@ class AvatarReuploadWorker @AssistedInject constructor( // When renew fails, we will try to re-upload the avatar if: // 1. The file is expired (we have the record of this file's expiry time), or // 2. The last update was more than 12 days ago. - if ((e is NonRetryableException || e is OnionRequestAPI.HTTPRequestFailedAtDestinationException)) { + if ((e is NonRetryableException || e is OnionError.DestinationError)) { //todo ONION does this check still work in the current setup val now = Instant.now() if (fileExpiry?.isBefore(now) == true || (lastUpdated?.isBefore(now.minus(Duration.ofDays(12)))) == true) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index 23e5448aa6..6ffb1e4739 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -43,8 +43,8 @@ import org.session.libsession.messaging.sending_receiving.link_preview.LinkPrevi import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel import org.session.libsession.messaging.utilities.UpdateMessageData -import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.network.SnodeClock +import org.session.libsession.network.model.OnionError import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.Address.Companion.toAddress @@ -589,7 +589,7 @@ open class Storage @Inject constructor( } if (error.localizedMessage != null) { val message: String - if (error is OnionRequestAPI.HTTPRequestFailedAtDestinationException && error.statusCode == 429) { + if (error is OnionError.DestinationError && error.status?.code == 429) { message = "429: Rate limited." } else { message = error.localizedMessage!! @@ -605,7 +605,7 @@ open class Storage @Inject constructor( if (error.localizedMessage != null) { val message: String - if (error is OnionRequestAPI.HTTPRequestFailedAtDestinationException && error.statusCode == 429) { + if (error is OnionError.DestinationError && error.status?.code == 429) { message = "429: Rate limited." } else { message = error.localizedMessage!! diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 4dc27ca65a..154c4aef6f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -48,8 +48,9 @@ import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier -import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.network.SnodeClock +import org.session.libsession.network.model.PathStatus +import org.session.libsession.network.onion.PathManager import org.session.libsession.utilities.Address import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY @@ -144,6 +145,7 @@ class HomeActivity : ScreenLockActionBarActivity(), @Inject lateinit var avatarUtils: AvatarUtils @Inject lateinit var loginStateRepository: LoginStateRepository @Inject lateinit var messageFormatter: MessageFormatter + @Inject lateinit var pathManager: PathManager private val globalSearchViewModel by viewModels() private val homeViewModel by viewModels() @@ -232,7 +234,7 @@ class HomeActivity : ScreenLockActionBarActivity(), val recipient by recipientRepository.observeSelf() .collectAsState(null) - val pathStatus by OnionRequestAPI.pathStatus.collectAsState() + val pathStatus by pathManager.status.collectAsState() Avatar( size = LocalDimensions.current.iconMediumAvatar, @@ -247,8 +249,8 @@ class HomeActivity : ScreenLockActionBarActivity(), val glowSize = LocalDimensions.current.xxxsSpacing Crossfade( targetState = when (pathStatus){ - OnionRequestAPI.PathStatus.BUILDING -> LocalColors.current.warning - OnionRequestAPI.PathStatus.ERROR -> LocalColors.current.danger + PathStatus.BUILDING -> LocalColors.current.warning + PathStatus.ERROR -> LocalColors.current.danger else -> primaryGreen }, label = "path") { PathDot( diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt index 600f6e8072..d53d4163ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt @@ -1,8 +1,6 @@ package org.thoughtcrime.securesms.home import android.content.Context -import android.content.Intent -import android.net.Uri import android.os.Bundle import android.util.AttributeSet import android.util.TypedValue @@ -12,7 +10,6 @@ import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.RelativeLayout import android.widget.TextView -import android.widget.Toast import androidx.annotation.ColorRes import androidx.core.content.ContextCompat import androidx.core.view.doOnLayout @@ -32,7 +29,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.databinding.ActivityPathBinding -import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.network.onion.PathManager import org.session.libsession.utilities.NonTranslatableStringConstants.APP_NAME import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.getColorFromAttr @@ -60,6 +57,9 @@ class PathActivity : ScreenLockActionBarActivity() { @Inject lateinit var inAppReviewManager: InAppReviewManager + @Inject + lateinit var pathManager: PathManager + // region Lifecycle override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) @@ -83,7 +83,7 @@ class PathActivity : ScreenLockActionBarActivity() { lifecycleScope.launch { // Check if the repeatOnLifecycle(Lifecycle.State.STARTED) { - OnionRequestAPI.paths + pathManager.paths .map { it.isEmpty() } .distinctUntilChanged() .collectLatest { @@ -127,13 +127,12 @@ class PathActivity : ScreenLockActionBarActivity() { private fun update(isAnimated: Boolean) { binding.pathRowsContainer.removeAllViews() - val paths = OnionRequestAPI.paths.value + val paths = pathManager.paths.value if (paths.isNotEmpty()) { val path = paths.firstOrNull() ?: return finish() val dotAnimationRepeatInterval = path.count().toLong() * 1000 + 1000 val pathRows = path.mapIndexed { index, snode -> - val isGuardSnode = (OnionRequestAPI.guardSnodes.contains(snode)) - getPathRow(snode, LineView.Location.Middle, index.toLong() * 1000 + 2000, dotAnimationRepeatInterval, isGuardSnode) + getPathRow(snode, LineView.Location.Middle, index.toLong() * 1000 + 2000, dotAnimationRepeatInterval, index == 0) //todo ONION verify this change works - old code was checking node against the set of guard snodes in the onionreqest api } val youRow = getPathRow(resources.getString(R.string.you), null, LineView.Location.Top, 1000, dotAnimationRepeatInterval) val destinationRow = getPathRow(resources.getString(R.string.onionRoutingPathDestination), null, LineView.Location.Bottom, path.count().toLong() * 1000 + 2000, dotAnimationRepeatInterval) diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt index fdf9dfca90..77bae8744c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt @@ -17,11 +17,10 @@ import org.session.libsession.messaging.sending_receiving.notifications.Subscrip import org.session.libsession.messaging.sending_receiving.notifications.SubscriptionResponse import org.session.libsession.messaging.sending_receiving.notifications.UnsubscribeResponse import org.session.libsession.messaging.sending_receiving.notifications.UnsubscriptionRequest -import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.network.SessionNetwork import org.session.libsession.network.SnodeClock +import org.session.libsession.network.onion.Version import org.session.libsession.snode.SwarmAuth -import org.session.libsession.snode.Version -import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Device import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.auth.LoginStateRepository @@ -36,6 +35,7 @@ class PushRegistryV2 @Inject constructor( private val device: Device, private val clock: SnodeClock, private val loginStateRepository: LoginStateRepository, + private val sessionNetwork: SessionNetwork ) { suspend fun register( @@ -114,12 +114,12 @@ class PushRegistryV2 @Inject constructor( val url = "${server.url}/$path" val body = requestParameters.toRequestBody("application/json".toMediaType()) val request = Request.Builder().url(url).post(body).build() - val response = OnionRequestAPI.sendOnionRequest( + val response = sessionNetwork.sendToServer( request = request, - server = server.url, + serverBaseUrl = server.url, x25519PublicKey = server.publicKey, version = Version.V4 - ).await() + ) return withContext(Dispatchers.IO) { requireNotNull(response.body) { "Response doesn't have a body" } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt index 5d29d66d54..3c4285a069 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt @@ -65,7 +65,7 @@ import com.bumptech.glide.integration.compose.GlideImage import com.squareup.phrase.Phrase import network.loki.messenger.BuildConfig import network.loki.messenger.R -import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.network.model.PathStatus import org.session.libsession.utilities.NonTranslatableStringConstants import org.session.libsession.utilities.NonTranslatableStringConstants.NETWORK_NAME import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY @@ -498,7 +498,7 @@ fun Settings( @Composable fun Buttons( recoveryHidden: Boolean, - pathStatus: OnionRequestAPI.PathStatus, + pathStatus: PathStatus, postPro: Boolean, proDataState: ProDataState, sendCommand: (SettingsViewModel.Commands) -> Unit, @@ -610,8 +610,8 @@ fun Buttons( Divider() Crossfade(when (pathStatus){ - OnionRequestAPI.PathStatus.BUILDING -> LocalColors.current.warning - OnionRequestAPI.PathStatus.ERROR -> LocalColors.current.danger + PathStatus.BUILDING -> LocalColors.current.warning + PathStatus.ERROR -> LocalColors.current.danger else -> primaryGreen }, label = "path") { ItemButton( @@ -1094,7 +1094,7 @@ private fun SettingsScreenPreview() { ), username = "Atreyu", accountID = "053d30141d0d35d9c4b30a8f8880f8464e221ee71a8aff9f0dcefb1e60145cea5144", - pathStatus = OnionRequestAPI.PathStatus.READY, + pathStatus = PathStatus.READY, version = "1.26.0", ), sendCommand = {}, diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt index 0c680b2be1..7072908a8c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt @@ -16,7 +16,7 @@ import dagger.assisted.AssistedInject import kotlinx.coroutines.CancellationException import network.loki.messenger.libsession_util.ED25519 import network.loki.messenger.libsession_util.pro.ProConfig -import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.network.model.OnionError import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.withMutableUserConfigs import org.session.libsignal.exceptions.NonRetryableException @@ -85,7 +85,7 @@ class ProProofGenerationWorker @AssistedInject constructor( Log.e(WORK_NAME, "Error generating Pro proof", e) if (e is NonRetryableException || // HTTP 403 indicates that the user is not - e.getRootCause()?.statusCode == 403) { + e.getRootCause()?.status?.code == 403) { //todo ONION verify this works within the new system Result.failure() } else { Result.retry() diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApiExecutor.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApiExecutor.kt index 6f28d3be8b..c74f5ce94b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApiExecutor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApiExecutor.kt @@ -9,8 +9,7 @@ import kotlinx.serialization.json.decodeFromStream import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody -import org.session.libsession.snode.OnionRequestAPI.sendOnionRequest -import org.session.libsession.snode.utilities.await +import org.session.libsession.network.SessionNetwork import org.thoughtcrime.securesms.pro.ProBackendConfig import javax.inject.Inject import javax.inject.Provider @@ -18,6 +17,7 @@ import javax.inject.Provider class ProApiExecutor @Inject constructor( private val json: Json, private val proConfigProvider: Provider, + private val sessionNetwork: SessionNetwork, ) { @Serializable private data class RawProApiResponse( @@ -57,7 +57,7 @@ class ProApiExecutor @Inject constructor( ): ProApiResponse { val config = proConfigProvider.get() - val rawResp = sendOnionRequest( + val rawResp = sessionNetwork.sendToServer( request = Request.Builder() .url(config.url.resolve(request.endpoint)!!) .post( @@ -66,9 +66,9 @@ class ProApiExecutor @Inject constructor( ) ) .build(), - server = config.url.host, + serverBaseUrl = config.url.host, x25519PublicKey = config.x25519PubKeyHex - ).await().body!!.inputStream().use { + ).body!!.inputStream().use { json.decodeFromStream(it) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenRepository.kt index 041e64a1cb..ed8a3a9fb4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenRepository.kt @@ -10,8 +10,7 @@ import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.file_server.FileServerApi -import org.session.libsession.snode.OnionRequestAPI -import org.session.libsession.snode.utilities.await +import org.session.libsession.network.SessionNetwork import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.toHexString @@ -28,6 +27,7 @@ class TokenRepositoryImpl @Inject constructor( @param:ApplicationContext val context: Context, private val storage: StorageProtocol, private val json: Json, + private val sessionNetwork: SessionNetwork ): TokenRepository { private val TAG = "TokenRepository" @@ -89,11 +89,11 @@ class TokenRepositoryImpl @Inject constructor( var response: T? = null try { - val rawResponse = OnionRequestAPI.sendOnionRequest( + val rawResponse = sessionNetwork.sendToServer( request = request, - server = TOKEN_SERVER_URL, // Note: The `request` contains the actual endpoint we'll hit + serverBaseUrl = TOKEN_SERVER_URL, // Note: The `request` contains the actual endpoint we'll hit x25519PublicKey = SERVER_PUBLIC_KEY - ).await() + ) val resultJsonString = rawResponse.body?.decodeToString() if (resultJsonString == null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt b/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt index e9362d9d75..9101f9b97d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt @@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch -import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsignal.utilities.Log import java.io.DataInputStream import java.io.InputStream @@ -73,8 +73,9 @@ class IP2Country internal constructor( if (isInitialized) { return; } shared = IP2Country(context.applicationContext) + //todo ONION we should look into injecting this class and optimising GlobalScope.launch { - OnionRequestAPI.paths + MessagingModuleConfiguration.shared.pathManager.paths .filter { it.isNotEmpty() } .collectLatest { shared.populateCacheIfNeeded() @@ -104,7 +105,7 @@ class IP2Country internal constructor( private fun populateCacheIfNeeded() { val start = System.currentTimeMillis() - OnionRequestAPI.paths.value.iterator().forEach { path -> + MessagingModuleConfiguration.shared.pathManager.paths.value.iterator().forEach { path -> path.iterator().forEach { snode -> cacheCountryForIP(snode.ip) // Preload if needed } From efddee8452e1538a69c1d65c3453a0ec3ada307a Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 18 Dec 2025 17:38:22 +1100 Subject: [PATCH 30/77] Fixing hilt dependencies --- .../messaging/open_groups/OpenGroupApi.kt | 22 +++++++++--------- .../libsession/network/SessionClient.kt | 8 +------ .../libsession/network/SessionNetwork.kt | 7 +++--- .../session/libsession/network/SnodeClock.kt | 5 ++-- .../libsession/network/onion/PathManager.kt | 4 ++-- .../network/snode/SwarmDirectory.kt | 23 ++++++++----------- 6 files changed, 30 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt index 3525f2d85c..fb9b294dbb 100644 --- a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt @@ -18,6 +18,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.network.model.OnionError import org.session.libsession.network.model.OnionResponse import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64.encodeBytes @@ -406,22 +407,21 @@ object OpenGroupApi { if (!request.room.isNullOrEmpty()) { requestBuilder.header("Room", request.room) } - if (request.useOnionRouting) { - val result = MessagingModuleConfiguration.shared.sessionNetwork.sendToServer( - requestBuilder.build(), - request.server, - serverPublicKey - ) - result.onFailure { e -> + if (request.useOnionRouting) { + try { + return MessagingModuleConfiguration.shared.sessionNetwork.sendToServer( + request = requestBuilder.build(), + serverBaseUrl = request.server, + x25519PublicKey = serverPublicKey + ) + } catch (e: Exception) { when (e) { - // No need for the stack trace for HTTP errors - is HTTP.HTTPRequestFailedException -> Log.e("SOGS", "Failed onion request: ${e.message}") + is OnionError -> Log.e("SOGS", "Failed onion request: ${e.message}") else -> Log.e("SOGS", "Failed onion request", e) } + throw e } - - return result.getOrThrow() } else { throw IllegalStateException("It's currently not allowed to send non onion routed requests.") } diff --git a/app/src/main/java/org/session/libsession/network/SessionClient.kt b/app/src/main/java/org/session/libsession/network/SessionClient.kt index 89e5cdb306..4ac73a9b2c 100644 --- a/app/src/main/java/org/session/libsession/network/SessionClient.kt +++ b/app/src/main/java/org/session/libsession/network/SessionClient.kt @@ -161,7 +161,7 @@ class SessionClient @Inject constructor( publicKey: String? = null, version: Version = Version.V4 ): ByteArraySlice { - val result = sessionNetwork.sendToSnode( + val onionResponse = sessionNetwork.sendToSnode( method = method, parameters = parameters, snode = snode, @@ -169,12 +169,6 @@ class SessionClient @Inject constructor( version = version ) - if (result.isFailure) { - throw result.exceptionOrNull() - ?: Error.Generic("Unknown error invoking $method on $snode") - } - - val onionResponse = result.getOrThrow() return onionResponse.body ?: throw Error.Generic("Empty body from snode for method $method") } diff --git a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt index 921159e715..1fbab27997 100644 --- a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt +++ b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt @@ -39,15 +39,16 @@ class SessionNetwork @Inject constructor( private val pathManager: PathManager, private val transport: OnionTransport, private val errorManager: OnionErrorManager, - private val maxAttempts: Int = 2, - private val baseRetryDelayMs: Long = 250L, - private val maxRetryDelayMs: Long = 2_000L ) { //todo ONION we now have a few places in the app calling SesisonNetwork directly to use // sendToSnode or sendToServer. Should this be abstracted away in sessionClient instead? // Is there a better way to discern the two? + private val maxAttempts: Int = 2 + private val baseRetryDelayMs: Long = 250L + private val maxRetryDelayMs: Long = 2_000L + /** * Send an onion request to a *service node*. */ diff --git a/app/src/main/java/org/session/libsession/network/SnodeClock.kt b/app/src/main/java/org/session/libsession/network/SnodeClock.kt index ed0a9e6d73..1f7dd9c626 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClock.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClock.kt @@ -1,6 +1,7 @@ package org.session.libsession.network import android.os.SystemClock +import dagger.Lazy import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -28,7 +29,7 @@ import javax.inject.Singleton class SnodeClock @Inject constructor( @param:ManagerScope private val scope: CoroutineScope, private val snodeDirectory: SnodeDirectory, - private val sessionClient: SessionClient + private val sessionClient: Lazy, ) : OnAppStartupComponent { //todo ONION we have a lot of calls to MessagingModuleConfiguration.shared.snodeClock.currentTimeMills() @@ -46,7 +47,7 @@ class SnodeClock @Inject constructor( val node = snodeDirectory.getRandomSnode() val requestStarted = SystemClock.elapsedRealtime() - var networkTime = sessionClient.getNetworkTime(node).second + var networkTime = sessionClient.get().getNetworkTime(node).second val requestEnded = SystemClock.elapsedRealtime() // Adjust network time to halfway through the request duration diff --git a/app/src/main/java/org/session/libsession/network/onion/PathManager.kt b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt index 7290314265..bdb97ec84c 100644 --- a/app/src/main/java/org/session/libsession/network/onion/PathManager.kt +++ b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt @@ -30,9 +30,9 @@ class PathManager @Inject constructor( private val scope: CoroutineScope, private val directory: SnodeDirectory, private val storage: SnodePathStorage, - private val pathSize: Int = 3, - private val targetPathCount: Int = 2, ) { + private val pathSize: Int = 3 + private val targetPathCount: Int = 2 private val _paths = MutableStateFlow( sanitizePaths(storage.getOnionRequestPaths()) diff --git a/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt b/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt index d37e94ddc2..48caa1faf5 100644 --- a/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt +++ b/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt @@ -1,5 +1,6 @@ package org.session.libsession.network.snode +import dagger.Lazy import org.session.libsession.network.SessionNetwork import org.session.libsession.network.onion.Version import org.session.libsignal.crypto.shuffledRandom @@ -13,9 +14,9 @@ import javax.inject.Singleton class SwarmDirectory @Inject constructor( private val storage: SwarmStorage, private val snodeDirectory: SnodeDirectory, - private val sessionNetwork: SessionNetwork, - private val minimumSwarmSize: Int = 3 + private val sessionNetwork: Lazy, ) { + private val minimumSwarmSize: Int = 3 suspend fun getSwarm(publicKey: String): Set { val cached = storage.getSwarm(publicKey) @@ -35,22 +36,16 @@ class SwarmDirectory @Inject constructor( } val randomSnode = pool.random() - val params = mapOf("pubKey" to publicKey) - val result = sessionNetwork.sendToSnode( - method = Snode.Method.GetSwarm, - parameters = params, - snode = randomSnode, - version = Version.V4 + val response = sessionNetwork.get().sendToSnode( + method = Snode.Method.GetSwarm, + parameters = params, + snode = randomSnode, + version = Version.V4 ) - if (result.isFailure) { - throw result.exceptionOrNull() ?: IllegalStateException("Unknown swarm error") - } - - val onionResponse = result.getOrThrow() - val body = onionResponse.body ?: error("Empty GetSwarm body") + val body = response.body ?: error("Empty GetSwarm body") val json = JsonUtil.fromJson(body, Map::class.java) as Map<*, *> return parseSnodes(json).toSet() From a697d2e848f3440133511fb09e9e19a000c47592 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 19 Dec 2025 10:37:51 +1100 Subject: [PATCH 31/77] Using V3 where needed --- .../libsession/network/SessionClient.kt | 26 +--- .../libsession/network/SessionNetwork.kt | 5 +- .../network/onion/OnionErrorManager.kt | 2 +- .../libsession/network/onion/PathManager.kt | 4 +- .../network/onion/http/HttpOnionTransport.kt | 131 +++++++++++++++++- .../network/snode/SnodeDirectory.kt | 13 +- .../libsession/network/snode/SnodeStorage.kt | 12 ++ .../network/snode/SwarmDirectory.kt | 1 - 8 files changed, 160 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/SessionClient.kt b/app/src/main/java/org/session/libsession/network/SessionClient.kt index 4ac73a9b2c..1af02609d4 100644 --- a/app/src/main/java/org/session/libsession/network/SessionClient.kt +++ b/app/src/main/java/org/session/libsession/network/SessionClient.kt @@ -111,7 +111,6 @@ class SessionClient @Inject constructor( publicKey = pubKey, requests = batch.map { it.request }, sequence = sequence, - version = version ) } catch (e: Throwable) { for (req in batch) runCatching { req.callback.send(Result.failure(e)) } @@ -159,7 +158,7 @@ class SessionClient @Inject constructor( snode: Snode, parameters: Map, publicKey: String? = null, - version: Version = Version.V4 + version: Version = Version.V3 ): ByteArraySlice { val onionResponse = sessionNetwork.sendToSnode( method = method, @@ -180,7 +179,7 @@ class SessionClient @Inject constructor( parameters: Map, responseDeserializationStrategy: DeserializationStrategy, publicKey: String? = null, - version: Version = Version.V4 + version: Version = Version.V3 ): Res { val body = invokeRaw( method = method, @@ -203,7 +202,7 @@ class SessionClient @Inject constructor( snode: Snode, parameters: Map, publicKey: String? = null, - version: Version = Version.V4 + version: Version = Version.V3 ): Map<*, *> { val body = invokeRaw( method = method, @@ -229,7 +228,6 @@ class SessionClient @Inject constructor( message: SnodeMessage, auth: SwarmAuth?, namespace: Int = 0, - version: Version = Version.V4 ): StoreMessageResponse { val params: Map = if (auth != null) { check(auth.accountId.hexString == message.recipient) { @@ -266,7 +264,6 @@ class SessionClient @Inject constructor( ), responseType = StoreMessageResponse.serializer(), sequence = false, - version = version ) } @@ -275,7 +272,6 @@ class SessionClient @Inject constructor( publicKey: String, swarmAuth: SwarmAuth, serverHashes: List, - version: Version = Version.V4 ): Map<*, *> { val snode = swarmDirectory.getSingleTargetSnode(publicKey) @@ -297,7 +293,6 @@ class SessionClient @Inject constructor( snode = snode, parameters = params, publicKey = publicKey, - version = version ) val swarms = rawResponse["swarm"] as? Map ?: throw Error.Generic("Missing swarm in delete response") @@ -343,7 +338,6 @@ class SessionClient @Inject constructor( suspend fun deleteAllMessages( auth: SwarmAuth, - version: Version = Version.V4 ): Map { val publicKey = auth.accountId.hexString val snode = swarmDirectory.getSingleTargetSnode(publicKey) @@ -365,7 +359,6 @@ class SessionClient @Inject constructor( snode = snode, parameters = params, publicKey = publicKey, - version = version ) return parseDeletions( @@ -399,13 +392,11 @@ class SessionClient @Inject constructor( suspend fun getNetworkTime( snode: Snode, - version: Version = Version.V4 ): Pair { val json = invoke( method = Snode.Method.Info, snode = snode, parameters = emptyMap(), - version = version ) val timestamp = when (val t = json["timestamp"]) { @@ -440,7 +431,6 @@ class SessionClient @Inject constructor( method = Snode.Method.OxenDaemonRPCCall, snode = snode, parameters = params, - version = Version.V4 ) @Suppress("UNCHECKED_CAST") @@ -476,7 +466,6 @@ class SessionClient @Inject constructor( newExpiry: Long, shorten: Boolean = false, extend: Boolean = false, - version: Version = Version.V4 ): Map<*, *> { val snode = swarmDirectory.getSingleTargetSnode(auth.accountId.hexString) val params = buildAlterTtlParams(auth, messageHashes, newExpiry, shorten, extend) @@ -486,7 +475,6 @@ class SessionClient @Inject constructor( snode = snode, parameters = params, publicKey = auth.accountId.hexString, - version = version ) } @@ -533,7 +521,6 @@ class SessionClient @Inject constructor( publicKey: String, requests: List, sequence: Boolean = false, - version: Version = Version.V4 ): BatchResponse { val method = if (sequence) Snode.Method.Sequence else Snode.Method.Batch val response = invokeTyped( @@ -542,7 +529,6 @@ class SessionClient @Inject constructor( parameters = mapOf("requests" to requests), responseDeserializationStrategy = BatchResponse.serializer(), publicKey = publicKey, - version = version ) // IMPORTANT: batch subresponse failures do not go through OnionErrorManager @@ -595,7 +581,6 @@ class SessionClient @Inject constructor( request: SnodeBatchRequestInfo, responseType: DeserializationStrategy, sequence: Boolean = false, - version: Version = Version.V4 ): T { val callback = Channel>(capacity = 1) @@ -607,7 +592,6 @@ class SessionClient @Inject constructor( responseType = responseType, callback = callback, sequence = sequence, - version = version ) ) @@ -626,7 +610,6 @@ class SessionClient @Inject constructor( publicKey: String, request: SnodeBatchRequestInfo, sequence: Boolean = false, - version: Version = Version.V4 ): JsonElement { return sendBatchRequest( snode = snode, @@ -634,7 +617,6 @@ class SessionClient @Inject constructor( request = request, responseType = JsonElement.serializer(), sequence = sequence, - version = version ) } @@ -820,7 +802,7 @@ class SessionClient @Inject constructor( val callback: SendChannel>, val requestTimeMs: Long = SystemClock.elapsedRealtime(), val sequence: Boolean = false, - val version: Version = Version.V4, + val version: Version = Version.V3, ) // Error diff --git a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt index 1fbab27997..844e1a85a8 100644 --- a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt +++ b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt @@ -57,7 +57,7 @@ class SessionNetwork @Inject constructor( parameters: Map<*, *>, snode: Snode, publicKey: String? = null, - version: Version = Version.V4 + version: Version = Version.V3 ): OnionResponse { val payload = JsonUtil.toJson( mapOf( @@ -121,6 +121,7 @@ class SessionNetwork @Inject constructor( for (attempt in 1..maxAttempts) { val path: Path = pathManager.getPath(exclude = snodeToExclude) + Log.i("Onion Request", "Sending onion request to $destination - attempt $attempt/$maxAttempts") try { val result = transport.send( @@ -134,7 +135,7 @@ class SessionNetwork @Inject constructor( } catch (e: Throwable) { val onionError = e as? OnionError ?: OnionError.Unknown(e) - Log.w("Onion", "Onion error on attempt $attempt/$maxAttempts: $onionError") + Log.w("Onion Request", "Onion error on attempt $attempt/$maxAttempts: $onionError") lastError = onionError diff --git a/app/src/main/java/org/session/libsession/network/onion/OnionErrorManager.kt b/app/src/main/java/org/session/libsession/network/onion/OnionErrorManager.kt index 526ee976e9..01eef5bb3a 100644 --- a/app/src/main/java/org/session/libsession/network/onion/OnionErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/onion/OnionErrorManager.kt @@ -109,7 +109,7 @@ class OnionErrorManager @Inject constructor( body = status.body ) } else { - Log.w("Onion", "Got 421 without an associated public key.") + Log.w("Onion Request", "Got 421 without an associated public key.") false } diff --git a/app/src/main/java/org/session/libsession/network/onion/PathManager.kt b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt index bdb97ec84c..7f84c323b9 100644 --- a/app/src/main/java/org/session/libsession/network/onion/PathManager.kt +++ b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt @@ -154,7 +154,7 @@ class PathManager @Inject constructor( val unused = pool.minus(usedSnodes) if (unused.isEmpty()) { - Log.w("Onion", "No unused snodes to repair path, dropping path entirely") + Log.w("Onion Request", "No unused snodes to repair path, dropping path entirely") newPathsList.removeAt(pathIndex) } else { val replacement = unused.secureRandom() @@ -191,7 +191,7 @@ class PathManager @Inject constructor( private fun sanitizePaths(paths: List): List { if (paths.isEmpty()) return emptyList() if (arePathsDisjoint(paths)) return paths - Log.w("Onion", "Paths contained overlapping snodes. Dropping backups.") + Log.w("Onion Request", "Paths contained overlapping snodes. Dropping backups.") return paths.take(1) } diff --git a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt index 31f1194577..2c9ef01495 100644 --- a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt +++ b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt @@ -1,5 +1,6 @@ package org.session.libsession.network.onion.http +import dagger.Lazy import org.session.libsession.network.model.ErrorStatus import org.session.libsession.network.model.OnionDestination import org.session.libsession.network.model.OnionError @@ -8,18 +9,24 @@ import org.session.libsession.network.onion.OnionBuilder import org.session.libsession.network.onion.OnionRequestEncryption import org.session.libsession.network.onion.OnionTransport import org.session.libsession.network.onion.Version +import org.session.libsession.network.snode.SnodeDirectory import org.session.libsession.utilities.AESGCM import org.session.libsignal.utilities.ByteArraySlice import org.session.libsignal.utilities.ByteArraySlice.Companion.view +import org.session.libsignal.utilities.ForkInfo import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.JsonUtil +import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode import org.session.libsignal.utilities.toHexString import javax.inject.Inject import javax.inject.Singleton +import kotlin.io.encoding.Base64 @Singleton -class HttpOnionTransport @Inject constructor() : OnionTransport { +class HttpOnionTransport @Inject constructor( + private val snodeDirectory: Lazy +) : OnionTransport { override suspend fun send( path: List, @@ -84,6 +91,8 @@ class HttpOnionTransport @Inject constructor() : OnionTransport { val statusCode = ex.statusCode + Log.w("Onion Request", "Got an HTTP error. Status: $statusCode, message: $message", ex) + // Special onion path error: "Next node not found: " val prefix = "Next node not found: " if (message != null && message.startsWith(prefix)) { @@ -110,12 +119,10 @@ class HttpOnionTransport @Inject constructor() : OnionTransport { destination: OnionDestination, version: Version ): OnionResponse { + Log.i("Onion Request", "Got a successful response from request") return when (version) { Version.V4 -> handleV4Response(rawResponse, destinationSymmetricKey, destination) - Version.V2, Version.V3 -> { - //todo ONION add support for v2/v3 - throw OnionError.Unknown(UnsupportedOperationException("Need to implement v2/v3")) - } + Version.V2, Version.V3 -> handleV2V3Response(rawResponse, destinationSymmetricKey) } } @@ -152,6 +159,8 @@ class HttpOnionTransport @Inject constructor() : OnionTransport { val statusCode = responseInfo["code"].toString().toInt() if (statusCode !in 200..299) { + Log.i("Onion Request", "Successful response decrypted, but non-2xx status code: $statusCode") + // Optional "body" part for some server errors (notably 400) //todo ONION should we ALWAYS attach the body so that no specific error rules are defined here and/or in case future rules change and the body is needed elsewhere? val bodySlice = @@ -182,6 +191,118 @@ class HttpOnionTransport @Inject constructor() : OnionTransport { } } + private fun handleV2V3Response( + rawResponse: ByteArray, + destinationSymmetricKey: ByteArray + ): OnionResponse { + // Outer wrapper: {"result": ""} + val jsonWrapper: Map<*, *> = try { + JsonUtil.fromJson(rawResponse, Map::class.java) as Map<*, *> + } catch (e: Exception) { + mapOf("result" to rawResponse.decodeToString()) + } + + val base64Ciphertext = jsonWrapper["result"] as? String + ?: throw OnionError.InvalidResponse(Exception("V2/V3 response missing 'result'")) + + val ivAndCiphertext: ByteArray = try { + Base64.decode(base64Ciphertext) + } catch (e: Exception) { + throw OnionError.InvalidResponse(Exception("Base64 decode failed", e)) + } + + val plaintextBytes: ByteArray = try { + AESGCM.decrypt(ivAndCiphertext, symmetricKey = destinationSymmetricKey) + } catch (e: Exception) { + throw OnionError.InvalidResponse(Exception("Decryption failed", e)) + } + + val plaintextString = plaintextBytes.toString(Charsets.UTF_8) + + val innerJson: Map<*, *> = try { + JsonUtil.fromJson(plaintextString, Map::class.java) as Map<*, *> + } catch (e: Exception) { + throw OnionError.InvalidResponse(Exception("Decrypted payload is not valid JSON", e)) + } + + val statusCode: Int = + (innerJson["status_code"] as? Number)?.toInt() + ?: (innerJson["status"] as? Number)?.toInt() + ?: throw OnionError.InvalidResponse(Exception("Missing status code in V2/V3 response")) + + val bodyObj: Any? = innerJson["body"] + + val normalizedBody: Any? = when (bodyObj) { + null -> null + + is Map<*, *> -> { + processForkInfo(bodyObj) + bodyObj + } + + is String -> { + val parsed: Any = try { + JsonUtil.fromJson(bodyObj, Map::class.java) + } catch (e: Exception) { + throw OnionError.InvalidResponse(Exception("Failed to parse body string as JSON", e)) + } + + val parsedMap = parsed as? Map<*, *> + ?: throw OnionError.InvalidResponse(Exception("Parsed body was not a JSON object")) + + processForkInfo(parsedMap) + parsedMap + } + + else -> { + throw OnionError.InvalidResponse(Exception("Unexpected body type: ${bodyObj::class.java}")) + } + } + + fun extractMessage(from: Map<*, *>): String? = + (from["result"] as? String) ?: (from["message"] as? String) + + //todo ONION old code used to only check != 200, but I think this is more correct? + if (statusCode !in 200..299) { + val errorMap = (normalizedBody as? Map<*, *>) ?: innerJson + throw OnionError.DestinationError( + ErrorStatus( + code = statusCode, + message = extractMessage(errorMap), + body = JsonUtil.toJson(errorMap).toByteArray().view() + ) + ) + } + + return if (normalizedBody != null) { + val bodyMap = normalizedBody as Map<*, *> + val bodyBytes: ByteArraySlice = JsonUtil.toJson(bodyMap).toByteArray().view() + OnionResponse(info = bodyMap, body = bodyBytes) + } else { + val jsonBytes: ByteArraySlice = JsonUtil.toJson(innerJson).toByteArray().view() + OnionResponse(info = innerJson, body = jsonBytes) + } + } + + private fun processForkInfo(map: Map<*, *>) { + if (!map.containsKey("hf")) return + + try { + @Suppress("UNCHECKED_CAST") + val currentHf = map["hf"] as? List ?: return + + if (currentHf.size >= 2) { + val hf = currentHf[0] + val sf = currentHf[1] + val newForkInfo = ForkInfo(hf, sf) + + snodeDirectory.get().updateForkInfo(newForkInfo) + } + } catch (e: Exception) { + Log.w("Onion Request", "Failed to parse fork info", e) + } + } + private fun ByteArray.getBody(infoLength: Int, infoEndIndex: Int): ByteArraySlice { val infoLengthStringLength = infoLength.toString().length if (size <= infoLength + infoLengthStringLength + 2) return ByteArraySlice.EMPTY diff --git a/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt b/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt index b8a5a5d864..fb3303d57f 100644 --- a/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt +++ b/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.launch import org.session.libsession.utilities.Environment import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.crypto.secureRandom +import org.session.libsignal.utilities.ForkInfo import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log @@ -189,7 +190,7 @@ class SnodeDirectory @Inject constructor( val newGuards = (0 until needed).map { val candidate = unused.secureRandom() unused = unused - candidate - Log.d("Onion", "Selected guard snode: $candidate") + Log.d("Onion Request", "Selected guard snode: $candidate") candidate } @@ -205,4 +206,14 @@ class SnodeDirectory @Inject constructor( Log.w("SnodeDirectory", "Dropping snode from pool (ed25519=$ed25519Key): $hit") updateSnodePool(current - hit) } + + fun updateForkInfo(newForkInfo: ForkInfo) { + val current = storage.getForkInfo() + if (newForkInfo > current) { + Log.d("Loki", "Updating fork info: $current -> $newForkInfo") + storage.setForkInfo(newForkInfo) + } else if (newForkInfo < current) { + Log.w("Loki", "Got stale fork info $newForkInfo (current: $current)") + } + } } diff --git a/app/src/main/java/org/session/libsession/network/snode/SnodeStorage.kt b/app/src/main/java/org/session/libsession/network/snode/SnodeStorage.kt index 4bc2cc8a24..f76f6f9b17 100644 --- a/app/src/main/java/org/session/libsession/network/snode/SnodeStorage.kt +++ b/app/src/main/java/org/session/libsession/network/snode/SnodeStorage.kt @@ -2,6 +2,7 @@ package org.session.libsession.network.snode import org.session.libsession.network.model.Path +import org.session.libsignal.utilities.ForkInfo import org.session.libsignal.utilities.Snode import org.thoughtcrime.securesms.database.LokiAPIDatabase import javax.inject.Inject @@ -21,6 +22,9 @@ interface SwarmStorage { interface SnodePoolStorage { fun getSnodePool(): Set fun setSnodePool(newValue: Set) + + fun getForkInfo(): ForkInfo + fun setForkInfo(forkInfo: ForkInfo) } @@ -59,4 +63,12 @@ class DbSnodePoolStorage @Inject constructor(private val db: LokiAPIDatabase) : override fun setSnodePool(newValue: Set) { db.setSnodePool(newValue) } + + override fun getForkInfo(): ForkInfo { + return db.getForkInfo() + } + + override fun setForkInfo(forkInfo: ForkInfo) { + db.setForkInfo(forkInfo) + } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt b/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt index 48caa1faf5..412ddbc507 100644 --- a/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt +++ b/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt @@ -42,7 +42,6 @@ class SwarmDirectory @Inject constructor( method = Snode.Method.GetSwarm, parameters = params, snode = randomSnode, - version = Version.V4 ) val body = response.body ?: error("Empty GetSwarm body") From 9d29735d8940869fb334da4203f485c9ae3324f6 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 22 Dec 2025 15:06:12 +1100 Subject: [PATCH 32/77] Better separation of concerns. No one calls SessionDirectly except the clients. --- .../messaging/MessagingModuleConfiguration.kt | 4 +- .../messaging/file_server/FileServerApi.kt | 6 +- .../messaging/jobs/NotifyPNServerJob.kt | 8 +- .../messaging/open_groups/OpenGroupApi.kt | 2 +- .../sending_receiving/MessageSender.kt | 8 +- .../ReceivedMessageProcessor.kt | 6 +- .../notifications/PushRegistryV1.kt | 2 +- .../sending_receiving/pollers/Poller.kt | 16 +-- .../libsession/network/ServerClient.kt | 94 ++++++++++++++ .../libsession/network/SessionNetwork.kt | 115 +----------------- .../{SessionClient.kt => SnodeClient.kt} | 46 ++++--- .../session/libsession/network/SnodeClock.kt | 4 +- .../network/snode/SwarmDirectory.kt | 12 +- .../securesms/configs/ConfigToDatabaseSync.kt | 6 +- .../securesms/configs/ConfigUploader.kt | 16 +-- .../securesms/groups/GroupManagerV2Impl.kt | 28 ++--- .../securesms/groups/GroupPoller.kt | 20 +-- .../handler/RemoveGroupMemberHandler.kt | 16 +-- .../newmessage/NewMessageViewModel.kt | 6 +- .../notifications/MarkReadProcessor.kt | 6 +- .../securesms/notifications/PushRegistryV2.kt | 6 +- .../preferences/SettingsViewModel.kt | 6 +- .../securesms/pro/api/ProApiExecutor.kt | 6 +- .../DefaultConversationRepository.kt | 8 +- .../securesms/tokenpage/TokenRepository.kt | 6 +- 25 files changed, 220 insertions(+), 233 deletions(-) create mode 100644 app/src/main/java/org/session/libsession/network/ServerClient.kt rename app/src/main/java/org/session/libsession/network/{SessionClient.kt => SnodeClient.kt} (96%) diff --git a/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt b/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt index 69636c30fd..7804d0b421 100644 --- a/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt +++ b/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt @@ -9,7 +9,7 @@ import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.messaging.jobs.MessageSendJob import org.session.libsession.messaging.notifications.TokenFetcher -import org.session.libsession.network.SessionNetwork +import org.session.libsession.network.ServerClient import org.session.libsession.network.SnodeClock import org.session.libsession.network.onion.PathManager import org.session.libsession.utilities.ConfigFactoryProtocol @@ -38,7 +38,7 @@ class MessagingModuleConfiguration @Inject constructor( val messageSendJobFactory: MessageSendJob.Factory, val json: Json, val snodeClock: SnodeClock, - val sessionNetwork: SessionNetwork, + val serverClient: ServerClient, val pathManager: PathManager ) { diff --git a/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt b/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt index ed7e4e7f18..6fe850764e 100644 --- a/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt +++ b/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt @@ -10,7 +10,7 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody import org.session.libsession.database.StorageProtocol -import org.session.libsession.network.SessionNetwork +import org.session.libsession.network.ServerClient import org.session.libsignal.utilities.ByteArraySlice import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.Hex @@ -28,7 +28,7 @@ import kotlin.time.Duration.Companion.milliseconds @Singleton class FileServerApi @Inject constructor( private val storage: StorageProtocol, - private val sessionNetwork: SessionNetwork + private val serverClient: ServerClient ) { companion object { @@ -94,7 +94,7 @@ class FileServerApi @Inject constructor( } return if (request.useOnionRouting) { try { - val response = sessionNetwork.sendToServer( + val response = serverClient.send( request = requestBuilder.build(), serverBaseUrl = request.fileServer.url.host, x25519PublicKey = diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt index 1fa4521e60..b69408525a 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt @@ -10,7 +10,7 @@ import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.jobs.Job.Companion.MAX_BUFFER_SIZE_BYTES import org.session.libsession.messaging.sending_receiving.notifications.Server import org.session.libsession.messaging.utilities.Data -import org.session.libsession.network.SessionNetwork +import org.session.libsession.network.ServerClient import org.session.libsession.network.onion.Version import org.session.libsession.snode.SnodeMessage import org.session.libsignal.utilities.JsonUtil @@ -25,8 +25,8 @@ class NotifyPNServerJob(val message: SnodeMessage) : Job { override val maxFailureCount: Int = 20 - private val sessionNetwork: SessionNetwork by lazy { - MessagingModuleConfiguration.shared.sessionNetwork + private val serverClient: ServerClient by lazy { + MessagingModuleConfiguration.shared.serverClient } companion object { @@ -46,7 +46,7 @@ class NotifyPNServerJob(val message: SnodeMessage) : Job { try { // High-level application retry (4 attempts) retryWithUniformInterval(maxRetryCount = 4) { - sessionNetwork.sendToServer( + serverClient.send( request = request, serverBaseUrl = server.url, x25519PublicKey = server.publicKey, diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt index fb9b294dbb..4130f09d3a 100644 --- a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt @@ -410,7 +410,7 @@ object OpenGroupApi { if (request.useOnionRouting) { try { - return MessagingModuleConfiguration.shared.sessionNetwork.sendToServer( + return MessagingModuleConfiguration.shared.serverClient.send( request = requestBuilder.build(), serverBaseUrl = request.server, x25519PublicKey = serverPublicKey diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt index dd2524fb52..a216800797 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt @@ -28,7 +28,7 @@ import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupApi.Capability import org.session.libsession.messaging.open_groups.OpenGroupMessage -import org.session.libsession.network.SessionClient +import org.session.libsession.network.SnodeClient import org.session.libsession.network.SnodeClock import org.session.libsession.snode.SnodeMessage import org.session.libsession.utilities.Address @@ -62,7 +62,7 @@ class MessageSender @Inject constructor( private val messageSendJobFactory: MessageSendJob.Factory, private val messageExpirationManager: ExpiringMessageManager, private val snodeClock: SnodeClock, - private val sessionClient: SessionClient, + private val snodeClient: SnodeClient, @param:ManagerScope private val scope: CoroutineScope, ) { @@ -247,14 +247,14 @@ class MessageSender @Inject constructor( "Unable to authorize group message send" } - sessionClient.sendMessage( + snodeClient.sendMessage( auth = groupAuth, message = snodeMessage, namespace = Namespace.GROUP_MESSAGES(), ) } is Destination.Contact -> { - sessionClient.sendMessage(snodeMessage, auth = null, namespace = Namespace.DEFAULT()) + snodeClient.sendMessage(snodeMessage, auth = null, namespace = Namespace.DEFAULT()) } is Destination.OpenGroup, is Destination.OpenGroupInbox -> throw IllegalStateException("Destination should not be an open group.") diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt index 9bee5b94ab..00776ca912 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt @@ -29,7 +29,7 @@ import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier import org.session.libsession.messaging.utilities.WebRtcUtils -import org.session.libsession.network.SessionClient +import org.session.libsession.network.SnodeClient import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol @@ -77,7 +77,7 @@ class ReceivedMessageProcessor @Inject constructor( private val visibleMessageHandler: Provider, private val blindMappingRepository: BlindMappingRepository, private val messageParser: MessageParser, - private val sessionClient: SessionClient + private val snodeClient: SnodeClient ) { private val threadMutexes = ConcurrentHashMap() @@ -454,7 +454,7 @@ class ReceivedMessageProcessor @Inject constructor( messageDataProvider.getServerHashForMessage(messageIdToDelete)?.let { serverHash -> scope.launch(Dispatchers.IO) { // using scope as we are slowly migrating to coroutines but we can't migrate everything at once try { - sessionClient.deleteMessage(author, userAuth, listOf(serverHash)) + snodeClient.deleteMessage(author, userAuth, listOf(serverHash)) } catch (e: Exception) { Log.e("Loki", "Failed to delete message", e) } diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt index 56c6627d94..28c234d054 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt @@ -62,7 +62,7 @@ object PushRegistryV1 { try { retryWithUniformInterval(MAX_RETRY_COUNT) { - MessagingModuleConfiguration.shared.sessionNetwork.sendToServer( + MessagingModuleConfiguration.shared.serverClient.send( request = request, serverBaseUrl = server.url, x25519PublicKey = server.publicKey, diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt index 7632f902b2..12f2ce98ea 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt @@ -30,7 +30,7 @@ import org.session.libsession.database.userAuth import org.session.libsession.messaging.messages.Message.Companion.senderOrSync import org.session.libsession.messaging.sending_receiving.MessageParser import org.session.libsession.messaging.sending_receiving.ReceivedMessageProcessor -import org.session.libsession.network.SessionClient +import org.session.libsession.network.SnodeClient import org.session.libsession.network.SnodeClock import org.session.libsession.network.snode.SwarmStorage import org.session.libsession.snode.model.RetrieveMessageResponse @@ -64,7 +64,7 @@ class Poller @AssistedInject constructor( private val receivedMessageHashDatabase: ReceivedMessageHashDatabase, private val processor: ReceivedMessageProcessor, private val messageParser: MessageParser, - private val sessionClient: SessionClient, + private val snodeClient: SnodeClient, private val swarmStorage: SwarmStorage, @Assisted scope: CoroutineScope ) { @@ -305,7 +305,7 @@ class Poller @AssistedInject constructor( // Get messages call wrapped in an async val fetchMessageTask = if (!pollOnlyUserProfileConfig) { - val request = sessionClient.buildAuthenticatedRetrieveBatchRequest( + val request = snodeClient.buildAuthenticatedRetrieveBatchRequest( lastHash = lokiApiDatabase.getLastMessageHashValue( snode = snode, publicKey = userAuth.accountId.hexString, @@ -316,7 +316,7 @@ class Poller @AssistedInject constructor( this.async { runCatching { - sessionClient.sendBatchRequest( + snodeClient.sendBatchRequest( snode = snode, publicKey = userPublicKey, request = request, @@ -341,7 +341,7 @@ class Poller @AssistedInject constructor( .map { type -> val config = configs.getConfig(type) hashesToExtend += config.activeHashes() - val request = sessionClient.buildAuthenticatedRetrieveBatchRequest( + val request = snodeClient.buildAuthenticatedRetrieveBatchRequest( lastHash = lokiApiDatabase.getLastMessageHashValue( snode = snode, publicKey = userAuth.accountId.hexString, @@ -354,7 +354,7 @@ class Poller @AssistedInject constructor( this.async { type to runCatching { - sessionClient.sendBatchRequest( + snodeClient.sendBatchRequest( snode = snode, publicKey = userPublicKey, request = request, @@ -368,10 +368,10 @@ class Poller @AssistedInject constructor( if (hashesToExtend.isNotEmpty()) { launch { try { - sessionClient.sendBatchRequest( + snodeClient.sendBatchRequest( snode, userPublicKey, - sessionClient.buildAuthenticatedAlterTtlBatchRequest( + snodeClient.buildAuthenticatedAlterTtlBatchRequest( messageHashes = hashesToExtend.toList(), auth = userAuth, newExpiry = snodeClock.currentTimeMills() + 14.days.inWholeMilliseconds, diff --git a/app/src/main/java/org/session/libsession/network/ServerClient.kt b/app/src/main/java/org/session/libsession/network/ServerClient.kt new file mode 100644 index 0000000000..3a21dc712f --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/ServerClient.kt @@ -0,0 +1,94 @@ +package org.session.libsession.network + +import okhttp3.Request +import org.session.libsession.network.model.OnionDestination +import org.session.libsession.network.model.OnionResponse +import org.session.libsession.network.onion.Version +import org.session.libsession.network.utilities.getBodyForOnionRequest +import org.session.libsession.network.utilities.getHeadersForOnionRequest +import org.session.libsignal.utilities.JsonUtil +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Responsible for encoding HTTP requests into the onion format (v3/v4) + * and sending them via the network. + */ +@Singleton +class ServerClient @Inject constructor( + private val sessionNetwork: SessionNetwork +) { + suspend fun send( + request: Request, + serverBaseUrl: String, + x25519PublicKey: String, + version: Version = Version.V4 + ): OnionResponse { + val url = request.url + val payload = generatePayload(request, serverBaseUrl, version) + + val destination = OnionDestination.ServerDestination( + host = url.host, + target = version.value, + x25519PublicKey = x25519PublicKey, + scheme = url.scheme, + port = url.port + ) + + return sessionNetwork.sendWithRetry( + destination = destination, + payload = payload, + version = version, + snodeToExclude = null, + targetSnode = null, + publicKey = null + ) + } + + private fun generatePayload(request: Request, server: String, version: Version): ByteArray { + val headers = request.getHeadersForOnionRequest().toMutableMap() + val url = request.url + val urlAsString = url.toString() + val body = request.getBodyForOnionRequest() ?: "null" + + val endpoint = if (server.length < urlAsString.length) { + urlAsString.substringAfter(server) + } else { + "" + } + + return if (version == Version.V4) { + if (request.body != null && + headers.keys.none { it.equals("Content-Type", ignoreCase = true) } + ) { + headers["Content-Type"] = "application/json" + } + + val requestPayload = mapOf( + "endpoint" to endpoint, + "method" to request.method, + "headers" to headers + ) + + val requestData = JsonUtil.toJson(requestPayload).toByteArray() + val prefixData = "l${requestData.size}:".toByteArray(Charsets.US_ASCII) + val suffixData = "e".toByteArray(Charsets.US_ASCII) + + if (request.body != null) { + val bodyData = if (body is ByteArray) body else body.toString().toByteArray() + val bodyLengthData = "${bodyData.size}:".toByteArray(Charsets.US_ASCII) + prefixData + requestData + bodyLengthData + bodyData + suffixData + } else { + prefixData + requestData + suffixData + } + } else { + val payload = mapOf( + "body" to body, + "endpoint" to endpoint.removePrefix("/"), + "method" to request.method, + "headers" to headers + ) + JsonUtil.toJson(payload).toByteArray() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt index 844e1a85a8..ffba8e6920 100644 --- a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt +++ b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt @@ -12,9 +12,6 @@ import org.session.libsession.network.onion.FailureDecision import org.session.libsession.network.onion.OnionTransport import org.session.libsession.network.onion.PathManager import org.session.libsession.network.onion.Version -import org.session.libsession.network.utilities.getBodyForOnionRequest -import org.session.libsession.network.utilities.getHeadersForOnionRequest -import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode import javax.inject.Inject @@ -41,75 +38,12 @@ class SessionNetwork @Inject constructor( private val errorManager: OnionErrorManager, ) { - //todo ONION we now have a few places in the app calling SesisonNetwork directly to use - // sendToSnode or sendToServer. Should this be abstracted away in sessionClient instead? - // Is there a better way to discern the two? - private val maxAttempts: Int = 2 private val baseRetryDelayMs: Long = 250L private val maxRetryDelayMs: Long = 2_000L - /** - * Send an onion request to a *service node*. - */ - suspend fun sendToSnode( - method: Snode.Method, - parameters: Map<*, *>, - snode: Snode, - publicKey: String? = null, - version: Version = Version.V3 - ): OnionResponse { - val payload = JsonUtil.toJson( - mapOf( - "method" to method.rawValue, - "params" to parameters - ) - ).toByteArray() - - val destination = OnionDestination.SnodeDestination(snode) - // Exclude the destination snode itself from being in the path (old behaviour) - return sendWithRetry( - destination = destination, - payload = payload, - version = version, - snodeToExclude = snode, - targetSnode = snode, - publicKey = publicKey - ) - } - - /** - * Send an onion request to an HTTP server via the snode network. - */ - suspend fun sendToServer( - request: Request, - serverBaseUrl: String, - x25519PublicKey: String, - version: Version = Version.V4 - ): OnionResponse { - val url = request.url - val payload = generatePayload(request, serverBaseUrl, version) - - val destination = OnionDestination.ServerDestination( - host = url.host, - target = version.value, - x25519PublicKey = x25519PublicKey, - scheme = url.scheme, - port = url.port - ) - - return sendWithRetry( - destination = destination, - payload = payload, - version = version, - snodeToExclude = null, - targetSnode = null, - publicKey = null - ) - } - - private suspend fun sendWithRetry( + internal suspend fun sendWithRetry( destination: OnionDestination, payload: ByteArray, version: Version, @@ -171,51 +105,4 @@ class SessionNetwork @Inject constructor( val jitter = Random.nextLong(0, capped / 3 + 1) return capped + jitter } - - private fun generatePayload(request: Request, server: String, version: Version): ByteArray { - val headers = request.getHeadersForOnionRequest().toMutableMap() - val url = request.url - val urlAsString = url.toString() - val body = request.getBodyForOnionRequest() ?: "null" - - val endpoint = if (server.length < urlAsString.length) { - urlAsString.substringAfter(server) - } else { - "" - } - - return if (version == Version.V4) { - if (request.body != null && - headers.keys.none { it.equals("Content-Type", ignoreCase = true) } - ) { - headers["Content-Type"] = "application/json" - } - - val requestPayload = mapOf( - "endpoint" to endpoint, - "method" to request.method, - "headers" to headers - ) - - val requestData = JsonUtil.toJson(requestPayload).toByteArray() - val prefixData = "l${requestData.size}:".toByteArray(Charsets.US_ASCII) - val suffixData = "e".toByteArray(Charsets.US_ASCII) - - if (request.body != null) { - val bodyData = if (body is ByteArray) body else body.toString().toByteArray() - val bodyLengthData = "${bodyData.size}:".toByteArray(Charsets.US_ASCII) - prefixData + requestData + bodyLengthData + bodyData + suffixData - } else { - prefixData + requestData + suffixData - } - } else { - val payload = mapOf( - "body" to body, - "endpoint" to endpoint.removePrefix("/"), - "method" to request.method, - "headers" to headers - ) - JsonUtil.toJson(payload).toByteArray() - } - } } diff --git a/app/src/main/java/org/session/libsession/network/SessionClient.kt b/app/src/main/java/org/session/libsession/network/SnodeClient.kt similarity index 96% rename from app/src/main/java/org/session/libsession/network/SessionClient.kt rename to app/src/main/java/org/session/libsession/network/SnodeClient.kt index 1af02609d4..a15385666f 100644 --- a/app/src/main/java/org/session/libsession/network/SessionClient.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClient.kt @@ -51,7 +51,7 @@ import kotlin.collections.get * High-level client for interacting with snodes. */ @Singleton -class SessionClient @Inject constructor( +class SnodeClient @Inject constructor( private val sessionNetwork: SessionNetwork, private val swarmDirectory: SwarmDirectory, private val snodeDirectory: SnodeDirectory, @@ -153,19 +153,29 @@ class SessionClient @Inject constructor( } } - private suspend fun invokeRaw( + private suspend fun sendToSnode( method: Snode.Method, snode: Snode, parameters: Map, publicKey: String? = null, version: Version = Version.V3 ): ByteArraySlice { - val onionResponse = sessionNetwork.sendToSnode( - method = method, - parameters = parameters, - snode = snode, - publicKey = publicKey, - version = version + val payload = JsonUtil.toJson( + mapOf( + "method" to method.rawValue, + "params" to parameters + ) + ).toByteArray() + + val destination = OnionDestination.SnodeDestination(snode) + + val onionResponse = sessionNetwork.sendWithRetry( + destination = destination, + payload = payload, + version = version, + snodeToExclude = snode, + targetSnode = snode, + publicKey = publicKey ) return onionResponse.body @@ -173,7 +183,7 @@ class SessionClient @Inject constructor( } @OptIn(ExperimentalSerializationApi::class) - suspend fun invokeTyped( + private suspend fun sendTyped( method: Snode.Method, snode: Snode, parameters: Map, @@ -181,7 +191,7 @@ class SessionClient @Inject constructor( publicKey: String? = null, version: Version = Version.V3 ): Res { - val body = invokeRaw( + val body = sendToSnode( method = method, snode = snode, parameters = parameters, @@ -197,14 +207,14 @@ class SessionClient @Inject constructor( } } - suspend fun invoke( + suspend fun send( method: Snode.Method, snode: Snode, parameters: Map, publicKey: String? = null, version: Version = Version.V3 ): Map<*, *> { - val body = invokeRaw( + val body = sendToSnode( method = method, snode = snode, parameters = parameters, @@ -288,7 +298,7 @@ class SessionClient @Inject constructor( this["messages"] = serverHashes } - val rawResponse = invoke( + val rawResponse = send( method = Snode.Method.DeleteMessage, snode = snode, parameters = params, @@ -354,7 +364,7 @@ class SessionClient @Inject constructor( put("namespace", "all") } - val raw = invoke( + val raw = send( method = Snode.Method.DeleteAll, snode = snode, parameters = params, @@ -393,7 +403,7 @@ class SessionClient @Inject constructor( suspend fun getNetworkTime( snode: Snode, ): Pair { - val json = invoke( + val json = send( method = Snode.Method.Info, snode = snode, parameters = emptyMap(), @@ -427,7 +437,7 @@ class SessionClient @Inject constructor( repeat(validationCount) { val snode = snodeDirectory.getRandomSnode() - val json = invoke( + val json = send( method = Snode.Method.OxenDaemonRPCCall, snode = snode, parameters = params, @@ -470,7 +480,7 @@ class SessionClient @Inject constructor( val snode = swarmDirectory.getSingleTargetSnode(auth.accountId.hexString) val params = buildAlterTtlParams(auth, messageHashes, newExpiry, shorten, extend) - return invoke( + return send( method = Snode.Method.Expire, snode = snode, parameters = params, @@ -523,7 +533,7 @@ class SessionClient @Inject constructor( sequence: Boolean = false, ): BatchResponse { val method = if (sequence) Snode.Method.Sequence else Snode.Method.Batch - val response = invokeTyped( + val response = sendTyped( method = method, snode = snode, parameters = mapOf("requests" to requests), diff --git a/app/src/main/java/org/session/libsession/network/SnodeClock.kt b/app/src/main/java/org/session/libsession/network/SnodeClock.kt index 1f7dd9c626..f4c51f34d3 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClock.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClock.kt @@ -29,7 +29,7 @@ import javax.inject.Singleton class SnodeClock @Inject constructor( @param:ManagerScope private val scope: CoroutineScope, private val snodeDirectory: SnodeDirectory, - private val sessionClient: Lazy, + private val snodeClient: Lazy, ) : OnAppStartupComponent { //todo ONION we have a lot of calls to MessagingModuleConfiguration.shared.snodeClock.currentTimeMills() @@ -47,7 +47,7 @@ class SnodeClock @Inject constructor( val node = snodeDirectory.getRandomSnode() val requestStarted = SystemClock.elapsedRealtime() - var networkTime = sessionClient.get().getNetworkTime(node).second + var networkTime = snodeClient.get().getNetworkTime(node).second val requestEnded = SystemClock.elapsedRealtime() // Adjust network time to halfway through the request duration diff --git a/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt b/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt index 412ddbc507..7601e09a31 100644 --- a/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt +++ b/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt @@ -1,8 +1,7 @@ package org.session.libsession.network.snode import dagger.Lazy -import org.session.libsession.network.SessionNetwork -import org.session.libsession.network.onion.Version +import org.session.libsession.network.SnodeClient import org.session.libsignal.crypto.shuffledRandom import org.session.libsignal.utilities.ByteArraySlice import org.session.libsignal.utilities.JsonUtil @@ -14,7 +13,7 @@ import javax.inject.Singleton class SwarmDirectory @Inject constructor( private val storage: SwarmStorage, private val snodeDirectory: SnodeDirectory, - private val sessionNetwork: Lazy, + private val snodeClient: Lazy, ) { private val minimumSwarmSize: Int = 3 @@ -38,16 +37,13 @@ class SwarmDirectory @Inject constructor( val randomSnode = pool.random() val params = mapOf("pubKey" to publicKey) - val response = sessionNetwork.get().sendToSnode( + val response = snodeClient.get().send( method = Snode.Method.GetSwarm, parameters = params, snode = randomSnode, ) - val body = response.body ?: error("Empty GetSwarm body") - val json = JsonUtil.fromJson(body, Map::class.java) as Map<*, *> - - return parseSnodes(json).toSet() + return parseSnodes(response).toSet() } /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt index 2ebe1adc13..d98ae62c7c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt @@ -19,7 +19,7 @@ import org.session.libsession.avatars.AvatarCacheCleaner import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1 -import org.session.libsession.network.SessionClient +import org.session.libsession.network.SnodeClient import org.session.libsession.network.SnodeClock import org.session.libsession.snode.OwnedSwarmAuth import org.session.libsession.utilities.Address @@ -89,7 +89,7 @@ class ConfigToDatabaseSync @Inject constructor( private val messageNotifier: MessageNotifier, private val recipientSettingsDatabase: RecipientSettingsDatabase, private val avatarCacheCleaner: AvatarCacheCleaner, - private val sessionClient: SessionClient, + private val snodeClient: SnodeClient, @param:ManagerScope private val scope: CoroutineScope, ) : AuthAwareComponent { override suspend fun doWhileLoggedIn(loggedInState: LoggedInState) { @@ -319,7 +319,7 @@ class ConfigToDatabaseSync @Inject constructor( scope.launch(Dispatchers.Default) { val cleanedHashes: List = messages.asSequence().map { it.second }.filter { !it.isNullOrEmpty() }.filterNotNull().toList() - if (cleanedHashes.isNotEmpty()) sessionClient.deleteMessage( + if (cleanedHashes.isNotEmpty()) snodeClient.deleteMessage( groupInfoConfig.id.hexString, groupAdminAuth, cleanedHashes diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt index 6bd86ac097..a42c5640e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt @@ -20,7 +20,7 @@ import network.loki.messenger.libsession_util.Namespace import network.loki.messenger.libsession_util.util.ConfigPush import org.session.libsession.database.StorageProtocol import org.session.libsession.database.userAuth -import org.session.libsession.network.SessionClient +import org.session.libsession.network.SnodeClient import org.session.libsession.network.SnodeClock import org.session.libsession.network.model.PathStatus import org.session.libsession.network.onion.PathManager @@ -68,7 +68,7 @@ class ConfigUploader @Inject constructor( private val clock: SnodeClock, private val networkConnectivity: NetworkConnectivity, private val swarmDirectory: SwarmDirectory, - private val sessionClient: SessionClient, + private val snodeClient: SnodeClient, private val pathManager: PathManager ) : AuthAwareComponent { /** @@ -209,10 +209,10 @@ class ConfigUploader @Inject constructor( // Keys push is different: it doesn't have the delete call so we don't call pushConfig. // Keys must be pushed first because the other configs depend on it. val keysPushResult = keysPush?.let { push -> - sessionClient.sendBatchRequest( + snodeClient.sendBatchRequest( snode = snode, publicKey = auth.accountId.hexString, - request = sessionClient.buildAuthenticatedStoreBatchInfo( + request = snodeClient.buildAuthenticatedStoreBatchInfo( Namespace.GROUP_KEYS(), SnodeMessage( auth.accountId.hexString, @@ -288,10 +288,10 @@ class ConfigUploader @Inject constructor( push.messages .map { message -> async { - sessionClient.sendBatchRequest( + snodeClient.sendBatchRequest( snode = snode, publicKey = auth.accountId.hexString, - request = sessionClient.buildAuthenticatedStoreBatchInfo( + request = snodeClient.buildAuthenticatedStoreBatchInfo( namespace, SnodeMessage( auth.accountId.hexString, @@ -309,10 +309,10 @@ class ConfigUploader @Inject constructor( } if (push.obsoleteHashes.isNotEmpty()) { - sessionClient.sendBatchRequest( + snodeClient.sendBatchRequest( snode = snode, publicKey = auth.accountId.hexString, - request = sessionClient.buildAuthenticatedDeleteBatchInfo(auth, push.obsoleteHashes) + request = snodeClient.buildAuthenticatedDeleteBatchInfo(auth, push.obsoleteHashes) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt index 6f9af255a0..5263d0b999 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -37,7 +37,7 @@ import org.session.libsession.messaging.utilities.MessageAuthentication.buildDel import org.session.libsession.messaging.utilities.MessageAuthentication.buildInfoChangeSignature import org.session.libsession.messaging.utilities.MessageAuthentication.buildMemberChangeSignature import org.session.libsession.messaging.utilities.UpdateMessageData -import org.session.libsession.network.SessionClient +import org.session.libsession.network.SnodeClient import org.session.libsession.network.SnodeClock import org.session.libsession.network.snode.SwarmDirectory import org.session.libsession.snode.OwnedSwarmAuth @@ -98,7 +98,7 @@ class GroupManagerV2Impl @Inject constructor( private val recipientRepository: RecipientRepository, private val messageSender: MessageSender, private val inviteContactJobFactory: InviteContactsJob.Factory, - private val sessionClient: SessionClient, + private val snodeClient: SnodeClient, private val swarmDirectory: SwarmDirectory ) : GroupManagerV2 { private val dispatcher = Dispatchers.Default @@ -262,7 +262,7 @@ class GroupManagerV2Impl @Inject constructor( val adminKey = requireAdminAccess(group) val groupAuth = OwnedSwarmAuth.ofClosedGroup(group, adminKey) - val batchRequests = mutableListOf() + val batchRequests = mutableListOf() val subAccountTokens = configFactory.withMutableGroupConfigs(group) { configs -> val shareHistoryHexes = mutableListOf() @@ -293,7 +293,7 @@ class GroupManagerV2Impl @Inject constructor( if (shareHistoryHexes.isNotEmpty()) { val memberKey = configs.groupKeys.supplementFor(shareHistoryHexes) batchRequests.add( - sessionClient.buildAuthenticatedStoreBatchInfo( + snodeClient.buildAuthenticatedStoreBatchInfo( namespace = Namespace.GROUP_KEYS(), message = SnodeMessage( recipient = group.hexString, @@ -311,7 +311,7 @@ class GroupManagerV2Impl @Inject constructor( } // Call un-revocate API on new members, in case they have been removed before - batchRequests += sessionClient.buildAuthenticatedUnrevokeSubKeyBatchRequest( + batchRequests += snodeClient.buildAuthenticatedUnrevokeSubKeyBatchRequest( groupAdminAuth = groupAuth, subAccountTokens = subAccountTokens ) @@ -319,7 +319,7 @@ class GroupManagerV2Impl @Inject constructor( // Call the API try { val swarmNode = swarmDirectory.getSingleTargetSnode(group.hexString) - val response = sessionClient.getBatchResponse(swarmNode, group.hexString, batchRequests) + val response = snodeClient.getBatchResponse(swarmNode, group.hexString, batchRequests) // Make sure every request is successful response.requireAllRequestsSuccessful("Failed to invite members") @@ -462,7 +462,7 @@ class GroupManagerV2Impl @Inject constructor( OwnedSwarmAuth.ofClosedGroup(groupAccountId, it) } ?: return@launchAndWait - sessionClient.deleteMessage(groupAccountId.hexString, groupAdminAuth, messagesToDelete) + snodeClient.deleteMessage(groupAccountId.hexString, groupAdminAuth, messagesToDelete) } override suspend fun clearAllMessagesForEveryone(groupAccountId: AccountId, deletedHashes: List) { @@ -478,7 +478,7 @@ class GroupManagerV2Impl @Inject constructor( // remove messages from swarm sessionClient.deleteMessage val cleanedHashes: List = deletedHashes.filter { !it.isNullOrEmpty() }.filterNotNull() - if(cleanedHashes.isNotEmpty()) sessionClient.deleteMessage(groupAccountId.hexString, groupAdminAuth, cleanedHashes) + if(cleanedHashes.isNotEmpty()) snodeClient.deleteMessage(groupAccountId.hexString, groupAdminAuth, cleanedHashes) } override suspend fun handleMemberLeftMessage(memberId: AccountId, group: AccountId) = scope.launchAndWait(group, "Handle member left message") { @@ -665,7 +665,7 @@ class GroupManagerV2Impl @Inject constructor( if (groupInviteMessageHash != null) { val auth = requireNotNull(storage.userAuth) - sessionClient.deleteMessage( + snodeClient.deleteMessage( publicKey = auth.accountId.hexString, swarmAuth = auth, serverHashes = listOf(groupInviteMessageHash) @@ -738,7 +738,7 @@ class GroupManagerV2Impl @Inject constructor( // Delete the invite once we have approved if (inviteMessageHash != null) { val auth = requireNotNull(storage.userAuth) - sessionClient.deleteMessage( + snodeClient.deleteMessage( publicKey = auth.accountId.hexString, swarmAuth = auth, serverHashes = listOf(inviteMessageHash) @@ -818,7 +818,7 @@ class GroupManagerV2Impl @Inject constructor( } // Delete the promotion message remotely - sessionClient.deleteMessage( + snodeClient.deleteMessage( userAuth.accountId.hexString, userAuth, listOf(promoteMessageHash) @@ -1025,7 +1025,7 @@ class GroupManagerV2Impl @Inject constructor( // If we are admin, we can delete the messages from the group swarm group.adminKey?.data?.let { adminKey -> - sessionClient.deleteMessage( + snodeClient.deleteMessage( publicKey = groupId.hexString, swarmAuth = OwnedSwarmAuth.ofClosedGroup(groupId, adminKey), serverHashes = messageHashes.toList() @@ -1123,7 +1123,7 @@ class GroupManagerV2Impl @Inject constructor( sender = sender.hexString, closedGroupId = groupId.hexString)) ) { - sessionClient.deleteMessage( + snodeClient.deleteMessage( groupId.hexString, OwnedSwarmAuth.ofClosedGroup(groupId, adminKey), hashes @@ -1138,7 +1138,7 @@ class GroupManagerV2Impl @Inject constructor( } if (userMessageHashes.isNotEmpty()) { - sessionClient.deleteMessage( + snodeClient.deleteMessage( groupId.hexString, OwnedSwarmAuth.ofClosedGroup(groupId, adminKey), userMessageHashes diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt index 5ad695e801..00c8574cc5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt @@ -22,7 +22,7 @@ import kotlinx.coroutines.sync.withPermit import network.loki.messenger.libsession_util.Namespace import org.session.libsession.messaging.sending_receiving.MessageParser import org.session.libsession.messaging.sending_receiving.ReceivedMessageProcessor -import org.session.libsession.network.SessionClient +import org.session.libsession.network.SnodeClient import org.session.libsession.network.SnodeClock import org.session.libsession.network.snode.SwarmDirectory import org.session.libsession.snode.model.BatchResponse @@ -57,7 +57,7 @@ class GroupPoller @AssistedInject constructor( private val messageParser: MessageParser, private val receivedMessageProcessor: ReceivedMessageProcessor, private val swarmDirectory: SwarmDirectory, - private val sessionClient: SessionClient, + private val snodeClient: SnodeClient, ) { companion object { private const val POLL_INTERVAL = 3_000L @@ -247,10 +247,10 @@ class GroupPoller @AssistedInject constructor( val pollingTasks = mutableListOf>>() val receiveRevokeMessage = async { - sessionClient.sendBatchRequest( + snodeClient.sendBatchRequest( snode, groupId.hexString, - sessionClient.buildAuthenticatedRetrieveBatchRequest( + snodeClient.buildAuthenticatedRetrieveBatchRequest( lastHash = lokiApiDatabase.getLastMessageHashValue( snode, groupId.hexString, @@ -266,10 +266,10 @@ class GroupPoller @AssistedInject constructor( if (configHashesToExtends.isNotEmpty() && adminKey != null) { pollingTasks += "extending group config TTL" to async { - sessionClient.sendBatchRequest( + snodeClient.sendBatchRequest( snode, groupId.hexString, - sessionClient.buildAuthenticatedAlterTtlBatchRequest( + snodeClient.buildAuthenticatedAlterTtlBatchRequest( messageHashes = configHashesToExtends.toList(), auth = groupAuth, newExpiry = clock.currentTimeMills() + 14.days.inWholeMilliseconds, @@ -287,10 +287,10 @@ class GroupPoller @AssistedInject constructor( ).orEmpty() - sessionClient.sendBatchRequest( + snodeClient.sendBatchRequest( snode = snode, publicKey = groupId.hexString, - request = sessionClient.buildAuthenticatedRetrieveBatchRequest( + request = snodeClient.buildAuthenticatedRetrieveBatchRequest( lastHash = lastHash, auth = groupAuth, namespace = Namespace.GROUP_MESSAGES(), @@ -306,10 +306,10 @@ class GroupPoller @AssistedInject constructor( Namespace.GROUP_MEMBERS() ).map { ns -> async { - sessionClient.sendBatchRequest( + snodeClient.sendBatchRequest( snode = snode, publicKey = groupId.hexString, - request = sessionClient.buildAuthenticatedRetrieveBatchRequest( + request = snodeClient.buildAuthenticatedRetrieveBatchRequest( lastHash = lokiApiDatabase.getLastMessageHashValue( snode, groupId.hexString, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt index 21be3fee8d..eca6447457 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt @@ -20,7 +20,7 @@ import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.control.GroupUpdated import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.MessageAuthentication -import org.session.libsession.network.SessionClient +import org.session.libsession.network.SnodeClient import org.session.libsession.network.SnodeClock import org.session.libsession.network.snode.SwarmDirectory import org.session.libsession.snode.OwnedSwarmAuth @@ -59,7 +59,7 @@ class RemoveGroupMemberHandler @Inject constructor( private val groupScope: GroupScope, private val messageSender: MessageSender, private val swarmDirectory: SwarmDirectory, - private val sessionClient: SessionClient, + private val snodeClient: SnodeClient, ) : AuthAwareComponent { override suspend fun doWhileLoggedIn(loggedInState: LoggedInState) { configFactory.configUpdateNotifications @@ -99,13 +99,13 @@ class RemoveGroupMemberHandler @Inject constructor( // 2. Send a message to a special namespace on the group to inform the removed members they have been removed // 3. Conditionally, send a `GroupUpdateDeleteMemberContent` to the group so the message deletion // can be performed by everyone in the group. - val calls = ArrayList(3) + val calls = ArrayList(3) val groupAuth = OwnedSwarmAuth.ofClosedGroup(groupAccountId, adminKey) // Call No 1. Revoke sub-key. This call is crucial and must not fail for the rest of the operation to be successful. calls += checkNotNull( - sessionClient.buildAuthenticatedRevokeSubKeyBatchRequest( + snodeClient.buildAuthenticatedRevokeSubKeyBatchRequest( groupAdminAuth = groupAuth, subAccountTokens = pendingRemovals.map { (member, _) -> configs.groupKeys.getSubAccountToken(member.accountId()) @@ -114,7 +114,7 @@ class RemoveGroupMemberHandler @Inject constructor( ) { "Fail to create a revoke request" } // Call No 2. Send a "kicked" message to the revoked namespace - calls += sessionClient.buildAuthenticatedStoreBatchInfo( + calls += snodeClient.buildAuthenticatedStoreBatchInfo( namespace = Namespace.REVOKED_GROUP_MESSAGES(), message = buildGroupKickMessage( groupAccountId.hexString, @@ -127,7 +127,7 @@ class RemoveGroupMemberHandler @Inject constructor( // Call No 3. Conditionally send the `GroupUpdateDeleteMemberContent` if (pendingRemovals.any { (member, status) -> member.shouldRemoveMessages(status) }) { - calls += sessionClient.buildAuthenticatedStoreBatchInfo( + calls += snodeClient.buildAuthenticatedStoreBatchInfo( namespace = Namespace.GROUP_MESSAGES(), message = buildDeleteGroupMemberContentMessage( adminKey = adminKey, @@ -141,7 +141,7 @@ class RemoveGroupMemberHandler @Inject constructor( ) } - pendingRemovals to (calls as List) + pendingRemovals to (calls as List) } if (pendingRemovals.isEmpty() || batchCalls.isEmpty()) { @@ -150,7 +150,7 @@ class RemoveGroupMemberHandler @Inject constructor( val node = swarmDirectory.getSingleTargetSnode(groupAccountId.hexString) val response = - sessionClient.getBatchResponse( + snodeClient.getBatchResponse( node, groupAccountId.hexString, batchCalls, diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt index 25ae792580..5cba48568b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt @@ -15,7 +15,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import network.loki.messenger.R -import org.session.libsession.network.SessionClient +import org.session.libsession.network.SnodeClient import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY @@ -28,7 +28,7 @@ import javax.inject.Inject @HiltViewModel class NewMessageViewModel @Inject constructor( private val application: Application, - private val sesionClient: SessionClient, + private val sesionClient: SnodeClient, ) : ViewModel(), Callbacks { private val HELP_URL : String = "https://getsession.org/account-ids" @@ -169,7 +169,7 @@ class NewMessageViewModel @Inject constructor( } private fun Exception.toMessage() = when (this) { - is SessionClient.Error.Generic -> application.getString(R.string.errorUnregisteredOns) + is SnodeClient.Error.Generic -> application.getString(R.string.errorUnregisteredOns) else -> Phrase.from(application, R.string.errorNoLookupOns) .put(APP_NAME_KEY, application.getString(R.string.app_name)) .format().toString() diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt index 62ac4cf526..f8a419655e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt @@ -8,7 +8,7 @@ import org.session.libsession.database.StorageProtocol import org.session.libsession.database.userAuth import org.session.libsession.messaging.messages.control.ReadReceipt import org.session.libsession.messaging.sending_receiving.MessageSender -import org.session.libsession.network.SessionClient +import org.session.libsession.network.SnodeClient import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled import org.session.libsession.utilities.associateByNotNull @@ -38,7 +38,7 @@ class MarkReadProcessor @Inject constructor( private val storage: StorageProtocol, private val snodeClock: SnodeClock, private val lokiMessageDatabase: LokiMessageDatabase, - private val sessionClient: SessionClient + private val snodeClient: SnodeClient ) { fun process( markedReadMessages: List @@ -99,7 +99,7 @@ class MarkReadProcessor @Inject constructor( keySelector = { it.value.expirationInfo.expiresIn }, valueTransform = { it.key } ).forEach { (expiresIn, hashes) -> - sessionClient.alterTtl( + snodeClient.alterTtl( messageHashes = hashes, newExpiry = snodeClock.currentTimeMills() + expiresIn, auth = checkNotNull(storage.userAuth) { "No authorized user" }, diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt index 77bae8744c..3ceb6fdb05 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt @@ -17,7 +17,7 @@ import org.session.libsession.messaging.sending_receiving.notifications.Subscrip import org.session.libsession.messaging.sending_receiving.notifications.SubscriptionResponse import org.session.libsession.messaging.sending_receiving.notifications.UnsubscribeResponse import org.session.libsession.messaging.sending_receiving.notifications.UnsubscriptionRequest -import org.session.libsession.network.SessionNetwork +import org.session.libsession.network.ServerClient import org.session.libsession.network.SnodeClock import org.session.libsession.network.onion.Version import org.session.libsession.snode.SwarmAuth @@ -35,7 +35,7 @@ class PushRegistryV2 @Inject constructor( private val device: Device, private val clock: SnodeClock, private val loginStateRepository: LoginStateRepository, - private val sessionNetwork: SessionNetwork + private val serverClient: ServerClient ) { suspend fun register( @@ -114,7 +114,7 @@ class PushRegistryV2 @Inject constructor( val url = "${server.url}/$path" val body = requestParameters.toRequestBody("application/json".toMediaType()) val request = Request.Builder().url(url).post(body).build() - val response = sessionNetwork.sendToServer( + val response = serverClient.send( request = request, serverBaseUrl = server.url, x25519PublicKey = server.publicKey, diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt index d8c96f2568..6b5ad4b652 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt @@ -34,7 +34,7 @@ import okio.source import org.session.libsession.database.StorageProtocol import org.session.libsession.database.userAuth import org.session.libsession.messaging.open_groups.OpenGroupApi -import org.session.libsession.network.SessionClient +import org.session.libsession.network.SnodeClient import org.session.libsession.network.model.PathStatus import org.session.libsession.network.onion.PathManager import org.session.libsession.utilities.StringSubstitutionConstants.VERSION_KEY @@ -88,7 +88,7 @@ class SettingsViewModel @Inject constructor( private val proDetailsRepository: ProDetailsRepository, private val donationManager: DonationManager, private val pathManager: PathManager, - private val sessionClient: SessionClient + private val snodeClient: SnodeClient ) : ViewModel() { private val TAG = "SettingsViewModel" @@ -468,7 +468,7 @@ class SettingsViewModel @Inject constructor( }.joinAll() } - sessionClient.deleteAllMessages(checkNotNull(storage.userAuth)) + snodeClient.deleteAllMessages(checkNotNull(storage.userAuth)) } catch (e: Exception) { Log.e(TAG, "Failed to delete network messages - offering user option to delete local data only.", e) null diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApiExecutor.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApiExecutor.kt index c74f5ce94b..02a962eb58 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApiExecutor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApiExecutor.kt @@ -9,7 +9,7 @@ import kotlinx.serialization.json.decodeFromStream import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody -import org.session.libsession.network.SessionNetwork +import org.session.libsession.network.ServerClient import org.thoughtcrime.securesms.pro.ProBackendConfig import javax.inject.Inject import javax.inject.Provider @@ -17,7 +17,7 @@ import javax.inject.Provider class ProApiExecutor @Inject constructor( private val json: Json, private val proConfigProvider: Provider, - private val sessionNetwork: SessionNetwork, + private val serverClient: ServerClient ) { @Serializable private data class RawProApiResponse( @@ -57,7 +57,7 @@ class ProApiExecutor @Inject constructor( ): ProApiResponse { val config = proConfigProvider.get() - val rawResp = sessionNetwork.sendToServer( + val rawResp = serverClient.send( request = Request.Builder() .url(config.url.resolve(request.endpoint)!!) .post( diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/DefaultConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/DefaultConversationRepository.kt index 4becf59567..04cd3f9717 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/DefaultConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/DefaultConversationRepository.kt @@ -24,7 +24,7 @@ import org.session.libsession.messaging.messages.visible.OpenGroupInvitation import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.MessageSender -import org.session.libsession.network.SessionClient +import org.session.libsession.network.SnodeClient import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress @@ -79,7 +79,7 @@ class DefaultConversationRepository @Inject constructor( private val messageSender: MessageSender, private val loginStateRepository: LoginStateRepository, private val proStatusManager: ProStatusManager, - private val sessionClient: SessionClient + private val snodeClient: SnodeClient ) : ConversationRepository { override val conversationListAddressesFlow get() = loginStateRepository.flowWithLoggedInState { @@ -355,7 +355,7 @@ class DefaultConversationRepository @Inject constructor( // delete from swarm messageDataProvider.getServerHashForMessage(message.messageId) ?.let { serverHash -> - sessionClient.deleteMessage(recipient.address, userAuth, listOf(serverHash)) + snodeClient.deleteMessage(recipient.address, userAuth, listOf(serverHash)) } // send an UnsendRequest to user's swarm @@ -412,7 +412,7 @@ class DefaultConversationRepository @Inject constructor( // delete from swarm messageDataProvider.getServerHashForMessage(message.messageId) ?.let { serverHash -> - sessionClient.deleteMessage(recipient.address, userAuth, listOf(serverHash)) + snodeClient.deleteMessage(recipient.address, userAuth, listOf(serverHash)) } // send an UnsendRequest to user's swarm diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenRepository.kt index ed8a3a9fb4..4c37833c71 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenRepository.kt @@ -10,7 +10,7 @@ import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.file_server.FileServerApi -import org.session.libsession.network.SessionNetwork +import org.session.libsession.network.ServerClient import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.toHexString @@ -27,7 +27,7 @@ class TokenRepositoryImpl @Inject constructor( @param:ApplicationContext val context: Context, private val storage: StorageProtocol, private val json: Json, - private val sessionNetwork: SessionNetwork + private val serverClient: ServerClient ): TokenRepository { private val TAG = "TokenRepository" @@ -89,7 +89,7 @@ class TokenRepositoryImpl @Inject constructor( var response: T? = null try { - val rawResponse = sessionNetwork.sendToServer( + val rawResponse = serverClient.send( request = request, serverBaseUrl = TOKEN_SERVER_URL, // Note: The `request` contains the actual endpoint we'll hit x25519PublicKey = SERVER_PUBLIC_KEY From 593aa4993bfe3a9fd19c2c1f75e0875356211ea9 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 6 Jan 2026 14:26:31 +1100 Subject: [PATCH 33/77] Adding snode to errors when possible. Removed loud logs --- .../libsession/network/SessionNetwork.kt | 2 +- .../session/libsession/network/SnodeClient.kt | 3 ++- .../libsession/network/model/OnionError.kt | 13 +++++++++---- .../network/onion/OnionErrorManager.kt | 3 ++- .../network/onion/http/HttpOnionTransport.kt | 17 ++++++++++------- 5 files changed, 24 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt index ffba8e6920..e25f5fdcd0 100644 --- a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt +++ b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt @@ -55,7 +55,7 @@ class SessionNetwork @Inject constructor( for (attempt in 1..maxAttempts) { val path: Path = pathManager.getPath(exclude = snodeToExclude) - Log.i("Onion Request", "Sending onion request to $destination - attempt $attempt/$maxAttempts") + //Log.i("Onion Request", "Sending onion request to $destination - attempt $attempt/$maxAttempts") try { val result = transport.send( diff --git a/app/src/main/java/org/session/libsession/network/SnodeClient.kt b/app/src/main/java/org/session/libsession/network/SnodeClient.kt index a15385666f..7339aaf5a3 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClient.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClient.kt @@ -567,7 +567,8 @@ class SnodeClient @Inject constructor( // we synthesise a DestinationError since what we get at this point is from the destination's response val err = OnionError.DestinationError( - ErrorStatus(code = item.code, message = null, body = bodySlice) + status = ErrorStatus(code = item.code, message = null, body = bodySlice), + destination = OnionDestination.SnodeDestination(targetSnode) ) return errorManager.onFailure( diff --git a/app/src/main/java/org/session/libsession/network/model/OnionError.kt b/app/src/main/java/org/session/libsession/network/model/OnionError.kt index 17299d8523..227f578569 100644 --- a/app/src/main/java/org/session/libsession/network/model/OnionError.kt +++ b/app/src/main/java/org/session/libsession/network/model/OnionError.kt @@ -17,6 +17,7 @@ enum class ErrorOrigin { UNKNOWN, TRANSPORT_TO_GUARD, PATH_HOP, DESTINATION_REPL sealed class OnionError( val origin: ErrorOrigin, val status: ErrorStatus? = null, + val snode: Snode? = null, cause: Throwable? = null ) : Exception(status?.message ?: "Onion error", cause) { @@ -34,19 +35,23 @@ sealed class OnionError( class IntermediateNodeFailed( val reportingNode: Snode?, val failedPublicKey: String? - ) : OnionError(ErrorOrigin.PATH_HOP) + ) : OnionError(origin = ErrorOrigin.PATH_HOP, snode = reportingNode) /** * The error happened, as far as we can tell, along the path on the way to the destination */ class PathError(val node: Snode?, status: ErrorStatus) - : OnionError(ErrorOrigin.PATH_HOP, status = status) + : OnionError(ErrorOrigin.PATH_HOP, status = status, snode = node) /** * The error happened after decrypting a payload form the destination */ - class DestinationError(status: ErrorStatus) - : OnionError(ErrorOrigin.DESTINATION_REPLY, status = status) + class DestinationError(val destination: OnionDestination, status: ErrorStatus) + : OnionError( + ErrorOrigin.DESTINATION_REPLY, + status = status, + snode = (destination as? OnionDestination.SnodeDestination)?.snode + ) /** * The onion payload returned something that we couldn't decode as a valid onion response. diff --git a/app/src/main/java/org/session/libsession/network/onion/OnionErrorManager.kt b/app/src/main/java/org/session/libsession/network/onion/OnionErrorManager.kt index 01eef5bb3a..c73e1e1400 100644 --- a/app/src/main/java/org/session/libsession/network/onion/OnionErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/onion/OnionErrorManager.kt @@ -33,10 +33,11 @@ class OnionErrorManager @Inject constructor( // 406/425: clock out of sync if (code == 406 || code == 425) { + Log.w("Onion Request", "Clock out of sync (code: $code) for destination ${ctx.targetSnode?.address} at node: ${error.snode?.address}") // Do not penalise path or snode. Reset the clock. Retry if reset succeeded. val resetOk = runCatching { //snodeClock.resync() - //todo ONION Can we do some clock reset here? + //todo ONION We should poll three random snode and use their median time - retry initial logic. If we still get an out of sync error, we should penalise the snode, and try again with another false }.getOrDefault(false) return if (resetOk) FailureDecision.Retry else FailureDecision.Fail(error) diff --git a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt index 2c9ef01495..78ebc36ff4 100644 --- a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt +++ b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt @@ -119,10 +119,10 @@ class HttpOnionTransport @Inject constructor( destination: OnionDestination, version: Version ): OnionResponse { - Log.i("Onion Request", "Got a successful response from request") + //Log.i("Onion Request", "Got a successful response from request") return when (version) { Version.V4 -> handleV4Response(rawResponse, destinationSymmetricKey, destination) - Version.V2, Version.V3 -> handleV2V3Response(rawResponse, destinationSymmetricKey) + Version.V2, Version.V3 -> handleV2V3Response(rawResponse, destinationSymmetricKey, destination) } } @@ -174,7 +174,8 @@ class HttpOnionTransport @Inject constructor( code = statusCode, message = responseInfo["message"]?.toString(), body = bodySlice - ) + ), + destination = destination ) } @@ -193,7 +194,8 @@ class HttpOnionTransport @Inject constructor( private fun handleV2V3Response( rawResponse: ByteArray, - destinationSymmetricKey: ByteArray + destinationSymmetricKey: ByteArray, + destination:OnionDestination ): OnionResponse { // Outer wrapper: {"result": ""} val jsonWrapper: Map<*, *> = try { @@ -266,11 +268,12 @@ class HttpOnionTransport @Inject constructor( if (statusCode !in 200..299) { val errorMap = (normalizedBody as? Map<*, *>) ?: innerJson throw OnionError.DestinationError( - ErrorStatus( + status = ErrorStatus( code = statusCode, message = extractMessage(errorMap), - body = JsonUtil.toJson(errorMap).toByteArray().view() - ) + body = JsonUtil.toJson(errorMap).toByteArray().view(), + ), + destination = destination ) } From b45702c0533ba498cf34fce24f11afe1324c84bc Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 6 Jan 2026 15:13:06 +1100 Subject: [PATCH 34/77] Keeping reference to previous errors during the retry logic --- .../org/session/libsession/network/SessionNetwork.kt | 9 +++++---- .../java/org/session/libsession/network/SnodeClient.kt | 3 ++- .../libsession/network/onion/OnionErrorManager.kt | 3 ++- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt index e25f5fdcd0..42e3f109c0 100644 --- a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt +++ b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt @@ -51,7 +51,7 @@ class SessionNetwork @Inject constructor( targetSnode: Snode?, publicKey: String? ): OnionResponse { - var lastError: Throwable? = null + var lastError: OnionError? = null for (attempt in 1..maxAttempts) { val path: Path = pathManager.getPath(exclude = snodeToExclude) @@ -71,8 +71,6 @@ class SessionNetwork @Inject constructor( Log.w("Onion Request", "Onion error on attempt $attempt/$maxAttempts: $onionError") - lastError = onionError - // Delegate all handling + retry decision val decision = errorManager.onFailure( error = onionError, @@ -80,10 +78,13 @@ class SessionNetwork @Inject constructor( path = path, destination = destination, targetSnode = targetSnode, - publicKey = publicKey + publicKey = publicKey, + previousError = lastError ) ) + lastError = onionError + when (decision) { is FailureDecision.Fail -> throw decision.throwable FailureDecision.Retry -> { diff --git a/app/src/main/java/org/session/libsession/network/SnodeClient.kt b/app/src/main/java/org/session/libsession/network/SnodeClient.kt index 7339aaf5a3..aaaeafb473 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClient.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClient.kt @@ -577,7 +577,8 @@ class SnodeClient @Inject constructor( path = listOf(targetSnode), destination = OnionDestination.SnodeDestination(targetSnode), targetSnode = targetSnode, - publicKey = publicKey + publicKey = publicKey, + previousError = null //todo ONION can we set this properly? ) ) } diff --git a/app/src/main/java/org/session/libsession/network/onion/OnionErrorManager.kt b/app/src/main/java/org/session/libsession/network/onion/OnionErrorManager.kt index c73e1e1400..00ab08bafe 100644 --- a/app/src/main/java/org/session/libsession/network/onion/OnionErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/onion/OnionErrorManager.kt @@ -134,7 +134,8 @@ data class OnionFailureContext( val path: Path, val destination: OnionDestination, val targetSnode: Snode? = null, - val publicKey: String? = null + val publicKey: String? = null, + val previousError: OnionError? = null // in some situations we could be coming from a retry to a previous error ) sealed class FailureDecision { From 1b092e92e0cc690f750952342988a43429602a28 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 6 Jan 2026 15:37:04 +1100 Subject: [PATCH 35/77] Snodeclock resync --- .../session/libsession/network/SnodeClock.kt | 104 +++++++++++------- 1 file changed, 65 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/SnodeClock.kt b/app/src/main/java/org/session/libsession/network/SnodeClock.kt index f4c51f34d3..8b09f30308 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClock.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClock.kt @@ -1,14 +1,20 @@ +// SnodeClock.kt package org.session.libsession.network import android.os.SystemClock import dagger.Lazy import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withTimeout import org.session.libsession.network.snode.SnodeDirectory import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.dependencies.ManagerScope @@ -36,46 +42,70 @@ class SnodeClock @Inject constructor( // can this be improved? private val instantState = MutableStateFlow(null) - private var job: Job? = null override fun onPostAppStarted() { - require(job == null) { "Already started" } - - job = scope.launch { - while (true) { - try { - val node = snodeDirectory.getRandomSnode() - val requestStarted = SystemClock.elapsedRealtime() - - var networkTime = snodeClient.get().getNetworkTime(node).second - val requestEnded = SystemClock.elapsedRealtime() - - // Adjust network time to halfway through the request duration - networkTime -= (requestEnded - requestStarted) / 2 - - val inst = Instant(requestStarted, networkTime) - - Log.d( - "SnodeClock", - "Network time: ${Date(inst.now())}, system time: ${Date()}" - ) - - instantState.value = inst - } catch (e: Exception) { - Log.e("SnodeClock", "Failed to get network time. Retrying in a few seconds", e) - } finally { - val delayMills = if (instantState.value == null) { - 3_000L - } else { - 3_600_000L - } - - delay(delayMills) + scope.launch { + resyncClock() + } + } + + /** + * Resync by querying 3 random snodes and setting time to the median of their adjusted times. + */ + suspend fun resyncClock(): Boolean { + return runCatching { + // Keep it bounded - clock sync shouldn't hang onion retries forever + withTimeout(8_000L) { + val nodes = pickDistinctRandomSnodes(count = 3) + + val samples: List> = supervisorScope { + nodes.map { node -> + async { + val requestStarted = SystemClock.elapsedRealtime() + var networkTime = snodeClient.get().getNetworkTime(node).second + val requestEnded = SystemClock.elapsedRealtime() + + // midpoint adjustment + networkTime -= (requestEnded - requestStarted) / 2 + + // (systemUptimeAtStart, adjustedNetworkTimeAtStart) + requestStarted to networkTime + } + }.awaitAll() } + + // Convert all samples to "time at (roughly) now" so they’re comparable, + // then take the median. + val nowUptime = SystemClock.elapsedRealtime() + val candidateNowTimes = samples.map { (uptimeAtStart, adjustedAtStart) -> + val elapsed = nowUptime - uptimeAtStart + adjustedAtStart + elapsed + }.sorted() + + val medianNow = candidateNowTimes[candidateNowTimes.size / 2] + + // Store as (systemUptimeNow, networkTimeNow) + val inst = Instant(systemUptime = nowUptime, networkTime = medianNow) + instantState.value = inst + + Log.d("SnodeClock", "Resynced. Network time: ${Date(inst.now())}, system time: ${Date()}") + true } + }.getOrElse { t -> + Log.w("SnodeClock", "Resync failed", t) + false } } + private suspend fun pickDistinctRandomSnodes(count: Int): List { + val out = LinkedHashSet(count) + var guard = 0 + while (out.size < count && guard++ < 20) { + out += snodeDirectory.getRandomSnode() + } + return out.toList() + } + /** * Wait for the network adjusted time to come through. */ @@ -91,13 +121,9 @@ class SnodeClock @Inject constructor( return instantState.value?.now() ?: System.currentTimeMillis() } - fun currentTimeSeconds(): Long { - return currentTimeMills() / 1000 - } + fun currentTimeSeconds(): Long = currentTimeMills() / 1000 - fun currentTime(): java.time.Instant { - return java.time.Instant.ofEpochMilli(currentTimeMills()) - } + fun currentTime(): java.time.Instant = java.time.Instant.ofEpochMilli(currentTimeMills()) /** * Delay until the specified instant. If the instant is in the past or now, this method returns From 79dc0ed77837d2a7b4bcc72d25c0e5fbf5ed4f9b Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 6 Jan 2026 15:38:52 +1100 Subject: [PATCH 36/77] tightening the coroutines --- .../session/libsession/network/SnodeClock.kt | 32 +++++++------------ 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/SnodeClock.kt b/app/src/main/java/org/session/libsession/network/SnodeClock.kt index 8b09f30308..ca6379b692 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClock.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClock.kt @@ -54,41 +54,33 @@ class SnodeClock @Inject constructor( */ suspend fun resyncClock(): Boolean { return runCatching { - // Keep it bounded - clock sync shouldn't hang onion retries forever withTimeout(8_000L) { val nodes = pickDistinctRandomSnodes(count = 3) val samples: List> = supervisorScope { nodes.map { node -> async { - val requestStarted = SystemClock.elapsedRealtime() - var networkTime = snodeClient.get().getNetworkTime(node).second - val requestEnded = SystemClock.elapsedRealtime() - - // midpoint adjustment - networkTime -= (requestEnded - requestStarted) / 2 - - // (systemUptimeAtStart, adjustedNetworkTimeAtStart) - requestStarted to networkTime + runCatching { + val requestStarted = SystemClock.elapsedRealtime() + var networkTime = snodeClient.get().getNetworkTime(node).second + val requestEnded = SystemClock.elapsedRealtime() + + networkTime -= (requestEnded - requestStarted) / 2 + requestStarted to networkTime + }.getOrNull() } - }.awaitAll() + }.awaitAll().filterNotNull() } - // Convert all samples to "time at (roughly) now" so they’re comparable, - // then take the median. val nowUptime = SystemClock.elapsedRealtime() val candidateNowTimes = samples.map { (uptimeAtStart, adjustedAtStart) -> - val elapsed = nowUptime - uptimeAtStart - adjustedAtStart + elapsed + adjustedAtStart + (nowUptime - uptimeAtStart) }.sorted() val medianNow = candidateNowTimes[candidateNowTimes.size / 2] + instantState.value = Instant(systemUptime = nowUptime, networkTime = medianNow) - // Store as (systemUptimeNow, networkTimeNow) - val inst = Instant(systemUptime = nowUptime, networkTime = medianNow) - instantState.value = inst - - Log.d("SnodeClock", "Resynced. Network time: ${Date(inst.now())}, system time: ${Date()}") + Log.d("SnodeClock", "Resynced. Network time: ${Date(medianNow)}, system time: ${Date()}") true } }.getOrElse { t -> From ab116c1478c56907a45371ee879d01bebf5b0a9c Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 6 Jan 2026 15:54:16 +1100 Subject: [PATCH 37/77] Removing NotifyPNServerJob --- .../libsession/messaging/jobs/JobQueue.kt | 2 - .../messaging/jobs/NotifyPNServerJob.kt | 106 ------------------ .../jobs/SessionJobManagerFactories.kt | 1 - .../network/onion/OnionErrorManager.kt | 3 +- 4 files changed, 1 insertion(+), 111 deletions(-) delete mode 100644 app/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt b/app/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt index 8373c344e9..ea60427a04 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt @@ -120,7 +120,6 @@ class JobQueue : JobDelegate { while (isActive) { when (val job = queue.receive()) { is InviteContactsJob, - is NotifyPNServerJob, is AttachmentUploadJob, is MessageSendJob -> { txQueue.send(job) @@ -219,7 +218,6 @@ class JobQueue : JobDelegate { AttachmentUploadJob.KEY, AttachmentDownloadJob.KEY, MessageSendJob.KEY, - NotifyPNServerJob.KEY, OpenGroupDeleteJob.KEY, InviteContactsJob.KEY, ) diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt deleted file mode 100644 index b69408525a..0000000000 --- a/app/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt +++ /dev/null @@ -1,106 +0,0 @@ -package org.session.libsession.messaging.jobs - -import com.esotericsoftware.kryo.Kryo -import com.esotericsoftware.kryo.io.Input -import com.esotericsoftware.kryo.io.Output -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.Request -import okhttp3.RequestBody -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.jobs.Job.Companion.MAX_BUFFER_SIZE_BYTES -import org.session.libsession.messaging.sending_receiving.notifications.Server -import org.session.libsession.messaging.utilities.Data -import org.session.libsession.network.ServerClient -import org.session.libsession.network.onion.Version -import org.session.libsession.snode.SnodeMessage -import org.session.libsignal.utilities.JsonUtil -import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.retryWithUniformInterval -import kotlin.coroutines.cancellation.CancellationException - -class NotifyPNServerJob(val message: SnodeMessage) : Job { - override var delegate: JobDelegate? = null - override var id: String? = null - override var failureCount: Int = 0 - - override val maxFailureCount: Int = 20 - - private val serverClient: ServerClient by lazy { - MessagingModuleConfiguration.shared.serverClient - } - - companion object { - val KEY: String = "NotifyPNServerJob" - - // Keys used for database storage - private val MESSAGE_KEY = "message" - } - - override suspend fun execute(dispatcherName: String) { - val server = Server.LEGACY - val parameters = mapOf("data" to message.data, "send_to" to message.recipient) - val url = "${server.url}/notify" - val body = RequestBody.create("application/json".toMediaType(), JsonUtil.toJson(parameters)) - val request = Request.Builder().url(url).post(body).build() - - try { - // High-level application retry (4 attempts) - retryWithUniformInterval(maxRetryCount = 4) { - serverClient.send( - request = request, - serverBaseUrl = server.url, - x25519PublicKey = server.publicKey, - version = Version.V2 - ) - - // todo ONION the old code was checking the status code on success and if it is null or 0 it would log it as a fail - // the new structure however throws all non 200.299 status as an OnionError - } - - handleSuccess(dispatcherName) - - } catch (e: Exception) { - if (e is CancellationException) throw e - - Log.d("NotifyPNServerJob", "Couldn't notify PN server due to error: $e.") - handleFailure(dispatcherName, e) - } - } - - private fun handleSuccess(dispatcherName: String) { - delegate?.handleJobSucceeded(this, dispatcherName) - } - - private fun handleFailure(dispatcherName: String, error: Exception) { - delegate?.handleJobFailed(this, dispatcherName, error) - } - - override fun serialize(): Data { - val kryo = Kryo() - kryo.isRegistrationRequired = false - val serializedMessage = ByteArray(4096) - val output = Output(serializedMessage, MAX_BUFFER_SIZE_BYTES) - kryo.writeObject(output, message) - output.close() - return Data.Builder() - .putByteArray(MESSAGE_KEY, serializedMessage) - .build(); - } - - override fun getFactoryKey(): String { - return KEY - } - - class DeserializeFactory : Job.DeserializeFactory { - - override fun create(data: Data): NotifyPNServerJob { - val serializedMessage = data.getByteArray(MESSAGE_KEY) - val kryo = Kryo() - kryo.isRegistrationRequired = false - val input = Input(serializedMessage) - val message = kryo.readObject(input, SnodeMessage::class.java) - input.close() - return NotifyPNServerJob(message) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt b/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt index c155d3847f..663ee8079e 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt @@ -16,7 +16,6 @@ class SessionJobManagerFactories @Inject constructor( AttachmentDownloadJob.KEY to attachmentDownloadJobFactory, AttachmentUploadJob.KEY to attachmentUploadJobFactory, MessageSendJob.KEY to messageSendJobFactory, - NotifyPNServerJob.KEY to NotifyPNServerJob.DeserializeFactory(), TrimThreadJob.KEY to trimThreadFactory, OpenGroupDeleteJob.KEY to deleteJobFactory, InviteContactsJob.KEY to inviteContactsJobFactory, diff --git a/app/src/main/java/org/session/libsession/network/onion/OnionErrorManager.kt b/app/src/main/java/org/session/libsession/network/onion/OnionErrorManager.kt index 00ab08bafe..8c4bcaa7da 100644 --- a/app/src/main/java/org/session/libsession/network/onion/OnionErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/onion/OnionErrorManager.kt @@ -36,9 +36,8 @@ class OnionErrorManager @Inject constructor( Log.w("Onion Request", "Clock out of sync (code: $code) for destination ${ctx.targetSnode?.address} at node: ${error.snode?.address}") // Do not penalise path or snode. Reset the clock. Retry if reset succeeded. val resetOk = runCatching { - //snodeClock.resync() + snodeClock.resyncClock() //todo ONION We should poll three random snode and use their median time - retry initial logic. If we still get an out of sync error, we should penalise the snode, and try again with another - false }.getOrDefault(false) return if (resetOk) FailureDecision.Retry else FailureDecision.Fail(error) } From b9a0504e592a95ea023bcc0ffabc007a39817627 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 7 Jan 2026 10:12:48 +1100 Subject: [PATCH 38/77] Reworking error management --- ...ErrorManager.kt => NetworkErrorManager.kt} | 36 ++++++++++++------- .../libsession/network/SessionNetwork.kt | 4 --- .../session/libsession/network/SnodeClient.kt | 3 -- .../libsession/network/model/OnionError.kt | 10 +++++- 4 files changed, 32 insertions(+), 21 deletions(-) rename app/src/main/java/org/session/libsession/network/{onion/OnionErrorManager.kt => NetworkErrorManager.kt} (77%) diff --git a/app/src/main/java/org/session/libsession/network/onion/OnionErrorManager.kt b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt similarity index 77% rename from app/src/main/java/org/session/libsession/network/onion/OnionErrorManager.kt rename to app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt index 8c4bcaa7da..b3c223d854 100644 --- a/app/src/main/java/org/session/libsession/network/onion/OnionErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt @@ -1,9 +1,9 @@ -package org.session.libsession.network.onion +package org.session.libsession.network -import org.session.libsession.network.SnodeClock import org.session.libsession.network.model.OnionDestination import org.session.libsession.network.model.OnionError import org.session.libsession.network.model.Path +import org.session.libsession.network.onion.PathManager import org.session.libsession.network.snode.SnodeDirectory import org.session.libsession.network.snode.SwarmDirectory import org.session.libsignal.utilities.Log @@ -31,17 +31,6 @@ class OnionErrorManager @Inject constructor( // 1) "Found anywhere" rules (path OR destination) // -------------------------------------------------------------------- - // 406/425: clock out of sync - if (code == 406 || code == 425) { - Log.w("Onion Request", "Clock out of sync (code: $code) for destination ${ctx.targetSnode?.address} at node: ${error.snode?.address}") - // Do not penalise path or snode. Reset the clock. Retry if reset succeeded. - val resetOk = runCatching { - snodeClock.resyncClock() - //todo ONION We should poll three random snode and use their median time - retry initial logic. If we still get an out of sync error, we should penalise the snode, and try again with another - }.getOrDefault(false) - return if (resetOk) FailureDecision.Retry else FailureDecision.Fail(error) - } - // 400, 403, 404: do not penalise path or snode; No retries if (code == 400 || code == 403 || code == 404) { //todo ONION need to move the REQUIRE_BLINDING_MESSAGE logic out of here, it should be handled at the calling site, in this case the community poller, to then call /capabilities once @@ -98,7 +87,28 @@ class OnionErrorManager @Inject constructor( // 3) Destination payload rules // -------------------------------------------------------------------- if (error is OnionError.DestinationError) { + // 406/425: clock out of sync (COS) + // 406 is COS only for a snode destination + // 425 is COS only for a server destination + if ((code == 406 && ctx.destination is OnionDestination.SnodeDestination) + || (code == 425 && ctx.destination is OnionDestination.ServerDestination)) + { + Log.w("Onion Request", "Clock out of sync (code: $code) for destination ${ctx.targetSnode?.address} at node: ${error.snode?.address} - Local Snode clock at ${snodeClock.currentTime()}") + // Attempt to reset the clock. + // The retry logic for COS shouldn't be handled here, but at a higher level + // since the clock will be reset, meaning request that need a timestamp will need + // to be recreated, so the responsibility should like on a layer further up + // for example in the SnodeErrorManager or ServerErrorManager + runCatching { + snodeClock.resyncClock() + //todo ONION Add retry logic in the snode and server error managers. If we still get an out of sync error, we should penalise the snode, and try again with another + }.getOrDefault(false) + + return FailureDecision.Fail(OnionError.ClockOutOfSync(error.destination, error.status)) + } + // 421: snode isn't associated with pubkey anymore -> update swarm / invalidate -> retry + //todo ONION this should be moved to SnodeErrorManager if (code == 421) { val publicKey = ctx.publicKey val targetSnode = ctx.targetSnode diff --git a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt index 42e3f109c0..6fd0bacad3 100644 --- a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt +++ b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt @@ -1,14 +1,10 @@ package org.session.libsession.network -import okhttp3.Request import kotlinx.coroutines.delay import org.session.libsession.network.model.OnionDestination import org.session.libsession.network.model.OnionError import org.session.libsession.network.model.OnionResponse import org.session.libsession.network.model.Path -import org.session.libsession.network.onion.OnionErrorManager -import org.session.libsession.network.onion.OnionFailureContext -import org.session.libsession.network.onion.FailureDecision import org.session.libsession.network.onion.OnionTransport import org.session.libsession.network.onion.PathManager import org.session.libsession.network.onion.Version diff --git a/app/src/main/java/org/session/libsession/network/SnodeClient.kt b/app/src/main/java/org/session/libsession/network/SnodeClient.kt index aaaeafb473..25dd1f923e 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClient.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClient.kt @@ -21,9 +21,6 @@ import network.loki.messenger.libsession_util.SessionEncrypt import org.session.libsession.network.model.ErrorStatus import org.session.libsession.network.model.OnionDestination import org.session.libsession.network.model.OnionError -import org.session.libsession.network.onion.FailureDecision -import org.session.libsession.network.onion.OnionErrorManager -import org.session.libsession.network.onion.OnionFailureContext import org.session.libsession.network.onion.Version import org.session.libsession.network.snode.SnodeDirectory import org.session.libsession.network.snode.SwarmDirectory diff --git a/app/src/main/java/org/session/libsession/network/model/OnionError.kt b/app/src/main/java/org/session/libsession/network/model/OnionError.kt index 227f578569..16686af5b2 100644 --- a/app/src/main/java/org/session/libsession/network/model/OnionError.kt +++ b/app/src/main/java/org/session/libsession/network/model/OnionError.kt @@ -46,13 +46,21 @@ sealed class OnionError( /** * The error happened after decrypting a payload form the destination */ - class DestinationError(val destination: OnionDestination, status: ErrorStatus) + open class DestinationError(val destination: OnionDestination, status: ErrorStatus) : OnionError( ErrorOrigin.DESTINATION_REPLY, status = status, snode = (destination as? OnionDestination.SnodeDestination)?.snode ) + /** + * A subcategory of a destination error. + * This indicates we got told our clock is out of sync and the client should resync its clock + */ + class ClockOutOfSync(destination: OnionDestination, status: ErrorStatus) + : DestinationError(destination, status) + + /** * The onion payload returned something that we couldn't decode as a valid onion response. */ From 4b20c0925f5df4a928fc78440ddefe8c84913f46 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 7 Jan 2026 10:16:36 +1100 Subject: [PATCH 39/77] Using Path --- .../java/org/session/libsession/network/onion/OnionBuilder.kt | 3 ++- .../org/session/libsession/network/onion/OnionTransport.kt | 3 ++- .../libsession/network/onion/http/HttpOnionTransport.kt | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/onion/OnionBuilder.kt b/app/src/main/java/org/session/libsession/network/onion/OnionBuilder.kt index c57438780d..608a50d5f2 100644 --- a/app/src/main/java/org/session/libsession/network/onion/OnionBuilder.kt +++ b/app/src/main/java/org/session/libsession/network/onion/OnionBuilder.kt @@ -1,6 +1,7 @@ package org.session.libsession.network.onion import org.session.libsession.network.model.OnionDestination +import org.session.libsession.network.model.Path import org.session.libsession.utilities.AESGCM.EncryptionResult import org.session.libsignal.utilities.Snode @@ -14,7 +15,7 @@ object OnionBuilder { ) fun build( - path: List, + path: Path, destination: OnionDestination, payload: ByteArray, version: Version diff --git a/app/src/main/java/org/session/libsession/network/onion/OnionTransport.kt b/app/src/main/java/org/session/libsession/network/onion/OnionTransport.kt index 7fc2d9d634..9478abef56 100644 --- a/app/src/main/java/org/session/libsession/network/onion/OnionTransport.kt +++ b/app/src/main/java/org/session/libsession/network/onion/OnionTransport.kt @@ -2,6 +2,7 @@ package org.session.libsession.network.onion import org.session.libsession.network.model.OnionDestination import org.session.libsession.network.model.OnionResponse +import org.session.libsession.network.model.Path import org.session.libsignal.utilities.Snode interface OnionTransport { @@ -10,7 +11,7 @@ interface OnionTransport { * */ suspend fun send( - path: List, + path: Path, destination: OnionDestination, payload: ByteArray, version: Version diff --git a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt index 78ebc36ff4..aa716685d7 100644 --- a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt +++ b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt @@ -5,6 +5,7 @@ import org.session.libsession.network.model.ErrorStatus import org.session.libsession.network.model.OnionDestination import org.session.libsession.network.model.OnionError import org.session.libsession.network.model.OnionResponse +import org.session.libsession.network.model.Path import org.session.libsession.network.onion.OnionBuilder import org.session.libsession.network.onion.OnionRequestEncryption import org.session.libsession.network.onion.OnionTransport @@ -29,7 +30,7 @@ class HttpOnionTransport @Inject constructor( ) : OnionTransport { override suspend fun send( - path: List, + path: Path, destination: OnionDestination, payload: ByteArray, version: Version From 5923d70441666cb6ed8f393c20f52eb3e6ae1a83 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 7 Jan 2026 11:16:48 +1100 Subject: [PATCH 40/77] Giving the Clients their own retry logic and error management --- .../libsession/network/NetworkErrorManager.kt | 37 ++------- .../libsession/network/ServerClient.kt | 73 +++++++++++++--- .../network/ServerClientErrorManager.kt | 54 ++++++++++++ .../libsession/network/SessionNetwork.kt | 5 +- .../session/libsession/network/SnodeClient.kt | 83 +++++++++++++++---- .../network/SnodeClientErrorManager.kt | 81 ++++++++++++++++++ .../network/model/FailureDecision.kt | 6 ++ 7 files changed, 277 insertions(+), 62 deletions(-) create mode 100644 app/src/main/java/org/session/libsession/network/ServerClientErrorManager.kt create mode 100644 app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt create mode 100644 app/src/main/java/org/session/libsession/network/model/FailureDecision.kt diff --git a/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt index b3c223d854..0e8d515db2 100644 --- a/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt @@ -1,5 +1,6 @@ package org.session.libsession.network +import org.session.libsession.network.model.FailureDecision import org.session.libsession.network.model.OnionDestination import org.session.libsession.network.model.OnionError import org.session.libsession.network.model.Path @@ -15,14 +16,14 @@ private const val REQUIRE_BLINDING_MESSAGE = "Invalid authentication: this server requires the use of blinded ids" @Singleton -class OnionErrorManager @Inject constructor( +class NetworkErrorManager @Inject constructor( private val pathManager: PathManager, private val snodeDirectory: SnodeDirectory, private val swarmDirectory: SwarmDirectory, private val snodeClock: SnodeClock, ) { - suspend fun onFailure(error: OnionError, ctx: OnionFailureContext): FailureDecision { + suspend fun onFailure(error: OnionError, ctx: NetworkFailureContext): FailureDecision { val status = error.status val code = status?.code val bodyText = status?.bodyText @@ -107,29 +108,6 @@ class OnionErrorManager @Inject constructor( return FailureDecision.Fail(OnionError.ClockOutOfSync(error.destination, error.status)) } - // 421: snode isn't associated with pubkey anymore -> update swarm / invalidate -> retry - //todo ONION this should be moved to SnodeErrorManager - if (code == 421) { - val publicKey = ctx.publicKey - val targetSnode = ctx.targetSnode - - val updated = if (publicKey != null) { - swarmDirectory.updateSwarmFromResponse( - publicKey = publicKey, - body = status.body - ) - } else { - Log.w("Onion Request", "Got 421 without an associated public key.") - false - } - - if (!updated && publicKey != null && targetSnode != null) { - swarmDirectory.dropSnodeFromSwarmIfNeeded(targetSnode, publicKey) - } - - return FailureDecision.Retry - } - // Anything else from destination: do not penalise path; no retries return FailureDecision.Fail(error) } @@ -139,15 +117,10 @@ class OnionErrorManager @Inject constructor( } } -data class OnionFailureContext( +data class NetworkFailureContext( val path: Path, val destination: OnionDestination, val targetSnode: Snode? = null, val publicKey: String? = null, val previousError: OnionError? = null // in some situations we could be coming from a retry to a previous error -) - -sealed class FailureDecision { - data object Retry : FailureDecision() - data class Fail(val throwable: Throwable) : FailureDecision() -} \ No newline at end of file +) \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/network/ServerClient.kt b/app/src/main/java/org/session/libsession/network/ServerClient.kt index 3a21dc712f..3d58012bb8 100644 --- a/app/src/main/java/org/session/libsession/network/ServerClient.kt +++ b/app/src/main/java/org/session/libsession/network/ServerClient.kt @@ -1,14 +1,19 @@ package org.session.libsession.network +import kotlinx.coroutines.delay import okhttp3.Request +import org.session.libsession.network.model.FailureDecision import org.session.libsession.network.model.OnionDestination +import org.session.libsession.network.model.OnionError import org.session.libsession.network.model.OnionResponse import org.session.libsession.network.onion.Version import org.session.libsession.network.utilities.getBodyForOnionRequest import org.session.libsession.network.utilities.getHeadersForOnionRequest import org.session.libsignal.utilities.JsonUtil +import org.session.libsignal.utilities.Log import javax.inject.Inject import javax.inject.Singleton +import kotlin.random.Random /** * Responsible for encoding HTTP requests into the onion format (v3/v4) @@ -16,16 +21,21 @@ import javax.inject.Singleton */ @Singleton class ServerClient @Inject constructor( - private val sessionNetwork: SessionNetwork + private val sessionNetwork: SessionNetwork, + val errorManager: ServerClientErrorManager ) { + private val maxAttempts: Int = 2 + private val baseRetryDelayMs: Long = 250L + private val maxRetryDelayMs: Long = 2_000L + suspend fun send( request: Request, serverBaseUrl: String, x25519PublicKey: String, version: Version = Version.V4 ): OnionResponse { + //todo ONION rework Request o be recomputed on retries, for example to help with new timestamps val url = request.url - val payload = generatePayload(request, serverBaseUrl, version) val destination = OnionDestination.ServerDestination( host = url.host, @@ -35,14 +45,57 @@ class ServerClient @Inject constructor( port = url.port ) - return sessionNetwork.sendWithRetry( - destination = destination, - payload = payload, - version = version, - snodeToExclude = null, - targetSnode = null, - publicKey = null - ) + // the client has its own retry logic, independent from the SessionNetwork's retry logic + var lastError: OnionError? = null + for (attempt in 1..maxAttempts) { + + try { + val payload = generatePayload(request, serverBaseUrl, version) + + return sessionNetwork.sendWithRetry( + destination = destination, + payload = payload, + version = version, + snodeToExclude = null, + targetSnode = null, + publicKey = null + ) + } catch (e: Throwable) { + val onionError = e as? OnionError ?: OnionError.Unknown(e) + + Log.w("Onion Request", "Onion error on attempt $attempt/$maxAttempts: $onionError") + + // Delegate all handling + retry decision + val decision = errorManager.onFailure( + error = onionError, + ctx = ServerClientFailureContext( + url = url, + previousError = lastError + ) + ) + + lastError = onionError + + when (decision) { + is FailureDecision.Fail -> throw decision.throwable + FailureDecision.Retry -> { + if (attempt >= maxAttempts) break + delay(computeBackoffDelayMs(attempt)) + continue + } + } + } + } + + throw lastError ?: IllegalStateException("Unknown Server client error") + } + + private fun computeBackoffDelayMs(attempt: Int): Long { + // Exponential-ish: base * 2^(attempt-1), with jitter, capped + val exp = baseRetryDelayMs * (1L shl (attempt - 1).coerceAtMost(5)) + val capped = exp.coerceAtMost(maxRetryDelayMs) + val jitter = Random.nextLong(0, capped / 3 + 1) + return capped + jitter } private fun generatePayload(request: Request, server: String, version: Version): ByteArray { diff --git a/app/src/main/java/org/session/libsession/network/ServerClientErrorManager.kt b/app/src/main/java/org/session/libsession/network/ServerClientErrorManager.kt new file mode 100644 index 0000000000..8782d8038f --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/ServerClientErrorManager.kt @@ -0,0 +1,54 @@ +package org.session.libsession.network + +import okhttp3.HttpUrl +import org.session.libsession.network.model.FailureDecision +import org.session.libsession.network.model.OnionDestination +import org.session.libsession.network.model.OnionError +import org.session.libsession.network.onion.PathManager +import org.session.libsession.network.snode.SnodeDirectory +import org.session.libsession.network.snode.SwarmDirectory +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Snode +import javax.inject.Inject +import javax.inject.Singleton + + +@Singleton +class ServerClientErrorManager @Inject constructor() { + + suspend fun onFailure(error: OnionError, ctx: ServerClientFailureContext): FailureDecision { + val status = error.status + val code = status?.code + val bodyText = status?.bodyText + + // -------------------------------------------------------------------- + // Destination payload rules + // -------------------------------------------------------------------- + if (error is OnionError.DestinationError) { + // Clock Out Of Sync + if (error is OnionError.ClockOutOfSync) + { + // if this is the first time we got a COS, retry, since we should have resynced the clock + if(ctx.previousError == null){ + return FailureDecision.Retry + + } else { + // if we already got a COS, and syncing the clock wasn't enough + // there is nothing more to do with servers. Consider it a failed request + return FailureDecision.Fail(error) + } + } + + // Anything else from destination: do not penalise path; no retries + return FailureDecision.Fail(error) + } + + // Default: fail + return FailureDecision.Fail(error) + } +} + +data class ServerClientFailureContext( + val url: HttpUrl, + val previousError: OnionError? = null // in some situations we could be coming from a retry to a previous error +) \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt index 6fd0bacad3..e75641ce35 100644 --- a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt +++ b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt @@ -1,6 +1,7 @@ package org.session.libsession.network import kotlinx.coroutines.delay +import org.session.libsession.network.model.FailureDecision import org.session.libsession.network.model.OnionDestination import org.session.libsession.network.model.OnionError import org.session.libsession.network.model.OnionResponse @@ -31,7 +32,7 @@ import kotlin.random.Random class SessionNetwork @Inject constructor( private val pathManager: PathManager, private val transport: OnionTransport, - private val errorManager: OnionErrorManager, + private val errorManager: NetworkErrorManager, ) { private val maxAttempts: Int = 2 @@ -70,7 +71,7 @@ class SessionNetwork @Inject constructor( // Delegate all handling + retry decision val decision = errorManager.onFailure( error = onionError, - ctx = OnionFailureContext( + ctx = NetworkFailureContext( path = path, destination = destination, targetSnode = targetSnode, diff --git a/app/src/main/java/org/session/libsession/network/SnodeClient.kt b/app/src/main/java/org/session/libsession/network/SnodeClient.kt index 25dd1f923e..d5b6148139 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClient.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClient.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select @@ -19,6 +20,7 @@ import network.loki.messenger.libsession_util.ED25519 import network.loki.messenger.libsession_util.Hash import network.loki.messenger.libsession_util.SessionEncrypt import org.session.libsession.network.model.ErrorStatus +import org.session.libsession.network.model.FailureDecision import org.session.libsession.network.model.OnionDestination import org.session.libsession.network.model.OnionError import org.session.libsession.network.onion.Version @@ -40,9 +42,7 @@ import org.session.libsignal.utilities.Snode import java.util.Locale import javax.inject.Inject import javax.inject.Singleton -import kotlin.collections.component1 -import kotlin.collections.component2 -import kotlin.collections.get +import kotlin.random.Random /** * High-level client for interacting with snodes. @@ -54,7 +54,7 @@ class SnodeClient @Inject constructor( private val snodeDirectory: SnodeDirectory, private val snodeClock: SnodeClock, private val json: Json, - private val errorManager: OnionErrorManager + private val errorManager: SnodeClientErrorManager ) { //todo ONION missing retry strategies @@ -62,6 +62,10 @@ class SnodeClient @Inject constructor( private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val batchedRequestsSender: SendChannel + private val maxAttempts: Int = 2 + private val baseRetryDelayMs: Long = 250L + private val maxRetryDelayMs: Long = 2_000L + init { val batchRequests = Channel(capacity = Channel.UNLIMITED) batchedRequestsSender = batchRequests @@ -157,6 +161,7 @@ class SnodeClient @Inject constructor( publicKey: String? = null, version: Version = Version.V3 ): ByteArraySlice { + //todo ONION these need to be integrated properly as part of the retries for example to recalculate timestamps val payload = JsonUtil.toJson( mapOf( "method" to method.rawValue, @@ -166,17 +171,59 @@ class SnodeClient @Inject constructor( val destination = OnionDestination.SnodeDestination(snode) - val onionResponse = sessionNetwork.sendWithRetry( - destination = destination, - payload = payload, - version = version, - snodeToExclude = snode, - targetSnode = snode, - publicKey = publicKey - ) + // the client has its own retry logic, independent from the SessionNetwork's retry logic + var lastError: OnionError? = null + for (attempt in 1..maxAttempts) { + + try { + val onionResponse = sessionNetwork.sendWithRetry( + destination = destination, + payload = payload, + version = version, + snodeToExclude = snode, + targetSnode = snode, + publicKey = publicKey + ) + + return onionResponse.body + ?: throw Error.Generic("Empty body from snode for method $method") + } catch (e: Throwable) { + val onionError = e as? OnionError ?: OnionError.Unknown(e) + + Log.w("Onion Request", "Onion error on attempt $attempt/$maxAttempts: $onionError") + + // Delegate all handling + retry decision + val decision = errorManager.onFailure( + error = onionError, + ctx = SnodeClientFailureContext( + targetSnode = snode, + publicKey = publicKey, + previousError = lastError + ) + ) + + lastError = onionError + + when (decision) { + is FailureDecision.Fail -> throw decision.throwable + FailureDecision.Retry -> { + if (attempt >= maxAttempts) break + delay(computeBackoffDelayMs(attempt)) + continue + } + } + } + } - return onionResponse.body - ?: throw Error.Generic("Empty body from snode for method $method") + throw lastError ?: IllegalStateException("Unknown Snode client error") + } + + private fun computeBackoffDelayMs(attempt: Int): Long { + // Exponential-ish: base * 2^(attempt-1), with jitter, capped + val exp = baseRetryDelayMs * (1L shl (attempt - 1).coerceAtMost(5)) + val capped = exp.coerceAtMost(maxRetryDelayMs) + val jitter = Random.nextLong(0, capped / 3 + 1) + return capped + jitter } @OptIn(ExperimentalSerializationApi::class) @@ -223,6 +270,8 @@ class SnodeClient @Inject constructor( return JsonUtil.fromJson(body, Map::class.java) as Map<*, *> } + //todo ONION Remove usage of JsonUtils in all the networking class in favour of kotlinx serializer. Create new data classes instead of relying on Maps + //todo ONION the methods below haven't been fully refactored - This is part of the next step of this refactor // Client methods @@ -558,7 +607,6 @@ class SnodeClient @Inject constructor( publicKey: String?, ) : FailureDecision { //todo ONION can we think of a better way to integrate batching with error handling? Right now this is a temporary way to fit it into our system - // we might be missing things like the path or the message val bodySlice = item.body.toString().toByteArray(Charsets.UTF_8).view() @@ -568,11 +616,10 @@ class SnodeClient @Inject constructor( destination = OnionDestination.SnodeDestination(targetSnode) ) + //todo ONION this is now referring to the new SnodeclientErrorManager, meaning some logic, like clock resync, won't apply here. We might need to modify this return errorManager.onFailure( error = err, - ctx = OnionFailureContext( - path = listOf(targetSnode), - destination = OnionDestination.SnodeDestination(targetSnode), + ctx = SnodeClientFailureContext( targetSnode = targetSnode, publicKey = publicKey, previousError = null //todo ONION can we set this properly? diff --git a/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt b/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt new file mode 100644 index 0000000000..ce7c9104d2 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt @@ -0,0 +1,81 @@ +package org.session.libsession.network + +import org.session.libsession.network.model.FailureDecision +import org.session.libsession.network.model.OnionDestination +import org.session.libsession.network.model.OnionError +import org.session.libsession.network.onion.PathManager +import org.session.libsession.network.snode.SnodeDirectory +import org.session.libsession.network.snode.SwarmDirectory +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Snode +import javax.inject.Inject +import javax.inject.Singleton + + +@Singleton +class SnodeClientErrorManager @Inject constructor( + private val pathManager: PathManager, + private val snodeDirectory: SnodeDirectory, + private val swarmDirectory: SwarmDirectory, +) { + + suspend fun onFailure(error: OnionError, ctx: SnodeClientFailureContext): FailureDecision { + val status = error.status + val code = status?.code + val bodyText = status?.bodyText + + // -------------------------------------------------------------------- + // Destination payload rules + // -------------------------------------------------------------------- + if (error is OnionError.DestinationError) { + // Clock Out Of Sync + if (error is OnionError.ClockOutOfSync) + { + // if this is the first time we got a COS, retry, since we should have resynced the clock + if(ctx.previousError == null){ + return FailureDecision.Retry + + } else { + // if we already got a COS, and syncing the clock wasn't enough + // we should consider the destination snode faulty. Penalise it and retry + //todo ONION penalise snode and retry + return FailureDecision.Retry + } + } + + // 421: snode isn't associated with pubkey anymore -> update swarm / invalidate -> retry + if (code == 421) { + val publicKey = ctx.publicKey + val targetSnode = ctx.targetSnode + + val updated = if (publicKey != null) { + swarmDirectory.updateSwarmFromResponse( + publicKey = publicKey, + body = status.body + ) + } else { + Log.w("Onion Request", "Got 421 without an associated public key.") + false + } + + if (!updated && publicKey != null && targetSnode != null) { + swarmDirectory.dropSnodeFromSwarmIfNeeded(targetSnode, publicKey) + } + + return FailureDecision.Retry + } + + // Anything else from destination: do not penalise path; no retries + return FailureDecision.Fail(error) + } + + // Default: fail + return FailureDecision.Fail(error) + } +} + +data class SnodeClientFailureContext( + val targetSnode: Snode? = null, + val publicKey: String? = null, + val previousError: OnionError? = null // in some situations we could be coming from a retry to a previous error +) \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/network/model/FailureDecision.kt b/app/src/main/java/org/session/libsession/network/model/FailureDecision.kt new file mode 100644 index 0000000000..b3984181e7 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/model/FailureDecision.kt @@ -0,0 +1,6 @@ +package org.session.libsession.network.model + +sealed class FailureDecision { + data object Retry : FailureDecision() + data class Fail(val throwable: Throwable) : FailureDecision() +} \ No newline at end of file From 2be1ea866d2144a6376460d3fd127a27555c212f Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 7 Jan 2026 11:28:26 +1100 Subject: [PATCH 41/77] Handle the bad snode in COS for Snode destination --- .../session/libsession/network/SnodeClientErrorManager.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt b/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt index ce7c9104d2..a2145609ba 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt @@ -15,7 +15,6 @@ import javax.inject.Singleton @Singleton class SnodeClientErrorManager @Inject constructor( private val pathManager: PathManager, - private val snodeDirectory: SnodeDirectory, private val swarmDirectory: SwarmDirectory, ) { @@ -38,7 +37,7 @@ class SnodeClientErrorManager @Inject constructor( } else { // if we already got a COS, and syncing the clock wasn't enough // we should consider the destination snode faulty. Penalise it and retry - //todo ONION penalise snode and retry + pathManager.handleBadSnode(ctx.targetSnode) return FailureDecision.Retry } } @@ -58,7 +57,7 @@ class SnodeClientErrorManager @Inject constructor( false } - if (!updated && publicKey != null && targetSnode != null) { + if (!updated && publicKey != null) { swarmDirectory.dropSnodeFromSwarmIfNeeded(targetSnode, publicKey) } @@ -75,7 +74,7 @@ class SnodeClientErrorManager @Inject constructor( } data class SnodeClientFailureContext( - val targetSnode: Snode? = null, + val targetSnode: Snode, val publicKey: String? = null, val previousError: OnionError? = null // in some situations we could be coming from a retry to a previous error ) \ No newline at end of file From 9e920c89964a6041a9f4f8ed767efabc4374d47f Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 7 Jan 2026 11:34:50 +1100 Subject: [PATCH 42/77] Making sure mutating the Path is safe when done from different threads --- .../libsession/network/onion/PathManager.kt | 66 +++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/onion/PathManager.kt b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt index 7f84c323b9..c8890b88ca 100644 --- a/app/src/main/java/org/session/libsession/network/onion/PathManager.kt +++ b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt @@ -132,46 +132,46 @@ class PathManager @Inject constructor( } /** Called when we know a specific snode is bad. */ - fun handleBadSnode(snode: Snode) { - _paths.update { currentList -> - // Locate the bad path in the *current* snapshot - val pathIndex = currentList.indexOfFirst { it.contains(snode) } - - // If the node isn't found (e.g., paths were just rebuilt), do nothing - if (pathIndex == -1) return@update currentList - - // Prepare mutable copies for modification - // We copy the outer list so we don't mutate the 'currentList' which might be needed for a CAS retry - val newPathsList = currentList.toMutableList() - val pathParams = newPathsList[pathIndex].toMutableList() - - // Remove the bad node - pathParams.remove(snode) - - // Find a replacement - val usedSnodes = newPathsList.flatten().toSet() - val pool = directory.getSnodePool() - val unused = pool.minus(usedSnodes) - - if (unused.isEmpty()) { - Log.w("Onion Request", "No unused snodes to repair path, dropping path entirely") - newPathsList.removeAt(pathIndex) - } else { + suspend fun handleBadSnode(snode: Snode) { + buildMutex.withLock { + _paths.update { currentList -> + // Locate the bad path in the *current* snapshot + val pathIndex = currentList.indexOfFirst { it.contains(snode) } + + // If the node isn't found (e.g., paths were just rebuilt), do nothing + if (pathIndex == -1) return@update currentList + + val newPathsList = currentList.toMutableList() + val pathParams = newPathsList[pathIndex].toMutableList() + + val badIndex = pathParams.indexOfFirst { it == snode } + if (badIndex == -1) return@update currentList + + val usedSnodes = newPathsList.flatten().toSet() + val pool = directory.getSnodePool() + val unused = pool.minus(usedSnodes) + + if (unused.isEmpty()) { + Log.w("Onion Request", "No unused snodes to repair path, dropping path entirely") + newPathsList.removeAt(pathIndex) + return@update sanitizePaths(newPathsList) + } + val replacement = unused.secureRandom() - pathParams.add(replacement) + pathParams[badIndex] = replacement newPathsList[pathIndex] = pathParams - } - // Return the new clean list - sanitizePaths(newPathsList) + sanitizePaths(newPathsList) + } } } /** Called when an entire path is considered unreliable. */ - fun handleBadPath(path: Path) { - _paths.update { currentList -> - // Filter returns a new list, so this is safe and atomic - currentList.filter { it != path } + suspend fun handleBadPath(path: Path) { + buildMutex.withLock { + _paths.update { currentList -> + currentList.filter { it != path } + } } } From d936b1ff27eaa53ccd9a65f5c2effc04f0470e6e Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 7 Jan 2026 12:37:29 +1100 Subject: [PATCH 43/77] Moving COS responsibility in the Clients --- .../libsession/network/NetworkErrorManager.kt | 27 ++----------------- .../network/ServerClientErrorManager.kt | 16 +++++++---- .../session/libsession/network/SnodeClient.kt | 26 +++++++----------- .../network/SnodeClientErrorManager.kt | 13 ++++++--- .../session/libsession/network/SnodeClock.kt | 1 + .../libsession/network/model/OnionError.kt | 8 ------ 6 files changed, 32 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt index 0e8d515db2..848789151e 100644 --- a/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt @@ -85,32 +85,9 @@ class NetworkErrorManager @Inject constructor( } // -------------------------------------------------------------------- - // 3) Destination payload rules + // 3) Destination payload rules - currently this doesn't handle + // DestinatioErrors directly. The clients' error manager do. // -------------------------------------------------------------------- - if (error is OnionError.DestinationError) { - // 406/425: clock out of sync (COS) - // 406 is COS only for a snode destination - // 425 is COS only for a server destination - if ((code == 406 && ctx.destination is OnionDestination.SnodeDestination) - || (code == 425 && ctx.destination is OnionDestination.ServerDestination)) - { - Log.w("Onion Request", "Clock out of sync (code: $code) for destination ${ctx.targetSnode?.address} at node: ${error.snode?.address} - Local Snode clock at ${snodeClock.currentTime()}") - // Attempt to reset the clock. - // The retry logic for COS shouldn't be handled here, but at a higher level - // since the clock will be reset, meaning request that need a timestamp will need - // to be recreated, so the responsibility should like on a layer further up - // for example in the SnodeErrorManager or ServerErrorManager - runCatching { - snodeClock.resyncClock() - //todo ONION Add retry logic in the snode and server error managers. If we still get an out of sync error, we should penalise the snode, and try again with another - }.getOrDefault(false) - - return FailureDecision.Fail(OnionError.ClockOutOfSync(error.destination, error.status)) - } - - // Anything else from destination: do not penalise path; no retries - return FailureDecision.Fail(error) - } // Default: fail return FailureDecision.Fail(error) diff --git a/app/src/main/java/org/session/libsession/network/ServerClientErrorManager.kt b/app/src/main/java/org/session/libsession/network/ServerClientErrorManager.kt index 8782d8038f..6557a1249d 100644 --- a/app/src/main/java/org/session/libsession/network/ServerClientErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/ServerClientErrorManager.kt @@ -14,7 +14,9 @@ import javax.inject.Singleton @Singleton -class ServerClientErrorManager @Inject constructor() { +class ServerClientErrorManager @Inject constructor( + private val snodeClock: SnodeClock, +) { suspend fun onFailure(error: OnionError, ctx: ServerClientFailureContext): FailureDecision { val status = error.status @@ -25,13 +27,17 @@ class ServerClientErrorManager @Inject constructor() { // Destination payload rules // -------------------------------------------------------------------- if (error is OnionError.DestinationError) { - // Clock Out Of Sync - if (error is OnionError.ClockOutOfSync) - { + // 425 is 'Clock out of sync' for a server destination + if (code == 425) { // if this is the first time we got a COS, retry, since we should have resynced the clock + Log.w("Onion Request", "Clock out of sync (code: $code) for destination server ${ctx.url} - Local Snode clock at ${snodeClock.currentTime()} - First time? ${ctx.previousError == null}") if(ctx.previousError == null){ - return FailureDecision.Retry + // reset the clock + runCatching { + snodeClock.resyncClock() + }.getOrDefault(false) + return FailureDecision.Retry } else { // if we already got a COS, and syncing the clock wasn't enough // there is nothing more to do with servers. Consider it a failed request diff --git a/app/src/main/java/org/session/libsession/network/SnodeClient.kt b/app/src/main/java/org/session/libsession/network/SnodeClient.kt index d5b6148139..f8e04b89ff 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClient.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClient.kt @@ -57,7 +57,7 @@ class SnodeClient @Inject constructor( private val errorManager: SnodeClientErrorManager ) { - //todo ONION missing retry strategies + //todo ONION missing retry strategies - create inline retry strategy to use on all calling sites - remove retry logic in sendToSnode private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val batchedRequestsSender: SendChannel @@ -154,6 +154,9 @@ class SnodeClient @Inject constructor( } } + + // send methods are all private so that each function that needs to send to a snode + // is forced to handle its retry strategy at the calling site, including batching private suspend fun sendToSnode( method: Snode.Method, snode: Snode, @@ -161,7 +164,6 @@ class SnodeClient @Inject constructor( publicKey: String? = null, version: Version = Version.V3 ): ByteArraySlice { - //todo ONION these need to be integrated properly as part of the retries for example to recalculate timestamps val payload = JsonUtil.toJson( mapOf( "method" to method.rawValue, @@ -226,6 +228,8 @@ class SnodeClient @Inject constructor( return capped + jitter } + // send methods are all private so that each function that needs to send to a snode + // is forced to handle its retry strategy at the calling site, including batching @OptIn(ExperimentalSerializationApi::class) private suspend fun sendTyped( method: Snode.Method, @@ -251,7 +255,9 @@ class SnodeClient @Inject constructor( } } - suspend fun send( + // send methods are all private so that each function that needs to send to a snode + // is forced to handle its retry strategy at the calling site, including batching + private suspend fun send( method: Snode.Method, snode: Snode, parameters: Map, @@ -587,17 +593,6 @@ class SnodeClient @Inject constructor( publicKey = publicKey, ) - // IMPORTANT: batch subresponse failures do not go through OnionErrorManager - // because the outer response is usually 200. - val firstFailed = response.results.firstOrNull { !it.isSuccessful } - if (firstFailed != null) { - handleBatchItemFailure( - targetSnode = snode, - publicKey = publicKey, - item = firstFailed - ) - } - return response } @@ -606,8 +601,6 @@ class SnodeClient @Inject constructor( targetSnode: Snode, publicKey: String?, ) : FailureDecision { - //todo ONION can we think of a better way to integrate batching with error handling? Right now this is a temporary way to fit it into our system - val bodySlice = item.body.toString().toByteArray(Charsets.UTF_8).view() // we synthesise a DestinationError since what we get at this point is from the destination's response @@ -616,7 +609,6 @@ class SnodeClient @Inject constructor( destination = OnionDestination.SnodeDestination(targetSnode) ) - //todo ONION this is now referring to the new SnodeclientErrorManager, meaning some logic, like clock resync, won't apply here. We might need to modify this return errorManager.onFailure( error = err, ctx = SnodeClientFailureContext( diff --git a/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt b/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt index a2145609ba..e02a445487 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt @@ -16,6 +16,7 @@ import javax.inject.Singleton class SnodeClientErrorManager @Inject constructor( private val pathManager: PathManager, private val swarmDirectory: SwarmDirectory, + private val snodeClock: SnodeClock, ) { suspend fun onFailure(error: OnionError, ctx: SnodeClientFailureContext): FailureDecision { @@ -27,13 +28,17 @@ class SnodeClientErrorManager @Inject constructor( // Destination payload rules // -------------------------------------------------------------------- if (error is OnionError.DestinationError) { - // Clock Out Of Sync - if (error is OnionError.ClockOutOfSync) - { + // 406 is 'Clock out of sync' for a snode destination + if (code == 406) { // if this is the first time we got a COS, retry, since we should have resynced the clock + Log.w("Onion Request", "Clock out of sync (code: $code) for destination snode ${ctx.targetSnode.address} - Local Snode clock at ${snodeClock.currentTime()} - First time? ${ctx.previousError == null}") if(ctx.previousError == null){ - return FailureDecision.Retry + // reset the clock + runCatching { + snodeClock.resyncClock() + }.getOrDefault(false) + return FailureDecision.Retry } else { // if we already got a COS, and syncing the clock wasn't enough // we should consider the destination snode faulty. Penalise it and retry diff --git a/app/src/main/java/org/session/libsession/network/SnodeClock.kt b/app/src/main/java/org/session/libsession/network/SnodeClock.kt index ca6379b692..02513e7d20 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClock.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClock.kt @@ -52,6 +52,7 @@ class SnodeClock @Inject constructor( /** * Resync by querying 3 random snodes and setting time to the median of their adjusted times. */ + //todo ONION add logic so this only happens every 10min, and making sure it wouldn't happen multiple times at the same time suspend fun resyncClock(): Boolean { return runCatching { withTimeout(8_000L) { diff --git a/app/src/main/java/org/session/libsession/network/model/OnionError.kt b/app/src/main/java/org/session/libsession/network/model/OnionError.kt index 16686af5b2..8dce67b23f 100644 --- a/app/src/main/java/org/session/libsession/network/model/OnionError.kt +++ b/app/src/main/java/org/session/libsession/network/model/OnionError.kt @@ -53,14 +53,6 @@ sealed class OnionError( snode = (destination as? OnionDestination.SnodeDestination)?.snode ) - /** - * A subcategory of a destination error. - * This indicates we got told our clock is out of sync and the client should resync its clock - */ - class ClockOutOfSync(destination: OnionDestination, status: ErrorStatus) - : DestinationError(destination, status) - - /** * The onion payload returned something that we couldn't decode as a valid onion response. */ From 010f10c22a39dfc5dca519a2db3fe962afc41219 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 7 Jan 2026 15:06:46 +1100 Subject: [PATCH 44/77] Retrying logic --- .../libsession/network/SessionNetwork.kt | 73 +++++-------------- .../network/utilities/NetworkRetry.kt | 59 +++++++++++++++ 2 files changed, 79 insertions(+), 53 deletions(-) create mode 100644 app/src/main/java/org/session/libsession/network/utilities/NetworkRetry.kt diff --git a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt index e75641ce35..cef9af9cd0 100644 --- a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt +++ b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt @@ -1,19 +1,15 @@ package org.session.libsession.network -import kotlinx.coroutines.delay -import org.session.libsession.network.model.FailureDecision import org.session.libsession.network.model.OnionDestination -import org.session.libsession.network.model.OnionError import org.session.libsession.network.model.OnionResponse import org.session.libsession.network.model.Path import org.session.libsession.network.onion.OnionTransport import org.session.libsession.network.onion.PathManager import org.session.libsession.network.onion.Version -import org.session.libsignal.utilities.Log +import org.session.libsession.network.utilities.runWithRetry import org.session.libsignal.utilities.Snode import javax.inject.Inject import javax.inject.Singleton -import kotlin.random.Random /** * High-level onion request manager. @@ -35,11 +31,6 @@ class SessionNetwork @Inject constructor( private val errorManager: NetworkErrorManager, ) { - private val maxAttempts: Int = 2 - private val baseRetryDelayMs: Long = 250L - private val maxRetryDelayMs: Long = 2_000L - - internal suspend fun sendWithRetry( destination: OnionDestination, payload: ByteArray, @@ -48,59 +39,35 @@ class SessionNetwork @Inject constructor( targetSnode: Snode?, publicKey: String? ): OnionResponse { - var lastError: OnionError? = null - - for (attempt in 1..maxAttempts) { - val path: Path = pathManager.getPath(exclude = snodeToExclude) - //Log.i("Onion Request", "Sending onion request to $destination - attempt $attempt/$maxAttempts") - - try { - val result = transport.send( - path = path, - destination = destination, - payload = payload, - version = version - ) - - return result - } catch (e: Throwable) { - val onionError = e as? OnionError ?: OnionError.Unknown(e) + var lastPath: Path? = null - Log.w("Onion Request", "Onion error on attempt $attempt/$maxAttempts: $onionError") + return runWithRetry( + operationName = "OnionRequest", + classifier = { error, previousError -> + // lastPath should always be set before transport.send() is called + val path = requireNotNull(lastPath) { "Path not set for onion retry classifier" } - // Delegate all handling + retry decision - val decision = errorManager.onFailure( - error = onionError, + errorManager.onFailure( + error = error, ctx = NetworkFailureContext( path = path, destination = destination, targetSnode = targetSnode, publicKey = publicKey, - previousError = lastError + previousError = previousError ) ) - - lastError = onionError - - when (decision) { - is FailureDecision.Fail -> throw decision.throwable - FailureDecision.Retry -> { - if (attempt >= maxAttempts) break - delay(computeBackoffDelayMs(attempt)) - continue - } - } } - } + ) { + val path = pathManager.getPath(exclude = snodeToExclude) + lastPath = path - throw lastError ?: IllegalStateException("Unknown onion error") - } - - private fun computeBackoffDelayMs(attempt: Int): Long { - // Exponential-ish: base * 2^(attempt-1), with jitter, capped - val exp = baseRetryDelayMs * (1L shl (attempt - 1).coerceAtMost(5)) - val capped = exp.coerceAtMost(maxRetryDelayMs) - val jitter = Random.nextLong(0, capped / 3 + 1) - return capped + jitter + transport.send( + path = path, + destination = destination, + payload = payload, + version = version + ) + } } } diff --git a/app/src/main/java/org/session/libsession/network/utilities/NetworkRetry.kt b/app/src/main/java/org/session/libsession/network/utilities/NetworkRetry.kt new file mode 100644 index 0000000000..56883e46fe --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/utilities/NetworkRetry.kt @@ -0,0 +1,59 @@ +package org.session.libsession.network.utilities + +import kotlinx.coroutines.delay +import org.session.libsession.network.model.FailureDecision +import org.session.libsession.network.model.OnionError +import org.session.libsignal.utilities.Log +import kotlin.coroutines.cancellation.CancellationException +import kotlin.random.Random + +// preserving suspend context and avoiding object allocation. +/** + * Generic retry loop that delegates the decision logic to a classifier. + * + * @param classifier A function that takes the current error and the previous error (if any) + * and returns a FailureDecision (Retry or Fail). + */ +suspend inline fun runWithRetry( + maxAttempts: Int = 3, + baseDelayMs: Long = 250L, + maxDelayMs: Long = 2000L, + operationName: String = "Operation", + crossinline classifier: suspend (error: OnionError, previousError: OnionError?) -> FailureDecision, + crossinline block: suspend (attempt: Int) -> T +): T { + var previousError: OnionError? = null + + for (attempt in 1..maxAttempts) { + try { + return block(attempt) + } catch (currentError: Throwable) { + if (currentError is CancellationException) throw currentError + + val onionError = currentError as? OnionError ?: OnionError.Unknown(currentError) + + val decision = classifier(onionError, previousError) + + previousError = onionError + + when (decision) { + is FailureDecision.Fail -> { + throw decision.throwable + } + is FailureDecision.Retry -> { + //Log.w("NetworkRetry", "$operationName failed (attempt $attempt/$maxAttempts): ${currentError.message}") + + if (attempt < maxAttempts) { + // Calculate Backoff + val exp = baseDelayMs * (1L shl (attempt - 1).coerceAtMost(5)) + val capped = exp.coerceAtMost(maxDelayMs) + val jitter = Random.nextLong(0, capped / 3 + 1) + delay(capped + jitter) + continue + } + } + } + } + } + throw previousError ?: IllegalStateException("$operationName failed with unknown error") +} \ No newline at end of file From 069bdb3e2e9e5c50c47f63679cba029bfc3bbaa0 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 7 Jan 2026 15:46:56 +1100 Subject: [PATCH 45/77] Moving retry strategy at the calling sites --- .../libsession/network/SessionNetwork.kt | 4 +- .../session/libsession/network/SnodeClient.kt | 426 +++++++++--------- .../network/utilities/NetworkRetry.kt | 3 +- 3 files changed, 217 insertions(+), 216 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt index cef9af9cd0..ef36cb398f 100644 --- a/app/src/main/java/org/session/libsession/network/SessionNetwork.kt +++ b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt @@ -6,7 +6,7 @@ import org.session.libsession.network.model.Path import org.session.libsession.network.onion.OnionTransport import org.session.libsession.network.onion.PathManager import org.session.libsession.network.onion.Version -import org.session.libsession.network.utilities.runWithRetry +import org.session.libsession.network.utilities.retryWithBackOff import org.session.libsignal.utilities.Snode import javax.inject.Inject import javax.inject.Singleton @@ -41,7 +41,7 @@ class SessionNetwork @Inject constructor( ): OnionResponse { var lastPath: Path? = null - return runWithRetry( + return retryWithBackOff( operationName = "OnionRequest", classifier = { error, previousError -> // lastPath should always be set before transport.send() is called diff --git a/app/src/main/java/org/session/libsession/network/SnodeClient.kt b/app/src/main/java/org/session/libsession/network/SnodeClient.kt index f8e04b89ff..04d16df561 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClient.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClient.kt @@ -7,7 +7,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select @@ -26,6 +25,7 @@ import org.session.libsession.network.model.OnionError import org.session.libsession.network.onion.Version import org.session.libsession.network.snode.SnodeDirectory import org.session.libsession.network.snode.SwarmDirectory +import org.session.libsession.network.utilities.retryWithBackOff import org.session.libsession.snode.SnodeMessage import org.session.libsession.snode.SwarmAuth import org.session.libsession.snode.model.BatchResponse @@ -42,7 +42,6 @@ import org.session.libsignal.utilities.Snode import java.util.Locale import javax.inject.Inject import javax.inject.Singleton -import kotlin.random.Random /** * High-level client for interacting with snodes. @@ -62,10 +61,6 @@ class SnodeClient @Inject constructor( private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val batchedRequestsSender: SendChannel - private val maxAttempts: Int = 2 - private val baseRetryDelayMs: Long = 250L - private val maxRetryDelayMs: Long = 2_000L - init { val batchRequests = Channel(capacity = Channel.UNLIMITED) batchedRequestsSender = batchRequests @@ -173,59 +168,17 @@ class SnodeClient @Inject constructor( val destination = OnionDestination.SnodeDestination(snode) - // the client has its own retry logic, independent from the SessionNetwork's retry logic - var lastError: OnionError? = null - for (attempt in 1..maxAttempts) { - - try { - val onionResponse = sessionNetwork.sendWithRetry( - destination = destination, - payload = payload, - version = version, - snodeToExclude = snode, - targetSnode = snode, - publicKey = publicKey - ) - - return onionResponse.body - ?: throw Error.Generic("Empty body from snode for method $method") - } catch (e: Throwable) { - val onionError = e as? OnionError ?: OnionError.Unknown(e) - - Log.w("Onion Request", "Onion error on attempt $attempt/$maxAttempts: $onionError") - - // Delegate all handling + retry decision - val decision = errorManager.onFailure( - error = onionError, - ctx = SnodeClientFailureContext( - targetSnode = snode, - publicKey = publicKey, - previousError = lastError - ) - ) - - lastError = onionError - - when (decision) { - is FailureDecision.Fail -> throw decision.throwable - FailureDecision.Retry -> { - if (attempt >= maxAttempts) break - delay(computeBackoffDelayMs(attempt)) - continue - } - } - } - } - - throw lastError ?: IllegalStateException("Unknown Snode client error") - } + val onionResponse = sessionNetwork.sendWithRetry( + destination = destination, + payload = payload, + version = version, + snodeToExclude = snode, + targetSnode = snode, + publicKey = publicKey + ) - private fun computeBackoffDelayMs(attempt: Int): Long { - // Exponential-ish: base * 2^(attempt-1), with jitter, capped - val exp = baseRetryDelayMs * (1L shl (attempt - 1).coerceAtMost(5)) - val capped = exp.coerceAtMost(maxRetryDelayMs) - val jitter = Random.nextLong(0, capped / 3 + 1) - return capped + jitter + return onionResponse.body + ?: throw Error.Generic("Empty body from snode for method $method") } // send methods are all private so that each function that needs to send to a snode @@ -276,6 +229,35 @@ class SnodeClient @Inject constructor( return JsonUtil.fromJson(body, Map::class.java) as Map<*, *> } + private suspend inline fun retryWithBackOffForSnode( + maxAttempts: Int = 3, + operationName: String, + publicKey: String?, + crossinline pickTarget: suspend () -> Snode, + crossinline block: suspend (target: Snode) -> T + ): T { + var lastTarget: Snode = pickTarget() + + return retryWithBackOff( + maxAttempts = maxAttempts, + operationName = operationName, + classifier = { error, previous -> + // use the most recent target we tried + errorManager.onFailure( + error = error, + ctx = SnodeClientFailureContext( + targetSnode = lastTarget, + publicKey = publicKey, + previousError = previous + ) + ) + } + ) { attempt -> + if (attempt != 1) lastTarget = pickTarget() + block(lastTarget) + } + } + //todo ONION Remove usage of JsonUtils in all the networking class in favour of kotlinx serializer. Create new data classes instead of relying on Maps //todo ONION the methods below haven't been fully refactored - This is part of the next step of this refactor @@ -291,42 +273,47 @@ class SnodeClient @Inject constructor( auth: SwarmAuth?, namespace: Int = 0, ): StoreMessageResponse { - val params: Map = if (auth != null) { - check(auth.accountId.hexString == message.recipient) { - "Message sent to ${message.recipient} but authenticated with ${auth.accountId.hexString}" - } + return retryWithBackOffForSnode( + maxAttempts = 2, + operationName = "sendMessage", + publicKey = message.recipient, + pickTarget = { swarmDirectory.getSingleTargetSnode(message.recipient) } + ) { target -> + val params: Map = if (auth != null) { + check(auth.accountId.hexString == message.recipient) { + "Message sent to ${message.recipient} but authenticated with ${auth.accountId.hexString}" + } - val timestamp = snodeClock.currentTimeMills() + val timestamp = snodeClock.currentTimeMills() - buildAuthenticatedParameters( - auth = auth, - namespace = namespace, - verificationData = { ns, t -> "${Snode.Method.SendMessage.rawValue}$ns$t" }, - timestamp = timestamp - ) { - put("sig_timestamp", timestamp) - putAll(message.toJSON()) - } - } else { - buildMap { - putAll(message.toJSON()) - if (namespace != 0) put("namespace", namespace) + buildAuthenticatedParameters( + auth = auth, + namespace = namespace, + verificationData = { ns, t -> "${Snode.Method.SendMessage.rawValue}$ns$t" }, + timestamp = timestamp + ) { + put("sig_timestamp", timestamp) + putAll(message.toJSON()) + } + } else { + buildMap { + putAll(message.toJSON()) + if (namespace != 0) put("namespace", namespace) + } } - } - val target = swarmDirectory.getSingleTargetSnode(message.recipient) - - return sendBatchRequest( - snode = target, - publicKey = message.recipient, - request = SnodeBatchRequestInfo( - method = Snode.Method.SendMessage.rawValue, - params = params, - namespace = namespace - ), - responseType = StoreMessageResponse.serializer(), - sequence = false, - ) + sendBatchRequest( + snode = target, + publicKey = message.recipient, + request = SnodeBatchRequestInfo( + method = Snode.Method.SendMessage.rawValue, + params = params, + namespace = namespace + ), + responseType = StoreMessageResponse.serializer(), + sequence = false, + ) + } } @Suppress("UNCHECKED_CAST") @@ -335,101 +322,100 @@ class SnodeClient @Inject constructor( swarmAuth: SwarmAuth, serverHashes: List, ): Map<*, *> { - val snode = swarmDirectory.getSingleTargetSnode(publicKey) - - val params = buildAuthenticatedParameters( - auth = swarmAuth, - namespace = null, - verificationData = { _, _ -> - buildString { - append(Snode.Method.DeleteMessage.rawValue) - serverHashes.forEach(this::append) + return retryWithBackOffForSnode( + operationName = "deleteMessage", + publicKey = publicKey, + pickTarget = { swarmDirectory.getSingleTargetSnode(publicKey) } + ) { snode -> + val params = buildAuthenticatedParameters( + auth = swarmAuth, + namespace = null, + verificationData = { _, _ -> + buildString { + append(Snode.Method.DeleteMessage.rawValue) + serverHashes.forEach(this::append) + } } + ) { + this["messages"] = serverHashes } - ) { - this["messages"] = serverHashes - } - - val rawResponse = send( - method = Snode.Method.DeleteMessage, - snode = snode, - parameters = params, - publicKey = publicKey, - ) - - val swarms = rawResponse["swarm"] as? Map ?: throw Error.Generic("Missing swarm in delete response") - val deletedMessages: Map = swarms.mapValuesNotNull { (hexSnodePublicKey, rawJSON) -> - val json = rawJSON as? Map ?: return@mapValuesNotNull null - - val isFailed = json["failed"] as? Boolean ?: false - val statusCode = json["code"]?.toString() - val reason = json["reason"] as? String + val rawResponse = send( + method = Snode.Method.DeleteMessage, + snode = snode, + parameters = params, + publicKey = publicKey, + ) - if (isFailed) { - Log.d("SessionClient", "DeleteMessage failed on $hexSnodePublicKey: $reason ($statusCode)") - false - } else { - val hashes = (json["deleted"] as? List<*>)?.filterIsInstance() - ?: return@mapValuesNotNull false + val swarms = rawResponse["swarm"] as? Map + ?: throw Error.Generic("Missing swarm in delete response") - val signature = json["signature"] as? String - ?: return@mapValuesNotNull false + val deletedMessages: Map = swarms.mapValuesNotNull { (hexSnodePublicKey, rawJSON) -> + val json = rawJSON as? Map ?: return@mapValuesNotNull null + val isFailed = json["failed"] as? Boolean ?: false + val statusCode = json["code"]?.toString() + val reason = json["reason"] as? String - // Signature: ( PUBKEY_HEX || RMSG[0]..RMSG[N] || DMSG[0]..DMSG[M] ) - val message = sequenceOf(swarmAuth.accountId.hexString) - .plus(serverHashes) - .plus(hashes) - .toByteArray() + if (isFailed) { + Log.d("SessionClient", "DeleteMessage failed on $hexSnodePublicKey: $reason ($statusCode)") + false + } else { + val hashes = (json["deleted"] as? List<*>)?.filterIsInstance() + ?: return@mapValuesNotNull false + + val signature = json["signature"] as? String + ?: return@mapValuesNotNull false + + val message = sequenceOf(swarmAuth.accountId.hexString) + .plus(serverHashes) + .plus(hashes) + .toByteArray() + + ED25519.verify( + ed25519PublicKey = Hex.fromStringCondensed(hexSnodePublicKey), + signature = Base64.decode(signature), + message = message + ) + } + } - ED25519.verify( - ed25519PublicKey = Hex.fromStringCondensed(hexSnodePublicKey), - signature = Base64.decode(signature), - message = message - ) + if (deletedMessages.entries.all { !it.value }) { + throw Error.Generic("DeleteMessage did not succeed on any swarm member") } - } - if (deletedMessages.entries.all { !it.value }) { - throw Error.Generic("DeleteMessage did not succeed on any swarm member") + rawResponse } - - return rawResponse } - - suspend fun deleteAllMessages( - auth: SwarmAuth, - ): Map { + suspend fun deleteAllMessages(auth: SwarmAuth): Map { val publicKey = auth.accountId.hexString - val snode = swarmDirectory.getSingleTargetSnode(publicKey) - // Prefer network-adjusted time for signature compatibility - val timestamp = snodeClock.waitForNetworkAdjustedTime() + return retryWithBackOffForSnode( + operationName = "deleteAllMessages", + publicKey = publicKey, + pickTarget = { swarmDirectory.getSingleTargetSnode(publicKey) } + ) { snode -> + val timestamp = snodeClock.waitForNetworkAdjustedTime() - val params = buildAuthenticatedParameters( - auth = auth, - namespace = null, - verificationData = { _, t -> "${Snode.Method.DeleteAll.rawValue}all$t" }, - timestamp = timestamp - ) { - put("namespace", "all") - } + val params = buildAuthenticatedParameters( + auth = auth, + namespace = null, + verificationData = { _, t -> "${Snode.Method.DeleteAll.rawValue}all$t" }, + timestamp = timestamp + ) { put("namespace", "all") } - val raw = send( - method = Snode.Method.DeleteAll, - snode = snode, - parameters = params, - publicKey = publicKey, - ) + val raw = send( + method = Snode.Method.DeleteAll, + snode = snode, + parameters = params, + publicKey = publicKey, + ) - return parseDeletions( - userPublicKey = publicKey, - timestamp = timestamp, - rawResponse = raw - ) + parseDeletions(publicKey, timestamp, raw) + } } + @Suppress("UNCHECKED_CAST") private fun parseDeletions(userPublicKey: String, timestamp: Long, rawResponse: Map<*, *>): Map = (rawResponse["swarm"] as? Map)?.mapValuesNotNull { (hexSnodePublicKey, rawJSON) -> @@ -452,25 +438,31 @@ class SnodeClient @Inject constructor( } } ?: mapOf() - suspend fun getNetworkTime( - snode: Snode, - ): Pair { - val json = send( - method = Snode.Method.Info, - snode = snode, - parameters = emptyMap(), - ) - val timestamp = when (val t = json["timestamp"]) { - is Long -> t - is Int -> t.toLong() - is Double -> t.toLong() - else -> -1 - } + suspend fun getNetworkTime(snode: Snode): Pair { + return retryWithBackOffForSnode( + operationName = "getNetworkTime", + publicKey = null, + pickTarget = { snode } + ) { snode -> + val json = send( + method = Snode.Method.Info, + snode = snode, + parameters = emptyMap(), + ) - return snode to timestamp + val timestamp = when (val t = json["timestamp"]) { + is Long -> t + is Int -> t.toLong() + is Double -> t.toLong() + else -> -1 + } + + snode to timestamp + } } + suspend fun getAccountID(onsName: String): String { val validationCount = 3 val onsNameLower = onsName.lowercase(Locale.US) @@ -483,45 +475,48 @@ class SnodeClient @Inject constructor( } } - // Ask 3 different snodes val results = mutableListOf() repeat(validationCount) { - val snode = snodeDirectory.getRandomSnode() - - val json = send( - method = Snode.Method.OxenDaemonRPCCall, - snode = snode, - parameters = params, - ) + val accountId = retryWithBackOffForSnode( + operationName = "onsResolve", + publicKey = null, + pickTarget = { snodeDirectory.getRandomSnode() } + ) { snode -> + val json = send( + method = Snode.Method.OxenDaemonRPCCall, + snode = snode, + parameters = params, + ) - @Suppress("UNCHECKED_CAST") - val intermediate = json["result"] as? Map<*, *> - ?: throw Error.Generic("Invalid ONS response: missing 'result'") + @Suppress("UNCHECKED_CAST") + val intermediate = json["result"] as? Map<*, *> + ?: throw Error.Generic("Invalid ONS response: missing 'result'") - val hexEncodedCiphertext = intermediate["encrypted_value"] as? String - ?: throw Error.Generic("Invalid ONS response: missing 'encrypted_value'") + val hexEncodedCiphertext = intermediate["encrypted_value"] as? String + ?: throw Error.Generic("Invalid ONS response: missing 'encrypted_value'") - val ciphertext = Hex.fromStringCondensed(hexEncodedCiphertext) - val nonce = (intermediate["nonce"] as? String)?.let(Hex::fromStringCondensed) + val ciphertext = Hex.fromStringCondensed(hexEncodedCiphertext) + val nonce = (intermediate["nonce"] as? String)?.let(Hex::fromStringCondensed) - val accountId = SessionEncrypt.decryptOnsResponse( - lowercaseName = onsNameLower, - ciphertext = ciphertext, - nonce = nonce - ) + SessionEncrypt.decryptOnsResponse( + lowercaseName = onsNameLower, + ciphertext = ciphertext, + nonce = nonce + ) + } results += accountId } - // All 3 must be equal for us to trust the result - if (results.size == validationCount && results.toSet().size == 1) { - return results.first() + return if (results.size == validationCount && results.toSet().size == 1) { + results.first() } else { throw Error.ValidationFailed } } + suspend fun alterTtl( auth: SwarmAuth, messageHashes: List, @@ -529,15 +524,22 @@ class SnodeClient @Inject constructor( shorten: Boolean = false, extend: Boolean = false, ): Map<*, *> { - val snode = swarmDirectory.getSingleTargetSnode(auth.accountId.hexString) - val params = buildAlterTtlParams(auth, messageHashes, newExpiry, shorten, extend) + val publicKey = auth.accountId.hexString - return send( - method = Snode.Method.Expire, - snode = snode, - parameters = params, - publicKey = auth.accountId.hexString, - ) + return retryWithBackOffForSnode( + operationName = "alterTtl", + publicKey = publicKey, + pickTarget = { swarmDirectory.getSingleTargetSnode(publicKey) } + ) { snode -> + val params = buildAlterTtlParams(auth, messageHashes, newExpiry, shorten, extend) + + send( + method = Snode.Method.Expire, + snode = snode, + parameters = params, + publicKey = auth.accountId.hexString, + ) + } } diff --git a/app/src/main/java/org/session/libsession/network/utilities/NetworkRetry.kt b/app/src/main/java/org/session/libsession/network/utilities/NetworkRetry.kt index 56883e46fe..6e8568cdd8 100644 --- a/app/src/main/java/org/session/libsession/network/utilities/NetworkRetry.kt +++ b/app/src/main/java/org/session/libsession/network/utilities/NetworkRetry.kt @@ -3,7 +3,6 @@ package org.session.libsession.network.utilities import kotlinx.coroutines.delay import org.session.libsession.network.model.FailureDecision import org.session.libsession.network.model.OnionError -import org.session.libsignal.utilities.Log import kotlin.coroutines.cancellation.CancellationException import kotlin.random.Random @@ -14,7 +13,7 @@ import kotlin.random.Random * @param classifier A function that takes the current error and the previous error (if any) * and returns a FailureDecision (Retry or Fail). */ -suspend inline fun runWithRetry( +suspend inline fun retryWithBackOff( maxAttempts: Int = 3, baseDelayMs: Long = 250L, maxDelayMs: Long = 2000L, From cd1f00a025d925100aa5d9b098bd41e4adf0369f Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 7 Jan 2026 16:04:37 +1100 Subject: [PATCH 46/77] Moving now private call to new function inclient --- .../session/libsession/network/SnodeClient.kt | 53 +++++++++---------- .../network/snode/SwarmDirectory.kt | 9 +--- 2 files changed, 27 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/SnodeClient.kt b/app/src/main/java/org/session/libsession/network/SnodeClient.kt index 04d16df561..c8afaa3aff 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClient.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClient.kt @@ -55,9 +55,6 @@ class SnodeClient @Inject constructor( private val json: Json, private val errorManager: SnodeClientErrorManager ) { - - //todo ONION missing retry strategies - create inline retry strategy to use on all calling sites - remove retry logic in sendToSnode - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val batchedRequestsSender: SendChannel @@ -122,7 +119,18 @@ class SnodeClient @Inject constructor( val item = items[i] val result: Result = runCatching { - if (!item.isSuccessful) throw BatchResponse.Error(item) + if (!item.isSuccessful) { + val bodySlice = item.body.toString().toByteArray(Charsets.UTF_8).view() + + throw OnionError.DestinationError( + status = ErrorStatus( + code = item.code, + message = "Batch Item Failure", + body = bodySlice + ), + destination = OnionDestination.SnodeDestination(snode) + ) + } // Decode each sub-response body into expected type @Suppress("UNCHECKED_CAST") @@ -264,6 +272,20 @@ class SnodeClient @Inject constructor( // Client methods + suspend fun fetchSwarm(publicKey: String): Map<*, *> { + return retryWithBackOffForSnode( + operationName = "fetchSwarm", + publicKey = publicKey, + pickTarget = { snodeDirectory.getRandomSnode() } + ) { snode -> + send( + method = Snode.Method.GetSwarm, + snode = snode, + parameters = mapOf("pubKey" to publicKey) + ) + } + } + /** * Note: After this method returns, [auth] will not be used by any of async calls and it's afe * for the caller to clean up the associated resources if needed. @@ -598,29 +620,6 @@ class SnodeClient @Inject constructor( return response } - private suspend fun handleBatchItemFailure( - item: BatchResponse.Item, - targetSnode: Snode, - publicKey: String?, - ) : FailureDecision { - val bodySlice = item.body.toString().toByteArray(Charsets.UTF_8).view() - - // we synthesise a DestinationError since what we get at this point is from the destination's response - val err = OnionError.DestinationError( - status = ErrorStatus(code = item.code, message = null, body = bodySlice), - destination = OnionDestination.SnodeDestination(targetSnode) - ) - - return errorManager.onFailure( - error = err, - ctx = SnodeClientFailureContext( - targetSnode = targetSnode, - publicKey = publicKey, - previousError = null //todo ONION can we set this properly? - ) - ) - } - /** * Convenience: single-request batching (coalesced for ~100ms). */ diff --git a/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt b/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt index 7601e09a31..f7db648fd7 100644 --- a/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt +++ b/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt @@ -34,14 +34,7 @@ class SwarmDirectory @Inject constructor( "Snode pool is empty" } - val randomSnode = pool.random() - val params = mapOf("pubKey" to publicKey) - - val response = snodeClient.get().send( - method = Snode.Method.GetSwarm, - parameters = params, - snode = randomSnode, - ) + val response = snodeClient.get().fetchSwarm(publicKey) return parseSnodes(response).toSet() } From 801552d8f762c4464998301b92430c19a4a4b4b5 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 7 Jan 2026 16:36:57 +1100 Subject: [PATCH 47/77] cleanup --- .../org/session/libsession/network/SnodeClientErrorManager.kt | 1 + app/src/main/java/org/session/libsession/network/SnodeClock.kt | 3 --- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt b/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt index e02a445487..02a50e38c2 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt @@ -53,6 +53,7 @@ class SnodeClientErrorManager @Inject constructor( val targetSnode = ctx.targetSnode val updated = if (publicKey != null) { + Log.w("Onion Request", "Got 421 with an associated public key. Update Swarm.") swarmDirectory.updateSwarmFromResponse( publicKey = publicKey, body = status.body diff --git a/app/src/main/java/org/session/libsession/network/SnodeClock.kt b/app/src/main/java/org/session/libsession/network/SnodeClock.kt index 02513e7d20..e355c2fa71 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClock.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClock.kt @@ -38,9 +38,6 @@ class SnodeClock @Inject constructor( private val snodeClient: Lazy, ) : OnAppStartupComponent { - //todo ONION we have a lot of calls to MessagingModuleConfiguration.shared.snodeClock.currentTimeMills() - // can this be improved? - private val instantState = MutableStateFlow(null) override fun onPostAppStarted() { From 1a9d43586055384e2658acd87ad8ad1e871d49cb Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 7 Jan 2026 16:57:18 +1100 Subject: [PATCH 48/77] Retry logic --- .../messaging/file_server/FileServerApi.kt | 81 ++++--- .../messaging/open_groups/OpenGroupApi.kt | 209 ++++++++++-------- .../notifications/PushRegistryV1.kt | 36 +-- .../libsession/network/ServerClient.kt | 102 ++++----- .../securesms/groups/GroupLeavingWorker.kt | 6 +- .../notifications/PushRegistrationWorker.kt | 51 +++-- .../securesms/notifications/PushRegistryV2.kt | 29 ++- .../securesms/pro/api/ProApiExecutor.kt | 19 +- .../securesms/tokenpage/TokenRepository.kt | 74 ++++--- 9 files changed, 329 insertions(+), 278 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt b/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt index 6fe850764e..49f3eb16c6 100644 --- a/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt +++ b/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt @@ -11,6 +11,7 @@ import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody import org.session.libsession.database.StorageProtocol import org.session.libsession.network.ServerClient +import org.session.libsession.network.SnodeClock import org.session.libsignal.utilities.ByteArraySlice import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.Hex @@ -28,7 +29,8 @@ import kotlin.time.Duration.Companion.milliseconds @Singleton class FileServerApi @Inject constructor( private val storage: StorageProtocol, - private val serverClient: ServerClient + private val serverClient: ServerClient, + private val snodeClock: SnodeClock ) { companion object { @@ -58,7 +60,10 @@ class FileServerApi @Inject constructor( * Always `true` under normal circumstances. You might want to disable * this when running over Lokinet. */ - val useOnionRouting: Boolean = true + val useOnionRouting: Boolean = true, + + // Computed fresh for each attempt (after clock resync etc.) + val dynamicHeaders: (suspend () -> Map)? = null ) private fun createBody(body: ByteArray?, parameters: Any?): RequestBody? { @@ -74,28 +79,36 @@ class FileServerApi @Inject constructor( } - private suspend fun send(request: Request): SendResponse { - val urlBuilder = request.fileServer.url - .newBuilder() - .addPathSegments(request.endpoint) - if (request.verb == HTTP.Verb.GET) { - for ((key, value) in request.queryParameters) { - urlBuilder.addQueryParameter(key, value) - } - } - val requestBuilder = okhttp3.Request.Builder() - .url(urlBuilder.build()) - .headers(request.headers.toHeaders()) - when (request.verb) { - HTTP.Verb.GET -> requestBuilder.get() - HTTP.Verb.PUT -> requestBuilder.put(createBody(request.body, request.parameters) ?: RequestBody.EMPTY) - HTTP.Verb.POST -> requestBuilder.post(createBody(request.body, request.parameters) ?: RequestBody.EMPTY) - HTTP.Verb.DELETE -> requestBuilder.delete(createBody(request.body, request.parameters)) - } + private suspend fun send(request: Request, operationName: String): SendResponse { return if (request.useOnionRouting) { try { val response = serverClient.send( - request = requestBuilder.build(), + operationName = operationName, + requestFactory = { + val urlBuilder = request.fileServer.url + .newBuilder() + .addPathSegments(request.endpoint) + + if (request.verb == HTTP.Verb.GET) { + for ((k, v) in request.queryParameters) urlBuilder.addQueryParameter(k, v) + } + + val computed = request.dynamicHeaders?.invoke().orEmpty() + val mergedHeaders = (request.headers + computed) + + val builder = okhttp3.Request.Builder() + .url(urlBuilder.build()) + .headers(mergedHeaders.toHeaders()) + + when (request.verb) { + HTTP.Verb.GET -> builder.get() + HTTP.Verb.PUT -> builder.put(createBody(request.body, request.parameters) ?: RequestBody.EMPTY) + HTTP.Verb.POST -> builder.post(createBody(request.body, request.parameters) ?: RequestBody.EMPTY) + HTTP.Verb.DELETE -> builder.delete(createBody(request.body, request.parameters)) + } + + builder.build() + }, serverBaseUrl = request.fileServer.url.host, x25519PublicKey = Hex.toStringCondensed( @@ -144,7 +157,7 @@ class FileServerApi @Inject constructor( } } ) - val response = send(request) + val response = send(request, "FileServer.upload") val json = JsonUtil.fromJson(response.body, Map::class.java) val id = json["id"]!!.toString() val expiresEpochSeconds = (json.getOrDefault("expires", null) as? Number)?.toLong() @@ -169,7 +182,7 @@ class FileServerApi @Inject constructor( verb = HTTP.Verb.GET, endpoint = "file/$fileId" ) - return send(request) + return send(request, "FileServer.download") } suspend fun renew(fileId: String, @@ -184,7 +197,7 @@ class FileServerApi @Inject constructor( "X-FS-TTL" to it.inWholeSeconds.toString() } } ?: mapOf() - )) + ), "FileServer.renew") resp.expires } @@ -308,8 +321,6 @@ class FileServerApi @Inject constructor( ?: throw (Error.NoEd25519KeyPair) val blindedKeys = BlindKeyAPI.blindVersionKeyPair(secretKey) - val timestamp = System.currentTimeMillis().milliseconds.inWholeSeconds // The current timestamp in seconds - val signature = BlindKeyAPI.blindVersionSign(secretKey, timestamp) // The hex encoded version-blinded public key with a 07 prefix val blindedPkHex = "07" + blindedKeys.pubKey.data.toHexString() @@ -319,15 +330,21 @@ class FileServerApi @Inject constructor( verb = HTTP.Verb.GET, endpoint = "session_version", queryParameters = mapOf("platform" to "android"), - headers = mapOf( - "X-FS-Pubkey" to blindedPkHex, - "X-FS-Timestamp" to timestamp.toString(), - "X-FS-Signature" to Base64.encodeToString(signature, Base64.NO_WRAP) - ) + headers = mapOf("X-FS-Pubkey" to blindedPkHex), + // dynamic ones recomputed every attempt: + dynamicHeaders = { + val timestamp = snodeClock.currentTimeSeconds() + val signature = BlindKeyAPI.blindVersionSign(secretKey, timestamp) + + mapOf( + "X-FS-Timestamp" to timestamp.toString(), + "X-FS-Signature" to Base64.encodeToString(signature, Base64.NO_WRAP) + ) + } ) // transform the promise into a coroutine - val result = send(request) + val result = send(request, "FileServer.getClientVersion") // map out the result return JsonUtil.fromJson(result.body, Map::class.java).let { diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt index 4130f09d3a..7154b98873 100644 --- a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt @@ -268,7 +268,8 @@ object OpenGroupApi { * Always `true` under normal circumstances. You might want to disable * this when running over Lokinet. */ - val useOnionRouting: Boolean = true + val useOnionRouting: Boolean = true, + val dynamicHeaders: (suspend () -> Map)? = null ) private fun createBody(body: ByteArray?, parameters: Any?): RequestBody? { @@ -283,7 +284,7 @@ object OpenGroupApi { signRequest: Boolean = true, serverPubKeyHex: String? = null ): ByteArraySlice { - val response = send(request, signRequest = signRequest, serverPubKeyHex = serverPubKeyHex) + val response = send(request, signRequest = signRequest, serverPubKeyHex = serverPubKeyHex, operationName = "OpenGroupAPI.getResponseBody") return response.body ?: throw Error.ParsingFailed } @@ -293,7 +294,7 @@ object OpenGroupApi { signRequest: Boolean = true, serverPubKeyHex: String? = null ): Map<*, *> { - val response = send(request, signRequest = signRequest, serverPubKeyHex = serverPubKeyHex) + val response = send(request, signRequest = signRequest, serverPubKeyHex = serverPubKeyHex, operationName = "OpenGroupAPI.getResponseBodyJson") return JsonUtil.fromJson(response.body, Map::class.java) } @@ -313,7 +314,7 @@ object OpenGroupApi { return fetched.capabilities } - private suspend fun send(request: Request, signRequest: Boolean, serverPubKeyHex: String? = null): OnionResponse { + private suspend fun send(request: Request, signRequest: Boolean, serverPubKeyHex: String? = null, operationName: String): OnionResponse { request.server.toHttpUrlOrNull() ?: throw(Error.InvalidURL) val urlBuilder = StringBuilder("${request.server}/${request.endpoint.value}") @@ -323,108 +324,130 @@ object OpenGroupApi { urlBuilder.append("$key=$value") } } + val urlRequest = urlBuilder.toString() val serverPublicKey = serverPubKeyHex ?: MessagingModuleConfiguration.shared.storage.getOpenGroupPublicKey(request.server) ?: throw Error.NoPublicKey - val urlRequest = urlBuilder.toString() - val headers = if (signRequest) { - val serverCapabilities = getOrFetchServerCapabilities(request.server) - - val ed25519KeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair() - ?: throw Error.NoEd25519KeyPair - - val headers = request.headers.toMutableMap() - val nonce = ByteArray(16).also { SecureRandom().nextBytes(it) } - val timestamp = TimeUnit.MILLISECONDS.toSeconds(MessagingModuleConfiguration.shared.snodeClock.currentTimeMills()) - val bodyHash = if (request.parameters != null) { - val parameterBytes = JsonUtil.toJson(request.parameters).toByteArray() - Hash.hash64(parameterBytes) - } else if (request.body != null) { - Hash.hash64(request.body) - } else { - byteArrayOf() - } + if (!request.useOnionRouting) { + throw IllegalStateException("It's currently not allowed to send non onion routed requests.") + } - val messageBytes = Hex.fromStringCondensed(serverPublicKey) - .plus(nonce) - .plus("$timestamp".toByteArray(Charsets.US_ASCII)) - .plus(request.verb.rawValue.toByteArray()) - .plus("/${request.endpoint.value}".toByteArray()) - .plus(bodyHash) - - val signature: ByteArray - val pubKey: String - - if (serverCapabilities.isEmpty() || serverCapabilities.contains(Capability.BLIND.name.lowercase())) { - pubKey = AccountId( - IdPrefix.BLINDED, - BlindKeyAPI.blind15KeyPair( - ed25519SecretKey = ed25519KeyPair.secretKey.data, - serverPubKey = Hex.fromStringCondensed(serverPublicKey) - ).pubKey.data - ).hexString - - try { - signature = BlindKeyAPI.blind15Sign( - ed25519SecretKey = ed25519KeyPair.secretKey.data, - serverPubKey = serverPublicKey, - message = messageBytes - ) - } catch (e: Exception) { - throw Error.SigningFailed - } - } else { - pubKey = AccountId( - IdPrefix.UN_BLINDED, - ed25519KeyPair.pubKey.data - ).hexString - - signature = ED25519.sign( - ed25519PrivateKey = ed25519KeyPair.secretKey.data, - message = messageBytes - ) + try { + return MessagingModuleConfiguration.shared.serverClient.send( + operationName = operationName, + requestFactory = { + val base = request.headers.toMutableMap() + + val computed = request.dynamicHeaders?.invoke().orEmpty() + base.putAll(computed) + + if (signRequest) { + val signingHeaders = buildSogsSigningHeaders( + request = request, + serverPublicKey = serverPublicKey + ) + base.putAll(signingHeaders) + } + + val builder = okhttp3.Request.Builder() + .url(urlRequest) + .headers(base.toHeaders()) + + when (request.verb) { + GET -> builder.get() + PUT -> builder.put(createBody(request.body, request.parameters)!!) + POST -> builder.post(createBody(request.body, request.parameters)!!) + DELETE -> builder.delete(createBody(request.body, request.parameters)) + } + + if (!request.room.isNullOrEmpty()) { + builder.header("Room", request.room) + } + + builder.build() + }, + serverBaseUrl = request.server, + x25519PublicKey = serverPublicKey + ) + } catch (e: Exception) { + when (e) { + is OnionError -> Log.e("SOGS", "Failed onion request: ${e.message}") + else -> Log.e("SOGS", "Failed onion request", e) } - headers["X-SOGS-Nonce"] = encodeBytes(nonce) - headers["X-SOGS-Timestamp"] = "$timestamp" - headers["X-SOGS-Pubkey"] = pubKey - headers["X-SOGS-Signature"] = encodeBytes(signature) - headers - } else { - request.headers + throw e } + } - val requestBuilder = okhttp3.Request.Builder() - .url(urlRequest) - .headers(headers.toHeaders()) - when (request.verb) { - GET -> requestBuilder.get() - PUT -> requestBuilder.put(createBody(request.body, request.parameters)!!) - POST -> requestBuilder.post(createBody(request.body, request.parameters)!!) - DELETE -> requestBuilder.delete(createBody(request.body, request.parameters)) - } - if (!request.room.isNullOrEmpty()) { - requestBuilder.header("Room", request.room) + private suspend fun buildSogsSigningHeaders( + request: Request, + serverPublicKey: String + ): Map { + val serverCapabilities = getOrFetchServerCapabilities(request.server) + + val ed25519KeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair() + ?: throw Error.NoEd25519KeyPair + + val nonce = ByteArray(16).also { SecureRandom().nextBytes(it) } + + // If you want “strict after COS”: use waitForNetworkAdjustedTime()/1000 + val timestamp = TimeUnit.MILLISECONDS.toSeconds( + MessagingModuleConfiguration.shared.snodeClock.currentTimeMills() + ) + + val bodyHash = when { + request.parameters != null -> Hash.hash64(JsonUtil.toJson(request.parameters).toByteArray()) + request.body != null -> Hash.hash64(request.body) + else -> byteArrayOf() } - if (request.useOnionRouting) { - try { - return MessagingModuleConfiguration.shared.serverClient.send( - request = requestBuilder.build(), - serverBaseUrl = request.server, - x25519PublicKey = serverPublicKey + val messageBytes = Hex.fromStringCondensed(serverPublicKey) + .plus(nonce) + .plus("$timestamp".toByteArray(Charsets.US_ASCII)) + .plus(request.verb.rawValue.toByteArray()) + .plus("/${request.endpoint.value}".toByteArray()) + .plus(bodyHash) + + val signature: ByteArray + val pubKey: String + + if (serverCapabilities.isEmpty() || serverCapabilities.contains(Capability.BLIND.name.lowercase())) { + pubKey = AccountId( + IdPrefix.BLINDED, + BlindKeyAPI.blind15KeyPair( + ed25519SecretKey = ed25519KeyPair.secretKey.data, + serverPubKey = Hex.fromStringCondensed(serverPublicKey) + ).pubKey.data + ).hexString + + signature = try { + BlindKeyAPI.blind15Sign( + ed25519SecretKey = ed25519KeyPair.secretKey.data, + serverPubKey = serverPublicKey, + message = messageBytes ) } catch (e: Exception) { - when (e) { - is OnionError -> Log.e("SOGS", "Failed onion request: ${e.message}") - else -> Log.e("SOGS", "Failed onion request", e) - } - throw e + throw Error.SigningFailed } } else { - throw IllegalStateException("It's currently not allowed to send non onion routed requests.") + pubKey = AccountId( + IdPrefix.UN_BLINDED, + ed25519KeyPair.pubKey.data + ).hexString + + signature = ED25519.sign( + ed25519PrivateKey = ed25519KeyPair.secretKey.data, + message = messageBytes + ) } + + return mapOf( + "X-SOGS-Nonce" to encodeBytes(nonce), + "X-SOGS-Timestamp" to "$timestamp", + "X-SOGS-Pubkey" to pubKey, + "X-SOGS-Signature" to encodeBytes(signature) + ) } suspend fun downloadOpenGroupProfilePicture( @@ -551,7 +574,7 @@ object OpenGroupApi { // region Message Deletion suspend fun deleteMessage(serverID: Long, room: String, server: String) { val request = Request(verb = DELETE, room = room, server = server, endpoint = Endpoint.RoomMessageIndividual(room, serverID)) - send(request, signRequest = true) + send(request, signRequest = true, operationName = "OpenGroupAPI.deleteMessage") Log.d("Loki", "Message deletion successful.") } @@ -568,7 +591,7 @@ object OpenGroupApi { parameters = parameters ) - send(request, signRequest = true) + send(request, signRequest = true, operationName = "OpenGroupAPI.ban") Log.d("Loki", "Banned user: $publicKey from: $server.$room.") } @@ -598,7 +621,7 @@ object OpenGroupApi { suspend fun unban(publicKey: String, room: String, server: String) { val request = Request(verb = DELETE, room = room, server = server, endpoint = Endpoint.UserUnban(publicKey)) - send(request, signRequest = true) + send(request, signRequest = true, operationName = "OpenGroupAPI.unban") Log.d("Loki", "Unbanned user: $publicKey from: $server.$room") } // endregion diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt index 28c234d054..bd7e5f72b0 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt @@ -19,7 +19,6 @@ import org.session.libsignal.utilities.retryWithUniformInterval @SuppressLint("StaticFieldLeak") object PushRegistryV1 { val context = MessagingModuleConfiguration.shared.context - private const val MAX_RETRY_COUNT = 4 private val server = Server.LEGACY @@ -55,23 +54,32 @@ object PushRegistryV1 { closedGroupPublicKey: String, publicKey: String ) { - val parameters = mapOf("closedGroupPublicKey" to closedGroupPublicKey, "pubKey" to publicKey) val url = "${server.url}/$operation" - val body = JsonUtil.toJson(parameters).toRequestBody("application/json".toMediaType()) - val request = Request.Builder().url(url).post(body).build() try { - retryWithUniformInterval(MAX_RETRY_COUNT) { - MessagingModuleConfiguration.shared.serverClient.send( - request = request, - serverBaseUrl = server.url, - x25519PublicKey = server.publicKey, - version = Version.V2 - ) + MessagingModuleConfiguration.shared.serverClient.send( + operationName = operation, + requestFactory = { + val parameters = mapOf( + "closedGroupPublicKey" to closedGroupPublicKey, + "pubKey" to publicKey + ) - // todo ONION the old code was checking the status code on success and if it is null or 0 it would log it as a fail - // the new structure however throws all non 200.299 status as an OnionError - } + val body = JsonUtil.toJson(parameters) + .toRequestBody("application/json".toMediaType()) + + Request.Builder() + .url(url) + .post(body) + .build() + }, + serverBaseUrl = server.url, + x25519PublicKey = server.publicKey, + version = Version.V2 + ) + + // todo ONION the old code was checking the status code on success and if it is null or 0 it would log it as a fail + // the new structure however throws all non 200.299 status as an OnionError } catch (e: Exception) { Log.w("PushRegistryV1", "Failed to perform group operation ($operation): $e") } diff --git a/app/src/main/java/org/session/libsession/network/ServerClient.kt b/app/src/main/java/org/session/libsession/network/ServerClient.kt index 3d58012bb8..53ba7596cb 100644 --- a/app/src/main/java/org/session/libsession/network/ServerClient.kt +++ b/app/src/main/java/org/session/libsession/network/ServerClient.kt @@ -1,19 +1,16 @@ package org.session.libsession.network -import kotlinx.coroutines.delay import okhttp3.Request -import org.session.libsession.network.model.FailureDecision import org.session.libsession.network.model.OnionDestination import org.session.libsession.network.model.OnionError import org.session.libsession.network.model.OnionResponse import org.session.libsession.network.onion.Version import org.session.libsession.network.utilities.getBodyForOnionRequest import org.session.libsession.network.utilities.getHeadersForOnionRequest +import org.session.libsession.network.utilities.retryWithBackOff import org.session.libsignal.utilities.JsonUtil -import org.session.libsignal.utilities.Log import javax.inject.Inject import javax.inject.Singleton -import kotlin.random.Random /** * Responsible for encoding HTTP requests into the onion format (v3/v4) @@ -24,78 +21,57 @@ class ServerClient @Inject constructor( private val sessionNetwork: SessionNetwork, val errorManager: ServerClientErrorManager ) { - private val maxAttempts: Int = 2 - private val baseRetryDelayMs: Long = 250L - private val maxRetryDelayMs: Long = 2_000L + /** + * The request is sent as a lambda in order to be recalculated as part of the retry strategy. + * This is useful for things like timestamps that might have been updated + * as part of a clock resync + */ suspend fun send( - request: Request, + requestFactory: suspend () -> Request, serverBaseUrl: String, x25519PublicKey: String, - version: Version = Version.V4 + version: Version = Version.V4, + operationName: String = "ServerClient.send", ): OnionResponse { - //todo ONION rework Request o be recomputed on retries, for example to help with new timestamps - val url = request.url - - val destination = OnionDestination.ServerDestination( - host = url.host, - target = version.value, - x25519PublicKey = x25519PublicKey, - scheme = url.scheme, - port = url.port - ) - - // the client has its own retry logic, independent from the SessionNetwork's retry logic - var lastError: OnionError? = null - for (attempt in 1..maxAttempts) { - - try { - val payload = generatePayload(request, serverBaseUrl, version) - - return sessionNetwork.sendWithRetry( - destination = destination, - payload = payload, - version = version, - snodeToExclude = null, - targetSnode = null, - publicKey = null - ) - } catch (e: Throwable) { - val onionError = e as? OnionError ?: OnionError.Unknown(e) - - Log.w("Onion Request", "Onion error on attempt $attempt/$maxAttempts: $onionError") - - // Delegate all handling + retry decision - val decision = errorManager.onFailure( + val initialRequest = requestFactory() + val url = initialRequest.url + + return retryWithBackOff( + operationName = operationName, + classifier = { error, previous -> + val onionError = error as? OnionError ?: OnionError.Unknown(error) + errorManager.onFailure( error = onionError, ctx = ServerClientFailureContext( url = url, - previousError = lastError + previousError = previous as? OnionError ) ) - - lastError = onionError - - when (decision) { - is FailureDecision.Fail -> throw decision.throwable - FailureDecision.Retry -> { - if (attempt >= maxAttempts) break - delay(computeBackoffDelayMs(attempt)) - continue - } - } } - } + ) { attempt -> + val request = if (attempt == 1) initialRequest else requestFactory() + val url = request.url + + val destination = OnionDestination.ServerDestination( + host = url.host, + target = version.value, + x25519PublicKey = x25519PublicKey, + scheme = url.scheme, + port = url.port + ) - throw lastError ?: IllegalStateException("Unknown Server client error") - } + val payload = generatePayload(request, serverBaseUrl, version) - private fun computeBackoffDelayMs(attempt: Int): Long { - // Exponential-ish: base * 2^(attempt-1), with jitter, capped - val exp = baseRetryDelayMs * (1L shl (attempt - 1).coerceAtMost(5)) - val capped = exp.coerceAtMost(maxRetryDelayMs) - val jitter = Random.nextLong(0, capped / 3 + 1) - return capped + jitter + sessionNetwork.sendWithRetry( + destination = destination, + payload = payload, + version = version, + snodeToExclude = null, + targetSnode = null, + publicKey = null + ) + } } private fun generatePayload(request: Request, server: String, version: Version): ByteArray { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt index 55a8ada16b..f9d3e79ff6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt @@ -69,9 +69,9 @@ class GroupLeavingWorker @AssistedInject constructor( val groupAuth = configFactory.getGroupAuth(groupId) if (groupAuth != null) { - val resp = pushRegistryV2.unregister(listOf( - pushRegistryV2.buildUnregisterRequest(currentToken, groupAuth) - )).firstOrNull() + val resp = pushRegistryV2.unregister { + listOf(pushRegistryV2.buildUnregisterRequest(currentToken, groupAuth)) + }.firstOrNull() check(resp?.success == true) { "Unsubscription failed: code = ${resp?.error}, message = ${resp?.message}" diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt index da6be333f9..fd94c50651 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt @@ -167,45 +167,57 @@ class PushRegistrationWorker @AssistedInject constructor( return Result.success() } - private suspend inline fun batchRequest( + private suspend inline fun batchRequest( items: List, - buildRequest: (T) -> Req, - sendBatchRequest: suspend (Collection) -> List, + crossinline buildRequest: (T) -> Req, + crossinline sendBatchRequest: suspend (suspend () -> Collection) -> List, ): List>> { - if (items.isEmpty()) { - return emptyList() - } + if (items.isEmpty()) return emptyList() val results = ArrayList>>(items.size) - val batchRequestItems = mutableListOf() - val batchRequests = mutableListOf() + // Items that are valid to send, and their per-attempt builders + val batchItems = mutableListOf() + val requestBuilders = mutableListOf<() -> Req>() for (item in items) { try { - val request = buildRequest(item) - batchRequestItems += item - batchRequests += request + //todo ONION I have to double the buildRequest here, once for validation and again to recompute... Is this ok? + buildRequest(item) + + batchItems += item + requestBuilders += { buildRequest(item) } // <- rebuilt each retry attempt } catch (ec: Exception) { - results += item to kotlin.Result.failure(NonRetryableException("Failed to build a request", ec)) + results += item to kotlin.Result.failure( + NonRetryableException("Failed to build a request", ec) + ) } } + if (batchItems.isEmpty()) return results + try { - val responses = sendBatchRequest(batchRequests) + val responses = sendBatchRequest { + requestBuilders.map { it() } + } + responses.forEachIndexed { idx, response -> - val item = batchRequestItems[idx] + val item = batchItems[idx] results += item to when { response.isSuccess() -> kotlin.Result.success(Unit) - response.error == 403 -> kotlin.Result.failure(NonRetryableException("Request failed: code = ${response.error}, message = ${response.message}")) - else -> kotlin.Result.failure(RuntimeException("Request failed: code = ${response.error}, message = ${response.message}")) + response.error == 403 -> kotlin.Result.failure( + NonRetryableException("Request failed: code = ${response.error}, message = ${response.message}") + ) + else -> kotlin.Result.failure( + RuntimeException("Request failed: code = ${response.error}, message = ${response.message}") + ) } } } catch (e: CancellationException) { throw e } catch (e: Exception) { - // If the batch API fails, mark all requests in this batch as failed. - batchRequestItems.forEach { item -> + // Batch call failed -> mark all *sent* items as failed + batchItems.forEach { item -> results += item to kotlin.Result.failure(e) } } @@ -213,6 +225,7 @@ class PushRegistrationWorker @AssistedInject constructor( return results } + private fun swarmAuthForAccount(accountId: AccountId): SwarmAuth { return when { accountId.prefix == IdPrefix.GROUP -> { @@ -271,4 +284,4 @@ class PushRegistrationWorker @AssistedInject constructor( return op } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt index 3ceb6fdb05..fe758afdee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt @@ -39,11 +39,11 @@ class PushRegistryV2 @Inject constructor( ) { suspend fun register( - requests: Collection + requestsFactory: suspend () -> Collection ): List { return getResponseBody( "subscribe", - Json.encodeToString(requests) + { Json.encodeToString(requestsFactory()) } ) } @@ -52,7 +52,7 @@ class PushRegistryV2 @Inject constructor( swarmAuth: SwarmAuth, namespaces: List ): SignedSubscriptionRequest { - val timestamp = clock.currentTimeMills() / 1000 // get timestamp in ms -> s + val timestamp = clock.currentTimeSeconds() val publicKey = swarmAuth.accountId.hexString val sortedNamespace = namespaces.sorted() val signed = swarmAuth.sign( @@ -74,9 +74,9 @@ class PushRegistryV2 @Inject constructor( } suspend fun unregister( - requests: Collection + requestsFactory: suspend () -> Collection ): List { - return getResponseBody("unsubscribe", Json.encodeToString(requests)) + return getResponseBody("unsubscribe", { Json.encodeToString(requestsFactory()) }) } fun buildUnregisterRequest( @@ -84,7 +84,7 @@ class PushRegistryV2 @Inject constructor( swarmAuth: SwarmAuth ): SignedUnsubscriptionRequest { val publicKey = swarmAuth.accountId.hexString - val timestamp = clock.currentTimeMills() / 1000 // get timestamp in ms -> s + val timestamp = clock.currentTimeSeconds() // if we want to support passing namespace list, here is the place to do it val signature = swarmAuth.sign( "UNSUBSCRIBE${publicKey}${timestamp}".encodeToByteArray() @@ -109,13 +109,20 @@ class PushRegistryV2 @Inject constructor( } @OptIn(ExperimentalSerializationApi::class) - private suspend inline fun getResponseBody(path: String, requestParameters: String): T { + private suspend inline fun getResponseBody( + path: String, + crossinline bodyFactory: suspend () -> String + ): T { val server = Server.LATEST val url = "${server.url}/$path" - val body = requestParameters.toRequestBody("application/json".toMediaType()) - val request = Request.Builder().url(url).post(body).build() + val response = serverClient.send( - request = request, + operationName = "PushRegistryV2.$path", + requestFactory = { + val bodyString = bodyFactory() + val body = bodyString.toRequestBody("application/json".toMediaType()) + Request.Builder().url(url).post(body).build() + }, serverBaseUrl = server.url, x25519PublicKey = server.publicKey, version = Version.V4 @@ -127,4 +134,4 @@ class PushRegistryV2 @Inject constructor( .use { Json.decodeFromStream(it) } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApiExecutor.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApiExecutor.kt index 02a962eb58..15b551655a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApiExecutor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApiExecutor.kt @@ -58,16 +58,19 @@ class ProApiExecutor @Inject constructor( val config = proConfigProvider.get() val rawResp = serverClient.send( - request = Request.Builder() - .url(config.url.resolve(request.endpoint)!!) - .post( - request.buildJsonBody().toRequestBody( - "application/json".toMediaType() + requestFactory = { + Request.Builder() + .url(config.url.resolve(request.endpoint)!!) + .post( + request.buildJsonBody().toRequestBody( + "application/json".toMediaType() + ) ) - ) - .build(), + .build() + }, serverBaseUrl = config.url.host, - x25519PublicKey = config.x25519PubKeyHex + x25519PublicKey = config.x25519PubKeyHex, + operationName = "ProApiExecutor.executeRequest" ).body!!.inputStream().use { json.decodeFromStream(it) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenRepository.kt index 4c37833c71..d3c661312a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenRepository.kt @@ -11,6 +11,7 @@ import okhttp3.RequestBody.Companion.toRequestBody import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.file_server.FileServerApi import org.session.libsession.network.ServerClient +import org.session.libsession.network.SnodeClock import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.toHexString @@ -27,14 +28,15 @@ class TokenRepositoryImpl @Inject constructor( @param:ApplicationContext val context: Context, private val storage: StorageProtocol, private val json: Json, - private val serverClient: ServerClient + private val serverClient: ServerClient, + private val snodeClock: SnodeClock ): TokenRepository { private val TAG = "TokenRepository" private val TOKEN_SERVER_URL = "http://networkv1.getsession.org" private val TOKEN_SERVER_INFO_ENDPOINT = "$TOKEN_SERVER_URL/info" private val SERVER_PUBLIC_KEY = "cbf461a4431dc9174dceef4421680d743a2a0e1a3131fc794240bcb0bc3dd449" - + private val secretKey by lazy { storage.getUserED25519KeyPair()?.secretKey?.data ?: throw (FileServerApi.Error.NoEd25519KeyPair) @@ -52,47 +54,49 @@ class TokenRepositoryImpl @Inject constructor( // Method to access the /info endpoint and retrieve a InfoResponse via onion-routing. override suspend fun getInfoResponse(): InfoResponse? { return sendOnionRequest( - path = "info", - url = TOKEN_SERVER_INFO_ENDPOINT - ) + path = "info", + url = TOKEN_SERVER_INFO_ENDPOINT + ) } private suspend inline fun sendOnionRequest( path: String, url: String, body: ByteArray? = null, - customCatch: (Exception) -> T? = { e -> defaultErrorHandling(e) } + noinline customCatch: (Exception) -> T? = { e -> defaultErrorHandling(e) } ): T? { - val timestampSeconds = System.currentTimeMillis().milliseconds.inWholeSeconds - val signature = BlindKeyAPI.blindVersionSignRequest( - ed25519SecretKey = secretKey, // Important: Use the ED25519 secret key here and NOT the blinded secret key! - timestamp = timestampSeconds, - path = ("/$path"), - body = body, - method = if (body == null) "GET" else "POST" - ) - - val headersMap = mapOf( - "X-FS-Pubkey" to "07" + userBlindedKeys.pubKey.data.toHexString(), - "X-FS-Timestamp" to timestampSeconds.toString(), - "X-FS-Signature" to Base64.encodeBytes(signature) // Careful: Do NOT add `android.util.Base64.NO_WRAP` to this - it breaks it. - ) - - var requestBuilder = Request.Builder() - requestBuilder = if (body == null) { - requestBuilder.get() - } else { - requestBuilder.post(body.toRequestBody()) - } - val request = requestBuilder - .url(url) - .headers(headersMap.toHeaders()) - .build() - var response: T? = null try { val rawResponse = serverClient.send( - request = request, - serverBaseUrl = TOKEN_SERVER_URL, // Note: The `request` contains the actual endpoint we'll hit - x25519PublicKey = SERVER_PUBLIC_KEY + requestFactory = { + val timestampSeconds = snodeClock.currentTimeSeconds() + val signature = BlindKeyAPI.blindVersionSignRequest( + ed25519SecretKey = secretKey, + timestamp = timestampSeconds, + path = ("/$path"), + body = body, + method = if (body == null) "GET" else "POST" + ) + + val headersMap = mapOf( + "X-FS-Pubkey" to "07" + userBlindedKeys.pubKey.data.toHexString(), + "X-FS-Timestamp" to timestampSeconds.toString(), + "X-FS-Signature" to Base64.encodeBytes(signature) + ) + + val requestBuilder = Request.Builder() + .url(url) + .headers(headersMap.toHeaders()) + + if (body == null) { + requestBuilder.get() + } else { + requestBuilder.post(body.toRequestBody()) + } + + requestBuilder.build() + }, + serverBaseUrl = TOKEN_SERVER_URL, + x25519PublicKey = SERVER_PUBLIC_KEY, + operationName = "TokenRepository.sendOnionRequest.$path" ) val resultJsonString = rawResponse.body?.decodeToString() From 847f2fbf4260b5485a5c8b14b6139ad75a2ba855 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 7 Jan 2026 17:12:13 +1100 Subject: [PATCH 49/77] updating tests --- .../libsession/messaging/file_server/FileServerApiTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/test/java/org/session/libsession/messaging/file_server/FileServerApiTest.kt b/app/src/test/java/org/session/libsession/messaging/file_server/FileServerApiTest.kt index ba148e2e26..b3a2961467 100644 --- a/app/src/test/java/org/session/libsession/messaging/file_server/FileServerApiTest.kt +++ b/app/src/test/java/org/session/libsession/messaging/file_server/FileServerApiTest.kt @@ -17,7 +17,7 @@ class FileServerApiTest { @Test fun `can build and parse attachment url`() { - val api = FileServerApi(storage = mock()) + val api = FileServerApi(storage = mock(), serverClient = mock(), snodeClock = mock()) val testCases = listOf( Case( From abda6b1f17a8585865696af14af063dec3004ca3 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 8 Jan 2026 10:44:06 +1100 Subject: [PATCH 50/77] New clock logic - resync every 10min max - Do not retry if clock wasn't synced --- .../network/ServerClientErrorManager.kt | 5 +- .../network/SnodeClientErrorManager.kt | 5 +- .../session/libsession/network/SnodeClock.kt | 100 +++++++++++++++--- 3 files changed, 94 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/ServerClientErrorManager.kt b/app/src/main/java/org/session/libsession/network/ServerClientErrorManager.kt index 6557a1249d..d1d01fc2e0 100644 --- a/app/src/main/java/org/session/libsession/network/ServerClientErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/ServerClientErrorManager.kt @@ -33,11 +33,12 @@ class ServerClientErrorManager @Inject constructor( Log.w("Onion Request", "Clock out of sync (code: $code) for destination server ${ctx.url} - Local Snode clock at ${snodeClock.currentTime()} - First time? ${ctx.previousError == null}") if(ctx.previousError == null){ // reset the clock - runCatching { + val resync = runCatching { snodeClock.resyncClock() }.getOrDefault(false) - return FailureDecision.Retry + // only retry if we were able to resync the clock + return if(resync) FailureDecision.Retry else FailureDecision.Fail(error) } else { // if we already got a COS, and syncing the clock wasn't enough // there is nothing more to do with servers. Consider it a failed request diff --git a/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt b/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt index 02a50e38c2..62a210babe 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt @@ -34,11 +34,12 @@ class SnodeClientErrorManager @Inject constructor( Log.w("Onion Request", "Clock out of sync (code: $code) for destination snode ${ctx.targetSnode.address} - Local Snode clock at ${snodeClock.currentTime()} - First time? ${ctx.previousError == null}") if(ctx.previousError == null){ // reset the clock - runCatching { + val resync = runCatching { snodeClock.resyncClock() }.getOrDefault(false) - return FailureDecision.Retry + // only retry if we were able to resync the clock + return if(resync) FailureDecision.Retry else FailureDecision.Fail(error) } else { // if we already got a COS, and syncing the clock wasn't enough // we should consider the destination snode faulty. Penalise it and retry diff --git a/app/src/main/java/org/session/libsession/network/SnodeClock.kt b/app/src/main/java/org/session/libsession/network/SnodeClock.kt index e355c2fa71..3dc49279d5 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClock.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClock.kt @@ -4,7 +4,7 @@ package org.session.libsession.network import android.os.SystemClock import dagger.Lazy import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job +import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -14,6 +14,8 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeout import org.session.libsession.network.snode.SnodeDirectory import org.session.libsignal.utilities.Log @@ -24,12 +26,9 @@ import javax.inject.Inject import javax.inject.Singleton /** - * A class that manages the network time by querying the network time from a random snode. The - * primary goal of this class is to provide a time that is not tied to current system time and not - * prone to time changes locally. - * - * Before the first network query is successfully, calling [currentTimeMills] will return the current - * system time. + * A class that manages the network time by querying the network time from a random snode. + * The primary goal of this class is to provide a time that is not tied to current system time + * and not prone to time changes locally. */ @Singleton class SnodeClock @Inject constructor( @@ -40,6 +39,16 @@ class SnodeClock @Inject constructor( private val instantState = MutableStateFlow(null) + // Concurrency & Throttling controls + private val syncMutex = Mutex() + private var activeSyncJob: Deferred? = null + + // Explicitly tracking "uptime" to handle device sleep/clock changes correctly + private var lastSuccessfulSyncUptimeMs: Long = 0L + + // 10 Minutes in milliseconds + private val minSyncIntervalMs = 10 * 60 * 1000L + override fun onPostAppStarted() { scope.launch { resyncClock() @@ -48,9 +57,58 @@ class SnodeClock @Inject constructor( /** * Resync by querying 3 random snodes and setting time to the median of their adjusted times. + * * Rules: + * 1. If a sync is already running, this call waits for it and returns that result (coalescing). + * 2. If a sync happened < 10 mins ago, returns false immediately. + * 3. Returns true only if a fresh sync succeeded. + */ + suspend fun resyncClock(): Boolean = coroutineScope { + val jobToAwait = syncMutex.withLock { + + // 1. If a job is already running, join it. + if (activeSyncJob?.isActive == true) { + return@withLock activeSyncJob + } + + // 2. Check throttling + val now = SystemClock.elapsedRealtime() + val timeSinceLastSync = now - lastSuccessfulSyncUptimeMs + + if (timeSinceLastSync < minSyncIntervalMs) { + Log.d("SnodeClock", "Resync throttled (last sync ${timeSinceLastSync / 1000}s ago)") + return@withLock null + } + + // 3. Start a new job on the ManagerScope + val newJob = scope.async { + performNetworkSync() + } + activeSyncJob = newJob + + // 4. Cleanup when done + newJob.invokeOnCompletion { + scope.launch { + syncMutex.withLock { + // Only null it out if it hasn't been replaced by a newer job (rare but possible) + if (activeSyncJob === newJob) { + activeSyncJob = null + } + } + } + } + + newJob + } + + // If jobToAwait is null, we were throttled. + return@coroutineScope jobToAwait?.await() ?: false + } + + /** + * The actual logic to query Snodes. + * This is private and only called by the controlled job inside resyncClock. */ - //todo ONION add logic so this only happens every 10min, and making sure it wouldn't happen multiple times at the same time - suspend fun resyncClock(): Boolean { + private suspend fun performNetworkSync(): Boolean { return runCatching { withTimeout(8_000L) { val nodes = pickDistinctRandomSnodes(count = 3) @@ -63,6 +121,7 @@ class SnodeClock @Inject constructor( var networkTime = snodeClient.get().getNetworkTime(node).second val requestEnded = SystemClock.elapsedRealtime() + // Adjust for latency networkTime -= (requestEnded - requestStarted) / 2 requestStarted to networkTime }.getOrNull() @@ -70,19 +129,33 @@ class SnodeClock @Inject constructor( }.awaitAll().filterNotNull() } + // Check for empty samples to prevent IndexOutOfBoundsException + if (samples.isEmpty()) { + Log.w("SnodeClock", "Resync failed: Unable to reach any Snodes.") + return@withTimeout false + } + val nowUptime = SystemClock.elapsedRealtime() val candidateNowTimes = samples.map { (uptimeAtStart, adjustedAtStart) -> adjustedAtStart + (nowUptime - uptimeAtStart) }.sorted() + // Calculate median val medianNow = candidateNowTimes[candidateNowTimes.size / 2] + + // Commit state instantState.value = Instant(systemUptime = nowUptime, networkTime = medianNow) + // Update throttling timer on SUCCESS only, protected by Mutex + syncMutex.withLock { + lastSuccessfulSyncUptimeMs = SystemClock.elapsedRealtime() + } + Log.d("SnodeClock", "Resynced. Network time: ${Date(medianNow)}, system time: ${Date()}") true } }.getOrElse { t -> - Log.w("SnodeClock", "Resync failed", t) + Log.w("SnodeClock", "Resync failed with exception", t) false } } @@ -90,7 +163,10 @@ class SnodeClock @Inject constructor( private suspend fun pickDistinctRandomSnodes(count: Int): List { val out = LinkedHashSet(count) var guard = 0 - while (out.size < count && guard++ < 20) { + // Added a sanity check for pool size to prevent infinite loops if pool is tiny + val poolSize = snodeDirectory.getSnodePool().size + + while (out.size < count && out.size < poolSize && guard++ < 20) { out += snodeDirectory.getRandomSnode() } return out.toList() @@ -141,4 +217,4 @@ class SnodeClock @Inject constructor( return networkTime + elapsed } } -} +} \ No newline at end of file From 0408220a75bcad308512ea86b7773c23ccc2e17d Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 8 Jan 2026 11:20:42 +1100 Subject: [PATCH 51/77] Using the right class in the poller+ better onion error logs --- .../libsession/messaging/sending_receiving/pollers/Poller.kt | 5 +++-- .../java/org/session/libsession/network/model/OnionError.kt | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt index 12f2ce98ea..52344092a8 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt @@ -32,6 +32,7 @@ import org.session.libsession.messaging.sending_receiving.MessageParser import org.session.libsession.messaging.sending_receiving.ReceivedMessageProcessor import org.session.libsession.network.SnodeClient import org.session.libsession.network.SnodeClock +import org.session.libsession.network.snode.SwarmDirectory import org.session.libsession.network.snode.SwarmStorage import org.session.libsession.snode.model.RetrieveMessageResponse import org.session.libsession.utilities.Address @@ -65,7 +66,7 @@ class Poller @AssistedInject constructor( private val processor: ReceivedMessageProcessor, private val messageParser: MessageParser, private val snodeClient: SnodeClient, - private val swarmStorage: SwarmStorage, + private val swarmDirectory: SwarmDirectory, @Assisted scope: CoroutineScope ) { private val userPublicKey: String @@ -177,7 +178,7 @@ class Poller @AssistedInject constructor( // check if the polling pool is empty if (pollPool.isEmpty()) { // if it is empty, fill it with the snodes from our swarm - pollPool.addAll(swarmStorage.getSwarm(userPublicKey)) + pollPool.addAll(swarmDirectory.getSwarm(userPublicKey)) } // randomly get a snode from the pool diff --git a/app/src/main/java/org/session/libsession/network/model/OnionError.kt b/app/src/main/java/org/session/libsession/network/model/OnionError.kt index 8dce67b23f..d50f96785c 100644 --- a/app/src/main/java/org/session/libsession/network/model/OnionError.kt +++ b/app/src/main/java/org/session/libsession/network/model/OnionError.kt @@ -19,7 +19,7 @@ sealed class OnionError( val status: ErrorStatus? = null, val snode: Snode? = null, cause: Throwable? = null -) : Exception(status?.message ?: "Onion error", cause) { +) : Exception(status?.message ?: "Onion error at ${origin.name}, with status code ${status?.code}. Snode: ${snode?.address}", cause) { /** * We couldn't even talk to the guard node. From 8e0fe94b5cc1b2d8acc049fac9fceeefc65f5ee8 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 8 Jan 2026 12:20:31 +1100 Subject: [PATCH 52/77] TODOs and clean up --- .../messaging/file_server/FileServerApi.kt | 5 ----- .../messaging/jobs/AttachmentDownloadJob.kt | 2 +- .../messaging/open_groups/OpenGroupApi.kt | 1 + .../notifications/PushRegistryV1.kt | 3 --- .../libsession/network/NetworkErrorManager.kt | 20 +++++++++--------- .../session/libsession/network/SnodeClient.kt | 2 +- .../libsession/network/model/OnionResponse.kt | 1 - .../network/onion/http/HttpOnionTransport.kt | 18 +++++++++------- .../network/snode/SnodeDirectory.kt | 2 +- .../org/session/libsignal/utilities/HTTP.kt | 21 ++++--------------- .../securesms/ApplicationContext.kt | 4 ---- .../attachments/AvatarDownloadManager.kt | 2 +- .../attachments/AvatarReuploadWorker.kt | 2 +- .../v2/ConversationReactionOverlay.kt | 2 +- .../securesms/home/PathActivity.kt | 2 +- .../notifications/MarkReadProcessor.kt | 8 ++++--- .../notifications/PushRegistrationWorker.kt | 2 +- .../securesms/pro/ProProofGenerationWorker.kt | 2 +- .../thoughtcrime/securesms/util/IP2Country.kt | 2 +- 19 files changed, 40 insertions(+), 61 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt b/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt index 49f3eb16c6..9fbf437871 100644 --- a/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt +++ b/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt @@ -116,11 +116,6 @@ class FileServerApi @Inject constructor( ) ) - //todo ONION in the new architecture, an Onionresponse should always be in 200..299, otherwise an OnionError is thrown - check(response.code in 200..299) { - "Error response from file server: ${response.code}" - } - val body = response.body ?: throw Error.ParsingFailed SendResponse( diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt index 932ae81e0a..2aae964c6a 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt @@ -94,7 +94,7 @@ class AttachmentDownloadJob @AssistedInject constructor( } else if (exception == Error.NoAttachment || exception == Error.NoThread || exception == Error.NoSender - || (exception is OnionError.DestinationError && exception.status?.code == 400) //todo ONION this is matching old behaviour. Do we want this kind of error handling here? + || (exception is OnionError.DestinationError && exception.status?.code == 400) || exception is NonRetryableException) { attachment?.let { id -> Log.d("AttachmentDownloadJob", "Setting attachment state = failed, have attachment") diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt index 7154b98873..e4834d5eab 100644 --- a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt @@ -372,6 +372,7 @@ object OpenGroupApi { x25519PublicKey = serverPublicKey ) } catch (e: Exception) { + //todo ONION handle the case where we get a 400 with "Invalid authentication: this server requires the use of blinded ids" - call capabilities once and retry when (e) { is OnionError -> Log.e("SOGS", "Failed onion request: ${e.message}") else -> Log.e("SOGS", "Failed onion request", e) diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt index bd7e5f72b0..1c89261404 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt @@ -77,9 +77,6 @@ object PushRegistryV1 { x25519PublicKey = server.publicKey, version = Version.V2 ) - - // todo ONION the old code was checking the status code on success and if it is null or 0 it would log it as a fail - // the new structure however throws all non 200.299 status as an OnionError } catch (e: Exception) { Log.w("PushRegistryV1", "Failed to perform group operation ($operation): $e") } diff --git a/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt index 848789151e..d8c2bb720c 100644 --- a/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt @@ -12,9 +12,6 @@ import org.session.libsignal.utilities.Snode import javax.inject.Inject import javax.inject.Singleton -private const val REQUIRE_BLINDING_MESSAGE = - "Invalid authentication: this server requires the use of blinded ids" - @Singleton class NetworkErrorManager @Inject constructor( private val pathManager: PathManager, @@ -34,7 +31,6 @@ class NetworkErrorManager @Inject constructor( // 400, 403, 404: do not penalise path or snode; No retries if (code == 400 || code == 403 || code == 404) { - //todo ONION need to move the REQUIRE_BLINDING_MESSAGE logic out of here, it should be handled at the calling site, in this case the community poller, to then call /capabilities once return FailureDecision.Fail(error) } @@ -68,20 +64,24 @@ class NetworkErrorManager @Inject constructor( is OnionError.GuardUnreachable -> { // penalise path; retry - //todo ONION not sure yet whether we should punish the path here, or even if we should retry as it is likely a "no connection" issue + //todo ONION if our connectivity manager tells us we have network, punish node (maybe after a couple of strikes?) and try again - otherwise fail pathManager.handleBadPath(ctx.path) return FailureDecision.Retry } - // InvalidResponse / Unknown: treat as path failure (penalise path; retry) - is OnionError.InvalidResponse, - is OnionError.Unknown -> { - //todo ONION also not sure whether to penalise path and retry here... + is OnionError.InvalidResponse -> { + // penalise path; retry pathManager.handleBadPath(ctx.path) return FailureDecision.Retry } - else -> Unit + is OnionError.Unknown -> { + return FailureDecision.Retry + } + + is OnionError.DestinationError -> { + FailureDecision.Fail(error) + } } // -------------------------------------------------------------------- diff --git a/app/src/main/java/org/session/libsession/network/SnodeClient.kt b/app/src/main/java/org/session/libsession/network/SnodeClient.kt index c8afaa3aff..0c411fce4b 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClient.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClient.kt @@ -268,7 +268,7 @@ class SnodeClient @Inject constructor( //todo ONION Remove usage of JsonUtils in all the networking class in favour of kotlinx serializer. Create new data classes instead of relying on Maps - //todo ONION the methods below haven't been fully refactored - This is part of the next step of this refactor + //todo ONION refactor batching // Client methods diff --git a/app/src/main/java/org/session/libsession/network/model/OnionResponse.kt b/app/src/main/java/org/session/libsession/network/model/OnionResponse.kt index 7ab0a60710..668c4e91b0 100644 --- a/app/src/main/java/org/session/libsession/network/model/OnionResponse.kt +++ b/app/src/main/java/org/session/libsession/network/model/OnionResponse.kt @@ -6,6 +6,5 @@ data class OnionResponse( val info: Map<*, *>, val body: ByteArraySlice? = null ) { - val code: Int? get() = info["code"] as? Int val message: String? get() = info["message"] as? String } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt index aa716685d7..da31f6f63e 100644 --- a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt +++ b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt @@ -20,8 +20,10 @@ import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode import org.session.libsignal.utilities.toHexString +import java.io.IOException import javax.inject.Inject import javax.inject.Singleton +import kotlin.coroutines.cancellation.CancellationException import kotlin.io.encoding.Base64 @Singleton @@ -62,12 +64,15 @@ class HttpOnionTransport @Inject constructor( val responseBytes: ByteArray = try { HTTP.execute(HTTP.Verb.POST, url, body) + } catch(e: CancellationException){ + throw e } catch (httpEx: HTTP.HTTPRequestFailedException) { // HTTP error from guard (we never got an onion-level response) throw mapPathHttpError(guard, httpEx) + } catch (e: IOException){ + throw OnionError.GuardUnreachable(guard, e) } catch (t: Throwable) { - // TCP / DNS / TLS / timeout etc. reaching guard - throw OnionError.GuardUnreachable(guard, t) + throw OnionError.Unknown(t) } // We have an onion-level response from the guard; decrypt & interpret @@ -139,6 +144,8 @@ class HttpOnionTransport @Inject constructor( val decrypted = AESGCM.decrypt(response, symmetricKey = destinationSymmetricKey) + //todo ONION is it really this class' responsibility to decode the decrypted payload instead of passing it to a higher level + if (decrypted.isEmpty() || decrypted[0] != 'l'.code.toByte()) { throw OnionError.InvalidResponse() } @@ -163,11 +170,7 @@ class HttpOnionTransport @Inject constructor( Log.i("Onion Request", "Successful response decrypted, but non-2xx status code: $statusCode") // Optional "body" part for some server errors (notably 400) - //todo ONION should we ALWAYS attach the body so that no specific error rules are defined here and/or in case future rules change and the body is needed elsewhere? - val bodySlice = - if (destination is OnionDestination.ServerDestination && statusCode == 400) { - decrypted.getBody(infoLength, infoEndIndex) - } else null + val bodySlice = decrypted.getBody(infoLength, infoEndIndex) throw OnionError.DestinationError( @@ -265,7 +268,6 @@ class HttpOnionTransport @Inject constructor( fun extractMessage(from: Map<*, *>): String? = (from["result"] as? String) ?: (from["message"] as? String) - //todo ONION old code used to only check != 200, but I think this is more correct? if (statusCode !in 200..299) { val errorMap = (normalizedBody as? Map<*, *>) ?: innerJson throw OnionError.DestinationError( diff --git a/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt b/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt index fb3303d57f..f8c17c8895 100644 --- a/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt +++ b/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt @@ -61,7 +61,6 @@ class SnodeDirectory @Inject constructor( Log.d("SnodeDirectory", "Snode pool populated on startup.") } catch (e: Exception) { Log.e("SnodeDirectory", "Failed to populate snode pool on startup", e) - //todo ONION should we have a failsafe here or is it ok ro rely on future call to getRandomSnode? } } } @@ -81,6 +80,7 @@ class SnodeDirectory @Inject constructor( * * Throws if the seed node returns an empty list or parsing fails. */ + //todo ONION ensure we guard against concurrent calls to this suspend fun ensurePoolPopulated( minCount: Int = MINIMUM_SNODE_POOL_COUNT ): Set { diff --git a/app/src/main/java/org/session/libsignal/utilities/HTTP.kt b/app/src/main/java/org/session/libsignal/utilities/HTTP.kt index 42e0462714..2f5e7fdeaf 100644 --- a/app/src/main/java/org/session/libsignal/utilities/HTTP.kt +++ b/app/src/main/java/org/session/libsignal/utilities/HTTP.kt @@ -12,6 +12,7 @@ import okhttp3.Request import okhttp3.RequestBody import okhttp3.Response import org.session.libsignal.utilities.Util.SECURE_RANDOM +import java.lang.IllegalArgumentException import java.security.cert.X509Certificate import java.util.concurrent.TimeUnit import javax.net.ssl.SSLContext @@ -19,8 +20,6 @@ import javax.net.ssl.X509TrustManager object HTTP { - var isConnectedToNetwork: (() -> Boolean) = { false } - private val seedNodeConnection by lazy { OkHttpClient().newBuilder() @@ -78,8 +77,7 @@ object HTTP { val json: Map<*, *>? = null, val body: String? = null, message: String = "HTTP request failed with status code $statusCode" - ) : kotlin.Exception(message) - class HTTPNoNetworkException : HTTPRequestFailedException(0, null, "No network connection") + ) : RuntimeException(message) enum class Verb(val rawValue: String) { GET("GET"), PUT("PUT"), POST("POST"), DELETE("DELETE") @@ -114,7 +112,7 @@ object HTTP { when (verb) { Verb.GET -> request.get() Verb.PUT, Verb.POST -> { - if (body == null) { throw Exception("Invalid request body.") } + if (body == null) { throw IllegalArgumentException("Invalid request body.") } val contentType = "application/json; charset=utf-8".toMediaType() @Suppress("NAME_SHADOWING") val body = RequestBody.create(contentType, body) if (verb == Verb.PUT) request.put(body) else request.post(body) @@ -144,18 +142,7 @@ object HTTP { } catch (exception: Exception) { Log.d("Loki", "${verb.rawValue} request to $url failed due to error: ${exception.localizedMessage}.") - if (!isConnectedToNetwork()) { throw HTTPNoNetworkException() } - - if (exception !is HTTPRequestFailedException) { - - // Override the actual error so that we can correctly catch failed requests in networking layer - throw HTTPRequestFailedException( - statusCode = 0, - message = "HTTP request failed due to: ${exception.message}" - ) - } else { - throw exception - } + throw exception } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt index c82cdfd2b0..e171f6ceb2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt @@ -46,7 +46,6 @@ import org.session.libsession.messaging.sending_receiving.notifications.MessageN import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences.Companion.pushSuffix -import org.session.libsignal.utilities.HTTP.isConnectedToNetwork import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.AppContext.configureKovenant import org.thoughtcrime.securesms.auth.LoginStateRepository @@ -150,9 +149,6 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, Configuratio initializeBlobProvider() refresh() - val networkConstraint = NetworkConstraint.Factory(this).create() - isConnectedToNetwork = { networkConstraint.isMet } - // add our shortcut debug menu if we are not in a release build if (BuildConfig.BUILD_TYPE != "release") { // add the config settings shortcut diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarDownloadManager.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarDownloadManager.kt index 75e50394da..ef3296984e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarDownloadManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarDownloadManager.kt @@ -114,7 +114,7 @@ class AvatarDownloadManager @Inject constructor( downloadAndDecryptFile(file) } catch (e: Exception) { if (e.getRootCause() != null || - e.getRootCause()?.status?.code == 404 //todo ONION does this check still work in the current setup + e.getRootCause()?.status?.code == 404 ) { Log.w(TAG, "Download failed permanently for file $file", e) // Write an empty file with a permanent error metadata if the download failed permanently. diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt index 7f70b27bda..3f67fd3b7f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt @@ -134,7 +134,7 @@ class AvatarReuploadWorker @AssistedInject constructor( // When renew fails, we will try to re-upload the avatar if: // 1. The file is expired (we have the record of this file's expiry time), or // 2. The last update was more than 12 days ago. - if ((e is NonRetryableException || e is OnionError.DestinationError)) { //todo ONION does this check still work in the current setup + if ((e is NonRetryableException || e is OnionError.DestinationError)) { val now = Instant.now() if (fileExpiry?.isBefore(now) == true || (lastUpdated?.isBefore(now.minus(Duration.ofDays(12)))) == true) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt index 03e94b8b13..99772b60f0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt @@ -804,7 +804,7 @@ private val MessageRecord.subtitle: ((Context) -> CharSequence?)? get() = if (expiresIn <= 0 || expireStarted <= 0) { null } else { context -> - //todo ONION is there a better way? + //todo ONION is there a better way? >> pass time here (expiresIn - (MessagingModuleConfiguration.shared.snodeClock.currentTimeMills() - expireStarted)) .coerceAtLeast(0L) .milliseconds diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt index d53d4163ed..85cd09810b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt @@ -132,7 +132,7 @@ class PathActivity : ScreenLockActionBarActivity() { val path = paths.firstOrNull() ?: return finish() val dotAnimationRepeatInterval = path.count().toLong() * 1000 + 1000 val pathRows = path.mapIndexed { index, snode -> - getPathRow(snode, LineView.Location.Middle, index.toLong() * 1000 + 2000, dotAnimationRepeatInterval, index == 0) //todo ONION verify this change works - old code was checking node against the set of guard snodes in the onionreqest api + getPathRow(snode, LineView.Location.Middle, index.toLong() * 1000 + 2000, dotAnimationRepeatInterval, index == 0) } val youRow = getPathRow(resources.getString(R.string.you), null, LineView.Location.Top, 1000, dotAnimationRepeatInterval) val destinationRow = getPathRow(resources.getString(R.string.onionRoutingPathDestination), null, LineView.Location.Bottom, path.count().toLong() * 1000 + 2000, dotAnimationRepeatInterval) diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt index f8a419655e..285c39c5ff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.notifications import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import org.session.libsession.database.StorageProtocol @@ -25,6 +26,7 @@ import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.content.DisappearingMessageUpdate +import org.thoughtcrime.securesms.dependencies.ManagerScope import javax.inject.Inject class MarkReadProcessor @Inject constructor( @@ -38,7 +40,8 @@ class MarkReadProcessor @Inject constructor( private val storage: StorageProtocol, private val snodeClock: SnodeClock, private val lokiMessageDatabase: LokiMessageDatabase, - private val snodeClient: SnodeClient + private val snodeClient: SnodeClient, + @param:ManagerScope private val coroutineScope: CoroutineScope, ) { fun process( markedReadMessages: List @@ -92,8 +95,7 @@ class MarkReadProcessor @Inject constructor( private fun shortenExpiryOfDisappearingAfterRead( hashToMessage: Map ) { - //todo ONION verify move to suspend below - GlobalScope.launch { + coroutineScope.launch { hashToMessage.entries .groupBy( keySelector = { it.value.expirationInfo.expiresIn }, diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt index fd94c50651..816694ce5d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt @@ -182,7 +182,7 @@ class PushRegistrationWorker @AssistedInject constructor( for (item in items) { try { - //todo ONION I have to double the buildRequest here, once for validation and again to recompute... Is this ok? + //todo ONION I have to double the buildRequest here, once for validation and again to recompute... Is this ok? FANCHAO buildRequest(item) batchItems += item diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt index 7072908a8c..b541644503 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt @@ -85,7 +85,7 @@ class ProProofGenerationWorker @AssistedInject constructor( Log.e(WORK_NAME, "Error generating Pro proof", e) if (e is NonRetryableException || // HTTP 403 indicates that the user is not - e.getRootCause()?.status?.code == 403) { //todo ONION verify this works within the new system + e.getRootCause()?.status?.code == 403) { Result.failure() } else { Result.retry() diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt b/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt index 9101f9b97d..e4a85c1605 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt @@ -73,7 +73,7 @@ class IP2Country internal constructor( if (isInitialized) { return; } shared = IP2Country(context.applicationContext) - //todo ONION we should look into injecting this class and optimising + //todo we should look into injecting this class and optimising GlobalScope.launch { MessagingModuleConfiguration.shared.pathManager.paths .filter { it.isNotEmpty() } From 8b294a4cdf5230ee1ce9afb90b4e42c339cc0d3e Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 8 Jan 2026 13:49:21 +1100 Subject: [PATCH 53/77] Error update --- .../libsession/network/NetworkErrorManager.kt | 17 +++++++++++------ .../session/libsession/network/SnodeClient.kt | 2 +- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt index d8c2bb720c..6bc69a12b7 100644 --- a/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt @@ -9,6 +9,7 @@ import org.session.libsession.network.snode.SnodeDirectory import org.session.libsession.network.snode.SwarmDirectory import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.util.NetworkConnectivity import javax.inject.Inject import javax.inject.Singleton @@ -16,8 +17,7 @@ import javax.inject.Singleton class NetworkErrorManager @Inject constructor( private val pathManager: PathManager, private val snodeDirectory: SnodeDirectory, - private val swarmDirectory: SwarmDirectory, - private val snodeClock: SnodeClock, + private val connectivity: NetworkConnectivity ) { suspend fun onFailure(error: OnionError, ctx: NetworkFailureContext): FailureDecision { @@ -63,10 +63,15 @@ class NetworkErrorManager @Inject constructor( } is OnionError.GuardUnreachable -> { - // penalise path; retry - //todo ONION if our connectivity manager tells us we have network, punish node (maybe after a couple of strikes?) and try again - otherwise fail - pathManager.handleBadPath(ctx.path) - return FailureDecision.Retry + // We couldn't reach the guard, yet we seem to have network connectivity: + // punish the node and try again + if(connectivity.networkAvailable.value) { + pathManager.handleBadSnode(ctx.path.first()) + return FailureDecision.Retry + } + + // otherwise fail + return FailureDecision.Fail(error) } is OnionError.InvalidResponse -> { diff --git a/app/src/main/java/org/session/libsession/network/SnodeClient.kt b/app/src/main/java/org/session/libsession/network/SnodeClient.kt index 0c411fce4b..cfb76a9d00 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClient.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClient.kt @@ -268,7 +268,7 @@ class SnodeClient @Inject constructor( //todo ONION Remove usage of JsonUtils in all the networking class in favour of kotlinx serializer. Create new data classes instead of relying on Maps - //todo ONION refactor batching + //todo ONION refactor batching and decide on whether we should have a public send vs every methods defined here // Client methods From fa97b8ade05deb0de9930cd38731d643b156d32b Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 8 Jan 2026 13:53:10 +1100 Subject: [PATCH 54/77] Adding mutex to protect the pool population --- .../network/snode/SnodeDirectory.kt | 120 ++++++++++-------- 1 file changed, 67 insertions(+), 53 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt b/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt index f8c17c8895..13e5061d2b 100644 --- a/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt +++ b/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt @@ -2,6 +2,8 @@ package org.session.libsession.network.snode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.session.libsession.utilities.Environment import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.crypto.secureRandom @@ -34,6 +36,8 @@ class SnodeDirectory @Inject constructor( private const val KEY_VERSION = "storage_server_version" } + private val poolMutex = Mutex() + private val seedNodePool: Set = when (prefs.getEnvironment()) { Environment.DEV_NET -> setOf("http://sesh-net.local:1280") Environment.TEST_NET -> setOf("http://public.loki.foundation:38157") @@ -76,76 +80,86 @@ class SnodeDirectory @Inject constructor( * * - If the current pool is already large enough, returns it unchanged. * - Otherwise, bootstraps from a random seed node (get_n_service_nodes), - * persists the new pool, and returns it. + * persists the new pool, and returns it. * * Throws if the seed node returns an empty list or parsing fails. + * Thread-safe: Ensures only one network call happens at a time. */ - //todo ONION ensure we guard against concurrent calls to this suspend fun ensurePoolPopulated( minCount: Int = MINIMUM_SNODE_POOL_COUNT ): Set { + // 1. Fast path: Optimistic check (no lock) val current = getSnodePool() if (current.size >= minCount) { return current } - val target = seedNodePool.random() - Log.d("SnodeDirectory", "Populating snode pool using seed node: $target") - - val url = "$target/json_rpc" - val responseBytes = HTTP.execute( - HTTP.Verb.POST, - url = url, - parameters = getRandomSnodeParams, - useSeedNodeConnection = true - ) + // 2. Slow path: Acquire lock + return poolMutex.withLock { + // 3. Double-check: Did someone populate it while we were waiting? + val freshCurrent = getSnodePool() + if (freshCurrent.size >= minCount) { + return@withLock freshCurrent + } - val json = runCatching { - JsonUtil.fromJson(responseBytes, Map::class.java) - }.getOrNull() ?: buildMap { - this["result"] = responseBytes.toString(Charsets.UTF_8) - } + val target = seedNodePool.random() + Log.d("SnodeDirectory", "Populating snode pool using seed node: $target") + + val url = "$target/json_rpc" + val responseBytes = HTTP.execute( + HTTP.Verb.POST, + url = url, + parameters = getRandomSnodeParams, + useSeedNodeConnection = true + ) + + val json = runCatching { + JsonUtil.fromJson(responseBytes, Map::class.java) + }.getOrNull() ?: buildMap { + this["result"] = responseBytes.toString(Charsets.UTF_8) + } - @Suppress("UNCHECKED_CAST") - val intermediate = json["result"] as? Map<*, *> - ?: throw IllegalStateException("Failed to update snode pool, 'result' was null.") - .also { Log.d("SnodeDirectory", "Failed to update snode pool, intermediate was null.") } - - @Suppress("UNCHECKED_CAST") - val rawSnodes = intermediate["service_node_states"] as? List<*> - ?: throw IllegalStateException("Failed to update snode pool, 'service_node_states' was null.") - .also { Log.d("SnodeDirectory", "Failed to update snode pool, rawSnodes was null.") } - - val newPool = rawSnodes.asSequence() - .mapNotNull { it as? Map<*, *> } - .mapNotNull { raw -> - createSnode( - address = raw[KEY_IP] as? String, - port = raw[KEY_PORT] as? Int, - ed25519Key = raw[KEY_ED25519] as? String, - x25519Key = raw[KEY_X25519] as? String, - version = (raw[KEY_VERSION] as? List<*>) - ?.filterIsInstance() - ?.let(Snode::Version) - ).also { - if (it == null) { - Log.d( - "SnodeDirectory", - "Failed to parse snode from: ${raw.prettifiedDescription()}." - ) + @Suppress("UNCHECKED_CAST") + val intermediate = json["result"] as? Map<*, *> + ?: throw IllegalStateException("Failed to update snode pool, 'result' was null.") + .also { Log.d("SnodeDirectory", "Failed to update snode pool, intermediate was null.") } + + @Suppress("UNCHECKED_CAST") + val rawSnodes = intermediate["service_node_states"] as? List<*> + ?: throw IllegalStateException("Failed to update snode pool, 'service_node_states' was null.") + .also { Log.d("SnodeDirectory", "Failed to update snode pool, rawSnodes was null.") } + + val newPool = rawSnodes.asSequence() + .mapNotNull { it as? Map<*, *> } + .mapNotNull { raw -> + createSnode( + address = raw[KEY_IP] as? String, + port = raw[KEY_PORT] as? Int, + ed25519Key = raw[KEY_ED25519] as? String, + x25519Key = raw[KEY_X25519] as? String, + version = (raw[KEY_VERSION] as? List<*>) + ?.filterIsInstance() + ?.let(Snode::Version) + ).also { + if (it == null) { + Log.d( + "SnodeDirectory", + "Failed to parse snode from: ${raw.prettifiedDescription()}." + ) + } } } - } - .toSet() + .toSet() - if (newPool.isEmpty()) { - throw IllegalStateException("Seed node returned empty snode pool") - } + if (newPool.isEmpty()) { + throw IllegalStateException("Seed node returned empty snode pool") + } - Log.d("SnodeDirectory", "Persisting snode pool with ${newPool.size} snodes.") - updateSnodePool(newPool) + Log.d("SnodeDirectory", "Persisting snode pool with ${newPool.size} snodes.") + updateSnodePool(newPool) - return newPool + newPool + } } /** @@ -216,4 +230,4 @@ class SnodeDirectory @Inject constructor( Log.w("Loki", "Got stale fork info $newForkInfo (current: $current)") } } -} +} \ No newline at end of file From a4ee9ac06e566c2b0e645de8ed38dbb43979dc4b Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 8 Jan 2026 15:00:11 +1100 Subject: [PATCH 55/77] Adding destination to the OnionErrors --- .../libsession/network/ServerClient.kt | 5 +- .../libsession/network/model/OnionError.kt | 29 ++++++------ .../network/onion/http/HttpOnionTransport.kt | 47 ++++++++++--------- .../network/utilities/NetworkRetry.kt | 2 +- 4 files changed, 43 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/ServerClient.kt b/app/src/main/java/org/session/libsession/network/ServerClient.kt index 53ba7596cb..19ef8d00ef 100644 --- a/app/src/main/java/org/session/libsession/network/ServerClient.kt +++ b/app/src/main/java/org/session/libsession/network/ServerClient.kt @@ -40,12 +40,11 @@ class ServerClient @Inject constructor( return retryWithBackOff( operationName = operationName, classifier = { error, previous -> - val onionError = error as? OnionError ?: OnionError.Unknown(error) errorManager.onFailure( - error = onionError, + error = error, ctx = ServerClientFailureContext( url = url, - previousError = previous as? OnionError + previousError = previous ) ) } diff --git a/app/src/main/java/org/session/libsession/network/model/OnionError.kt b/app/src/main/java/org/session/libsession/network/model/OnionError.kt index d50f96785c..273ae9c422 100644 --- a/app/src/main/java/org/session/libsession/network/model/OnionError.kt +++ b/app/src/main/java/org/session/libsession/network/model/OnionError.kt @@ -17,16 +17,16 @@ enum class ErrorOrigin { UNKNOWN, TRANSPORT_TO_GUARD, PATH_HOP, DESTINATION_REPL sealed class OnionError( val origin: ErrorOrigin, val status: ErrorStatus? = null, - val snode: Snode? = null, + val destination: OnionDestination?, cause: Throwable? = null -) : Exception(status?.message ?: "Onion error at ${origin.name}, with status code ${status?.code}. Snode: ${snode?.address}", cause) { +) : Exception(status?.message ?: "Onion error at ${origin.name}, with status code ${status?.code}. Destination: ${if(destination is OnionDestination.SnodeDestination) "Snode: "+destination.snode.address else if(destination is OnionDestination.ServerDestination) "Server: "+destination.host else "Unknown"}", cause) { /** * We couldn't even talk to the guard node. * Typical causes: offline, DNS failure, TCP connect fails, TLS failure. */ - class GuardUnreachable(val guard: Snode, cause: Throwable) - : OnionError(ErrorOrigin.TRANSPORT_TO_GUARD, cause = cause) + class GuardUnreachable(val guard: Snode, destination: OnionDestination, cause: Throwable) + : OnionError(ErrorOrigin.TRANSPORT_TO_GUARD, destination = destination, cause = cause) /** * The onion chain broke mid-path: one hop reported that the next node was not found. @@ -34,34 +34,35 @@ sealed class OnionError( */ class IntermediateNodeFailed( val reportingNode: Snode?, - val failedPublicKey: String? - ) : OnionError(origin = ErrorOrigin.PATH_HOP, snode = reportingNode) + val failedPublicKey: String?, + destination: OnionDestination, + ) : OnionError(origin = ErrorOrigin.PATH_HOP, destination = destination) /** * The error happened, as far as we can tell, along the path on the way to the destination */ - class PathError(val node: Snode?, status: ErrorStatus) - : OnionError(ErrorOrigin.PATH_HOP, status = status, snode = node) + class PathError(val node: Snode?, status: ErrorStatus, destination: OnionDestination,) + : OnionError(ErrorOrigin.PATH_HOP, status = status, destination = destination) /** * The error happened after decrypting a payload form the destination */ - open class DestinationError(val destination: OnionDestination, status: ErrorStatus) + open class DestinationError(destination: OnionDestination, status: ErrorStatus) : OnionError( ErrorOrigin.DESTINATION_REPLY, status = status, - snode = (destination as? OnionDestination.SnodeDestination)?.snode + destination = destination ) /** * The onion payload returned something that we couldn't decode as a valid onion response. */ - class InvalidResponse(cause: Throwable? = null) - : OnionError(ErrorOrigin.DESTINATION_REPLY, cause = cause) + class InvalidResponse(destination: OnionDestination, cause: Throwable? = null) + : OnionError(ErrorOrigin.DESTINATION_REPLY, cause = cause, destination = destination) /** * Fallback for anything we haven't classified yet. */ - class Unknown(cause: Throwable) - : OnionError(ErrorOrigin.UNKNOWN, cause = cause) + class Unknown(destination: OnionDestination?, cause: Throwable) + : OnionError(ErrorOrigin.UNKNOWN, cause = cause, destination = destination) } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt index da31f6f63e..1c5d4de78b 100644 --- a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt +++ b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt @@ -44,7 +44,7 @@ class HttpOnionTransport @Inject constructor( val built = try { OnionBuilder.build(path, destination, payload, version) } catch (t: Throwable) { - throw OnionError.Unknown(t) + throw OnionError.Unknown(destination, t,) } val url = "${guard.address}:${guard.port}/onion_req/v2" @@ -59,7 +59,7 @@ class HttpOnionTransport @Inject constructor( json = params ) } catch (t: Throwable) { - throw OnionError.Unknown(t) + throw OnionError.Unknown(destination, t) } val responseBytes: ByteArray = try { @@ -68,11 +68,11 @@ class HttpOnionTransport @Inject constructor( throw e } catch (httpEx: HTTP.HTTPRequestFailedException) { // HTTP error from guard (we never got an onion-level response) - throw mapPathHttpError(guard, httpEx) + throw mapPathHttpError(guard, httpEx, destination) } catch (e: IOException){ - throw OnionError.GuardUnreachable(guard, e) + throw OnionError.GuardUnreachable(guard, destination, e) } catch (t: Throwable) { - throw OnionError.Unknown(t) + throw OnionError.Unknown(destination,t) } // We have an onion-level response from the guard; decrypt & interpret @@ -89,7 +89,8 @@ class HttpOnionTransport @Inject constructor( */ private fun mapPathHttpError( node: Snode, - ex: HTTP.HTTPRequestFailedException + ex: HTTP.HTTPRequestFailedException, + destination: OnionDestination ): OnionError { val json = ex.json val message = (json?.get("result") as? String) @@ -105,7 +106,8 @@ class HttpOnionTransport @Inject constructor( val failedPk = message.removePrefix(prefix) return OnionError.IntermediateNodeFailed( reportingNode = node, - failedPublicKey = failedPk + failedPublicKey = failedPk, + destination = destination ) } @@ -115,7 +117,8 @@ class HttpOnionTransport @Inject constructor( code = statusCode, message = message, body = null - ) + ), + destination = destination ) } @@ -139,7 +142,7 @@ class HttpOnionTransport @Inject constructor( ): OnionResponse { try { if (response.size <= AESGCM.ivSize) { - throw OnionError.InvalidResponse() + throw OnionError.InvalidResponse(destination) } val decrypted = AESGCM.decrypt(response, symmetricKey = destinationSymmetricKey) @@ -147,19 +150,19 @@ class HttpOnionTransport @Inject constructor( //todo ONION is it really this class' responsibility to decode the decrypted payload instead of passing it to a higher level if (decrypted.isEmpty() || decrypted[0] != 'l'.code.toByte()) { - throw OnionError.InvalidResponse() + throw OnionError.InvalidResponse(destination) } val infoSepIdx = decrypted.indexOfFirst { it == ':'.code.toByte() } - if (infoSepIdx <= 1) throw OnionError.InvalidResponse() + if (infoSepIdx <= 1) throw OnionError.InvalidResponse(destination) val infoLenSlice = decrypted.slice(1 until infoSepIdx) val infoLength = infoLenSlice.toByteArray().toString(Charsets.US_ASCII).toIntOrNull() - ?: throw OnionError.InvalidResponse() + ?: throw OnionError.InvalidResponse(destination) val infoStartIndex = "l$infoLength".length + 1 val infoEndIndex = infoStartIndex + infoLength - if (infoEndIndex > decrypted.size) throw OnionError.InvalidResponse() + if (infoEndIndex > decrypted.size) throw OnionError.InvalidResponse(destination) val infoSlice = decrypted.view(infoStartIndex until infoEndIndex) val responseInfo = JsonUtil.fromJson(infoSlice, Map::class.java) as Map<*, *> @@ -192,7 +195,7 @@ class HttpOnionTransport @Inject constructor( } catch (e: OnionError) { throw e } catch (t: Throwable) { - throw OnionError.InvalidResponse(t) + throw OnionError.InvalidResponse(destination, t) } } @@ -209,18 +212,18 @@ class HttpOnionTransport @Inject constructor( } val base64Ciphertext = jsonWrapper["result"] as? String - ?: throw OnionError.InvalidResponse(Exception("V2/V3 response missing 'result'")) + ?: throw OnionError.InvalidResponse(destination, Exception("V2/V3 response missing 'result'")) val ivAndCiphertext: ByteArray = try { Base64.decode(base64Ciphertext) } catch (e: Exception) { - throw OnionError.InvalidResponse(Exception("Base64 decode failed", e)) + throw OnionError.InvalidResponse(destination, Exception("Base64 decode failed", e)) } val plaintextBytes: ByteArray = try { AESGCM.decrypt(ivAndCiphertext, symmetricKey = destinationSymmetricKey) } catch (e: Exception) { - throw OnionError.InvalidResponse(Exception("Decryption failed", e)) + throw OnionError.InvalidResponse(destination, Exception("Decryption failed", e)) } val plaintextString = plaintextBytes.toString(Charsets.UTF_8) @@ -228,13 +231,13 @@ class HttpOnionTransport @Inject constructor( val innerJson: Map<*, *> = try { JsonUtil.fromJson(plaintextString, Map::class.java) as Map<*, *> } catch (e: Exception) { - throw OnionError.InvalidResponse(Exception("Decrypted payload is not valid JSON", e)) + throw OnionError.InvalidResponse(destination, Exception("Decrypted payload is not valid JSON", e)) } val statusCode: Int = (innerJson["status_code"] as? Number)?.toInt() ?: (innerJson["status"] as? Number)?.toInt() - ?: throw OnionError.InvalidResponse(Exception("Missing status code in V2/V3 response")) + ?: throw OnionError.InvalidResponse(destination, Exception("Missing status code in V2/V3 response")) val bodyObj: Any? = innerJson["body"] @@ -250,18 +253,18 @@ class HttpOnionTransport @Inject constructor( val parsed: Any = try { JsonUtil.fromJson(bodyObj, Map::class.java) } catch (e: Exception) { - throw OnionError.InvalidResponse(Exception("Failed to parse body string as JSON", e)) + throw OnionError.InvalidResponse(destination, Exception("Failed to parse body string as JSON", e)) } val parsedMap = parsed as? Map<*, *> - ?: throw OnionError.InvalidResponse(Exception("Parsed body was not a JSON object")) + ?: throw OnionError.InvalidResponse(destination, Exception("Parsed body was not a JSON object")) processForkInfo(parsedMap) parsedMap } else -> { - throw OnionError.InvalidResponse(Exception("Unexpected body type: ${bodyObj::class.java}")) + throw OnionError.InvalidResponse(destination, Exception("Unexpected body type: ${bodyObj::class.java}")) } } diff --git a/app/src/main/java/org/session/libsession/network/utilities/NetworkRetry.kt b/app/src/main/java/org/session/libsession/network/utilities/NetworkRetry.kt index 6e8568cdd8..f95117e39e 100644 --- a/app/src/main/java/org/session/libsession/network/utilities/NetworkRetry.kt +++ b/app/src/main/java/org/session/libsession/network/utilities/NetworkRetry.kt @@ -29,7 +29,7 @@ suspend inline fun retryWithBackOff( } catch (currentError: Throwable) { if (currentError is CancellationException) throw currentError - val onionError = currentError as? OnionError ?: OnionError.Unknown(currentError) + val onionError = currentError as? OnionError ?: OnionError.Unknown(null, currentError) val decision = classifier(onionError, previousError) From 6bfd7c6fd7ee3a87b1e1cd746eedf03694a8bdca Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 8 Jan 2026 17:22:43 +1100 Subject: [PATCH 56/77] Fixing network issues --- .../messaging/open_groups/OpenGroupApi.kt | 2 +- .../session/libsession/network/SnodeClient.kt | 2 +- .../libsession/network/onion/OnionBuilder.kt | 39 ++++++------------- .../network/onion/http/HttpOnionTransport.kt | 22 +++++------ .../org/session/libsignal/utilities/Snode.kt | 2 +- 5 files changed, 25 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt index e4834d5eab..078c664858 100644 --- a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt @@ -374,7 +374,7 @@ object OpenGroupApi { } catch (e: Exception) { //todo ONION handle the case where we get a 400 with "Invalid authentication: this server requires the use of blinded ids" - call capabilities once and retry when (e) { - is OnionError -> Log.e("SOGS", "Failed onion request: ${e.message}") + is OnionError -> Log.e("SOGS", "Failed onion request: ${e.message}", e) else -> Log.e("SOGS", "Failed onion request", e) } throw e diff --git a/app/src/main/java/org/session/libsession/network/SnodeClient.kt b/app/src/main/java/org/session/libsession/network/SnodeClient.kt index cfb76a9d00..efd1a36f7d 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClient.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClient.kt @@ -490,7 +490,7 @@ class SnodeClient @Inject constructor( val onsNameLower = onsName.lowercase(Locale.US) val params: Map = buildMap { - this["method"] = "ons_resolve" + this["endpoint"] = "ons_resolve" this["params"] = buildMap { this["type"] = 0 this["name_hash"] = Base64.encodeBytes(Hash.hash32(onsNameLower.toByteArray())) diff --git a/app/src/main/java/org/session/libsession/network/onion/OnionBuilder.kt b/app/src/main/java/org/session/libsession/network/onion/OnionBuilder.kt index 608a50d5f2..cd7ac2956c 100644 --- a/app/src/main/java/org/session/libsession/network/onion/OnionBuilder.kt +++ b/app/src/main/java/org/session/libsession/network/onion/OnionBuilder.kt @@ -2,7 +2,6 @@ package org.session.libsession.network.onion import org.session.libsession.network.model.OnionDestination import org.session.libsession.network.model.Path -import org.session.libsession.utilities.AESGCM.EncryptionResult import org.session.libsignal.utilities.Snode object OnionBuilder { @@ -22,38 +21,24 @@ object OnionBuilder { ): BuiltOnion { require(path.isNotEmpty()) { "Path must not be empty" } - val guardSnode = path.first() - - val destResult: EncryptionResult = + val destinationResult = OnionRequestEncryption.encryptPayloadForDestination(payload, destination, version) - var encryptionResult: EncryptionResult = destResult - var rhs: OnionDestination = destination - var remainingPath = path - - fun addLayer(): EncryptionResult { - return if (remainingPath.isEmpty()) { - encryptionResult - } else { - val lhs = OnionDestination.SnodeDestination(remainingPath.last()) - remainingPath = remainingPath.dropLast(1) - - OnionRequestEncryption.encryptHop(lhs, rhs, encryptionResult).also { r -> - encryptionResult = r - rhs = lhs - } - } - } - - while (remainingPath.isNotEmpty()) { - addLayer() - } + val encryptionResult = path.foldRight( + destination to destinationResult + ) { hop, (previousDestination, previousEncryptionResult) -> + OnionDestination.SnodeDestination(hop) to OnionRequestEncryption.encryptHop( + lhs = OnionDestination.SnodeDestination(hop), + rhs = previousDestination, + previousEncryptionResult = previousEncryptionResult, + ) + }.second return BuiltOnion( - guard = guardSnode, + guard = path.first(), ciphertext = encryptionResult.ciphertext, ephemeralPublicKey = encryptionResult.ephemeralPublicKey, - destinationSymmetricKey = destResult.symmetricKey + destinationSymmetricKey = destinationResult.symmetricKey ) } } diff --git a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt index 1c5d4de78b..f4bacf8d62 100644 --- a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt +++ b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt @@ -92,9 +92,7 @@ class HttpOnionTransport @Inject constructor( ex: HTTP.HTTPRequestFailedException, destination: OnionDestination ): OnionError { - val json = ex.json - val message = (json?.get("result") as? String) - ?: (json?.get("message") as? String) + val message = ex.body val statusCode = ex.statusCode @@ -150,19 +148,19 @@ class HttpOnionTransport @Inject constructor( //todo ONION is it really this class' responsibility to decode the decrypted payload instead of passing it to a higher level if (decrypted.isEmpty() || decrypted[0] != 'l'.code.toByte()) { - throw OnionError.InvalidResponse(destination) + throw OnionError.DestinationError(destination, ErrorStatus(0, "Error decoding payload")) } val infoSepIdx = decrypted.indexOfFirst { it == ':'.code.toByte() } - if (infoSepIdx <= 1) throw OnionError.InvalidResponse(destination) + if (infoSepIdx <= 1) throw OnionError.DestinationError(destination, ErrorStatus(0, "Error decoding payload")) val infoLenSlice = decrypted.slice(1 until infoSepIdx) val infoLength = infoLenSlice.toByteArray().toString(Charsets.US_ASCII).toIntOrNull() - ?: throw OnionError.InvalidResponse(destination) + ?: throw OnionError.DestinationError(destination, ErrorStatus(0, "Error decoding payload")) val infoStartIndex = "l$infoLength".length + 1 val infoEndIndex = infoStartIndex + infoLength - if (infoEndIndex > decrypted.size) throw OnionError.InvalidResponse(destination) + if (infoEndIndex > decrypted.size) throw OnionError.DestinationError(destination, ErrorStatus(0, "Error decoding payload")) val infoSlice = decrypted.view(infoStartIndex until infoEndIndex) val responseInfo = JsonUtil.fromJson(infoSlice, Map::class.java) as Map<*, *> @@ -231,13 +229,13 @@ class HttpOnionTransport @Inject constructor( val innerJson: Map<*, *> = try { JsonUtil.fromJson(plaintextString, Map::class.java) as Map<*, *> } catch (e: Exception) { - throw OnionError.InvalidResponse(destination, Exception("Decrypted payload is not valid JSON", e)) + throw OnionError.DestinationError(destination, ErrorStatus(code = 0, message = "Decrypted payload is not valid JSON", null)) } val statusCode: Int = (innerJson["status_code"] as? Number)?.toInt() ?: (innerJson["status"] as? Number)?.toInt() - ?: throw OnionError.InvalidResponse(destination, Exception("Missing status code in V2/V3 response")) + ?: throw OnionError.DestinationError(destination, ErrorStatus(code = 0, message = "Missing status code in V2/V3 response", null)) val bodyObj: Any? = innerJson["body"] @@ -253,18 +251,18 @@ class HttpOnionTransport @Inject constructor( val parsed: Any = try { JsonUtil.fromJson(bodyObj, Map::class.java) } catch (e: Exception) { - throw OnionError.InvalidResponse(destination, Exception("Failed to parse body string as JSON", e)) + throw OnionError.DestinationError(destination, ErrorStatus(code = 0, message = "Failed to parse body string as JSON", null)) } val parsedMap = parsed as? Map<*, *> - ?: throw OnionError.InvalidResponse(destination, Exception("Parsed body was not a JSON object")) + ?: throw OnionError.DestinationError(destination, ErrorStatus(code = 0, message = "Parsed body was not a JSON object", null)) processForkInfo(parsedMap) parsedMap } else -> { - throw OnionError.InvalidResponse(destination, Exception("Unexpected body type: ${bodyObj::class.java}")) + throw OnionError.DestinationError(destination, ErrorStatus(code = 0, message = "Unexpected body type: ${bodyObj::class.java}", null)) } } diff --git a/app/src/main/java/org/session/libsignal/utilities/Snode.kt b/app/src/main/java/org/session/libsignal/utilities/Snode.kt index f127bb8691..71fc0c6dc0 100644 --- a/app/src/main/java/org/session/libsignal/utilities/Snode.kt +++ b/app/src/main/java/org/session/libsignal/utilities/Snode.kt @@ -16,7 +16,7 @@ fun Snode(string: String): Snode? { return Snode(address, port, Snode.KeySet(ed25519Key, x25519Key), version) } -class Snode(val address: String, val port: Int, val publicKeySet: KeySet?, val version: Version) { +data class Snode(val address: String, val port: Int, val publicKeySet: KeySet?, val version: Version) { val ip: String get() = address.removePrefix("https://") enum class Method(val rawValue: String) { From c4dbec865df14a6a6ba20be1ef881c9453106b2a Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 9 Jan 2026 09:07:18 +1100 Subject: [PATCH 57/77] Better logging --- .../org/session/libsession/network/model/OnionError.kt | 5 +++-- .../org/session/libsession/network/onion/PathManager.kt | 6 ++++++ .../libsession/network/onion/http/HttpOnionTransport.kt | 7 ++++++- .../session/libsession/network/utilities/NetworkRetry.kt | 3 +++ 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/model/OnionError.kt b/app/src/main/java/org/session/libsession/network/model/OnionError.kt index 273ae9c422..aa5f6f6ef4 100644 --- a/app/src/main/java/org/session/libsession/network/model/OnionError.kt +++ b/app/src/main/java/org/session/libsession/network/model/OnionError.kt @@ -19,7 +19,7 @@ sealed class OnionError( val status: ErrorStatus? = null, val destination: OnionDestination?, cause: Throwable? = null -) : Exception(status?.message ?: "Onion error at ${origin.name}, with status code ${status?.code}. Destination: ${if(destination is OnionDestination.SnodeDestination) "Snode: "+destination.snode.address else if(destination is OnionDestination.ServerDestination) "Server: "+destination.host else "Unknown"}", cause) { +) : Exception("Onion error at ${origin.name}, with status code ${status?.code}. Message: ${status?.message}. Destination: ${if(destination is OnionDestination.SnodeDestination) "Snode: "+destination.snode.address else if(destination is OnionDestination.ServerDestination) "Server: "+destination.host else "Unknown"}", cause) { /** * We couldn't even talk to the guard node. @@ -34,9 +34,10 @@ sealed class OnionError( */ class IntermediateNodeFailed( val reportingNode: Snode?, + status: ErrorStatus, val failedPublicKey: String?, destination: OnionDestination, - ) : OnionError(origin = ErrorOrigin.PATH_HOP, destination = destination) + ) : OnionError(origin = ErrorOrigin.PATH_HOP, destination = destination, status = status) /** * The error happened, as far as we can tell, along the path on the way to the destination diff --git a/app/src/main/java/org/session/libsession/network/onion/PathManager.kt b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt index c8890b88ca..b2e05e30b4 100644 --- a/app/src/main/java/org/session/libsession/network/onion/PathManager.kt +++ b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt @@ -76,6 +76,8 @@ class PathManager @Inject constructor( return selectPath(current, exclude) } + Log.w("Onion Request", "We only have ${current.size}/$targetPathCount paths, need to rebuild path.") + // Wait for rebuild to finish if one is happening, or start one rebuildPaths(reusablePaths = current) @@ -95,6 +97,7 @@ class PathManager @Inject constructor( } _isBuilding.value = true + Log.w("Onion Request", "Rebuilding paths...") try { // Ensure we actually have a usable pool before doing anything val pool = directory.ensurePoolPopulated() @@ -126,6 +129,7 @@ class PathManager @Inject constructor( val sanitized = sanitizePaths(allPaths) _paths.value = sanitized } finally { + Log.w("Onion Request", "New path(s) created.") _isBuilding.value = false } } @@ -158,6 +162,7 @@ class PathManager @Inject constructor( } val replacement = unused.secureRandom() + Log.w("Onion Request", "Handling bad snode. Repaired path by replacing $snode with $replacement") pathParams[badIndex] = replacement newPathsList[pathIndex] = pathParams @@ -169,6 +174,7 @@ class PathManager @Inject constructor( /** Called when an entire path is considered unreliable. */ suspend fun handleBadPath(path: Path) { buildMutex.withLock { + Log.w("Onion Request", "Path considered bad - Removed from our paths list.") _paths.update { currentList -> currentList.filter { it != path } } diff --git a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt index f4bacf8d62..d88fb957a6 100644 --- a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt +++ b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt @@ -96,7 +96,7 @@ class HttpOnionTransport @Inject constructor( val statusCode = ex.statusCode - Log.w("Onion Request", "Got an HTTP error. Status: $statusCode, message: $message", ex) + //Log.w("Onion Request", "Got an HTTP error. Status: $statusCode, message: $message", ex) // Special onion path error: "Next node not found: " val prefix = "Next node not found: " @@ -105,6 +105,11 @@ class HttpOnionTransport @Inject constructor( return OnionError.IntermediateNodeFailed( reportingNode = node, failedPublicKey = failedPk, + status = ErrorStatus( + code = statusCode, + message = message, + body = null + ), destination = destination ) } diff --git a/app/src/main/java/org/session/libsession/network/utilities/NetworkRetry.kt b/app/src/main/java/org/session/libsession/network/utilities/NetworkRetry.kt index f95117e39e..f72aab9379 100644 --- a/app/src/main/java/org/session/libsession/network/utilities/NetworkRetry.kt +++ b/app/src/main/java/org/session/libsession/network/utilities/NetworkRetry.kt @@ -3,6 +3,7 @@ package org.session.libsession.network.utilities import kotlinx.coroutines.delay import org.session.libsession.network.model.FailureDecision import org.session.libsession.network.model.OnionError +import org.session.libsignal.utilities.Log import kotlin.coroutines.cancellation.CancellationException import kotlin.random.Random @@ -29,6 +30,8 @@ suspend inline fun retryWithBackOff( } catch (currentError: Throwable) { if (currentError is CancellationException) throw currentError + Log.w("Network", "Got an error sending a network request. Retrying. Attempt $attempt/$maxAttempts", currentError) + val onionError = currentError as? OnionError ?: OnionError.Unknown(null, currentError) val decision = classifier(onionError, previousError) From 4dc66006dafa32a5549914a6f0dbfca540427bf1 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 9 Jan 2026 13:15:57 +1100 Subject: [PATCH 58/77] More error management and todos --- .../libsession/network/NetworkErrorManager.kt | 9 ++++ .../libsession/network/model/OnionError.kt | 22 +++++----- .../libsession/network/onion/PathManager.kt | 5 +++ .../network/onion/http/HttpOnionTransport.kt | 43 ++++++++++++++++--- 4 files changed, 64 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt index 6bc69a12b7..27f8cbd787 100644 --- a/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt @@ -25,6 +25,9 @@ class NetworkErrorManager @Inject constructor( val code = status?.code val bodyText = status?.bodyText + //todo ONION investigate why we got stuck in a invalid cyphertext state + //todo ONION how can we deal with errors sent from the destination, but not within the 200 > encrypted package? Currently they will become PathError that will wrongly penalise the path + // -------------------------------------------------------------------- // 1) "Found anywhere" rules (path OR destination) // -------------------------------------------------------------------- @@ -56,6 +59,12 @@ class NetworkErrorManager @Inject constructor( return FailureDecision.Retry } + is OnionError.DestinationUnreachable -> { + //todo ONION implement this properly + + return FailureDecision.Fail(error) + } + is OnionError.PathError -> { // "Anything else along the path": penalise path; no retries (caller decides) pathManager.handleBadPath(ctx.path) diff --git a/app/src/main/java/org/session/libsession/network/model/OnionError.kt b/app/src/main/java/org/session/libsession/network/model/OnionError.kt index aa5f6f6ef4..f666366b0e 100644 --- a/app/src/main/java/org/session/libsession/network/model/OnionError.kt +++ b/app/src/main/java/org/session/libsession/network/model/OnionError.kt @@ -12,21 +12,18 @@ data class ErrorStatus( get() = body?.decodeToString() } -enum class ErrorOrigin { UNKNOWN, TRANSPORT_TO_GUARD, PATH_HOP, DESTINATION_REPLY } - sealed class OnionError( - val origin: ErrorOrigin, val status: ErrorStatus? = null, val destination: OnionDestination?, cause: Throwable? = null -) : Exception("Onion error at ${origin.name}, with status code ${status?.code}. Message: ${status?.message}. Destination: ${if(destination is OnionDestination.SnodeDestination) "Snode: "+destination.snode.address else if(destination is OnionDestination.ServerDestination) "Server: "+destination.host else "Unknown"}", cause) { +) : Exception("Onion error with status code ${status?.code}. Message: ${status?.message}. Destination: ${if(destination is OnionDestination.SnodeDestination) "Snode: "+destination.snode.address else if(destination is OnionDestination.ServerDestination) "Server: "+destination.host else "Unknown"}", cause) { /** * We couldn't even talk to the guard node. * Typical causes: offline, DNS failure, TCP connect fails, TLS failure. */ class GuardUnreachable(val guard: Snode, destination: OnionDestination, cause: Throwable) - : OnionError(ErrorOrigin.TRANSPORT_TO_GUARD, destination = destination, cause = cause) + : OnionError(destination = destination, cause = cause) /** * The onion chain broke mid-path: one hop reported that the next node was not found. @@ -37,20 +34,25 @@ sealed class OnionError( status: ErrorStatus, val failedPublicKey: String?, destination: OnionDestination, - ) : OnionError(origin = ErrorOrigin.PATH_HOP, destination = destination, status = status) + ) : OnionError(destination = destination, status = status) + + /** + * We couldn't reach the destination from the final snode in the path + */ + class DestinationUnreachable(destination: OnionDestination, status: ErrorStatus) + : OnionError(destination = destination, status = status) /** * The error happened, as far as we can tell, along the path on the way to the destination */ class PathError(val node: Snode?, status: ErrorStatus, destination: OnionDestination,) - : OnionError(ErrorOrigin.PATH_HOP, status = status, destination = destination) + : OnionError(status = status, destination = destination) /** * The error happened after decrypting a payload form the destination */ open class DestinationError(destination: OnionDestination, status: ErrorStatus) : OnionError( - ErrorOrigin.DESTINATION_REPLY, status = status, destination = destination ) @@ -59,11 +61,11 @@ sealed class OnionError( * The onion payload returned something that we couldn't decode as a valid onion response. */ class InvalidResponse(destination: OnionDestination, cause: Throwable? = null) - : OnionError(ErrorOrigin.DESTINATION_REPLY, cause = cause, destination = destination) + : OnionError(cause = cause, destination = destination) /** * Fallback for anything we haven't classified yet. */ class Unknown(destination: OnionDestination?, cause: Throwable) - : OnionError(ErrorOrigin.UNKNOWN, cause = cause, destination = destination) + : OnionError(cause = cause, destination = destination) } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/network/onion/PathManager.kt b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt index b2e05e30b4..5ec074bf4c 100644 --- a/app/src/main/java/org/session/libsession/network/onion/PathManager.kt +++ b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt @@ -135,6 +135,11 @@ class PathManager @Inject constructor( } } + //todo ONION bad path should have a strike system, not removing path directly + //todo ONION bad snode should have a strike system, not removing snode directly + //todo ONION do we need path rotation? + //todo ONION should an intermediate node not found also penalise the path, or just swap out the bad snode? + /** Called when we know a specific snode is bad. */ suspend fun handleBadSnode(snode: Snode) { buildMutex.withLock { diff --git a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt index d88fb957a6..a0e9c533b8 100644 --- a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt +++ b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt @@ -68,7 +68,7 @@ class HttpOnionTransport @Inject constructor( throw e } catch (httpEx: HTTP.HTTPRequestFailedException) { // HTTP error from guard (we never got an onion-level response) - throw mapPathHttpError(guard, httpEx, destination) + throw mapPathHttpError(guard, httpEx, path, destination) } catch (e: IOException){ throw OnionError.GuardUnreachable(guard, destination, e) } catch (t: Throwable) { @@ -90,6 +90,7 @@ class HttpOnionTransport @Inject constructor( private fun mapPathHttpError( node: Snode, ex: HTTP.HTTPRequestFailedException, + path: Path, destination: OnionDestination ): OnionError { val message = ex.body @@ -100,11 +101,43 @@ class HttpOnionTransport @Inject constructor( // Special onion path error: "Next node not found: " val prefix = "Next node not found: " - if (message != null && message.startsWith(prefix)) { + if (message != null && message.startsWith(prefix)){ val failedPk = message.removePrefix(prefix) - return OnionError.IntermediateNodeFailed( - reportingNode = node, - failedPublicKey = failedPk, + + // The missing Snode is the destination + if( failedPk == (destination as? OnionDestination.SnodeDestination)?.snode?.publicKeySet?.ed25519Key){ + return OnionError.DestinationUnreachable( + status = ErrorStatus( + code = statusCode, + message = message, + body = null + ), + destination = destination + ) + } else { // the missing snode is along the path + return OnionError.IntermediateNodeFailed( + reportingNode = node, + failedPublicKey = failedPk, + status = ErrorStatus( + code = statusCode, + message = message, + body = null + ), + destination = destination + ) + } + } + + // check for the case where the SERVER destination no longer exists. + // The rule is: + // - the destination is a ServerDestination + // - the status code is 502 or 504 + // - the message contains the server's destination url + //todo ONION since we can't know that this is happening in the last hop to the destination, is it possible to get 502/504 with the host in the message but that isn't while trying to reach the destination + if(destination is OnionDestination.ServerDestination + && statusCode in 500..504 + && message?.contains(destination.host) == true ){ + return OnionError.DestinationUnreachable( status = ErrorStatus( code = statusCode, message = message, From 327f012ef42359a6db27a48720dde8067fde34e4 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 9 Jan 2026 14:11:25 +1100 Subject: [PATCH 59/77] More todos --- .../java/org/session/libsession/network/NetworkErrorManager.kt | 2 ++ .../org/session/libsession/network/SnodeClientErrorManager.kt | 1 + .../java/org/session/libsession/network/onion/PathManager.kt | 3 +-- .../libsession/network/onion/http/HttpOnionTransport.kt | 1 + 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt index 27f8cbd787..90192891cf 100644 --- a/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt @@ -53,6 +53,8 @@ class NetworkErrorManager @Inject constructor( ctx.path.firstOrNull { it.publicKeySet?.ed25519Key == pk } } + //todo ONION we are actually supposed to handle the bad snode AND also penalise the path in this case, even if the node was swapped out + //todo ONION and in this case handleBadSnode should not strike but definitely remove the snode if (bad != null) pathManager.handleBadSnode(bad) else pathManager.handleBadPath(ctx.path) diff --git a/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt b/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt index 62a210babe..8d164d91f3 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt @@ -43,6 +43,7 @@ class SnodeClientErrorManager @Inject constructor( } else { // if we already got a COS, and syncing the clock wasn't enough // we should consider the destination snode faulty. Penalise it and retry + //todo ONION is this right? The snode in this case is that the destination, not in the path! So this isn't dealing with it properly pathManager.handleBadSnode(ctx.targetSnode) return FailureDecision.Retry } diff --git a/app/src/main/java/org/session/libsession/network/onion/PathManager.kt b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt index 5ec074bf4c..5a2b40aba1 100644 --- a/app/src/main/java/org/session/libsession/network/onion/PathManager.kt +++ b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt @@ -137,8 +137,7 @@ class PathManager @Inject constructor( //todo ONION bad path should have a strike system, not removing path directly //todo ONION bad snode should have a strike system, not removing snode directly - //todo ONION do we need path rotation? - //todo ONION should an intermediate node not found also penalise the path, or just swap out the bad snode? + //todo ONION iOS gives every node in a path a strike if the path is dropped /** Called when we know a specific snode is bad. */ suspend fun handleBadSnode(snode: Snode) { diff --git a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt index a0e9c533b8..77714cc362 100644 --- a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt +++ b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt @@ -100,6 +100,7 @@ class HttpOnionTransport @Inject constructor( //Log.w("Onion Request", "Got an HTTP error. Status: $statusCode, message: $message", ex) // Special onion path error: "Next node not found: " + //todo ONION do we also need to care for "Next node is currently unreachable: or "" val prefix = "Next node not found: " if (message != null && message.startsWith(prefix)){ val failedPk = message.removePrefix(prefix) From ab14e25b99609351b2182120d9c828ed9ee72929 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 9 Jan 2026 15:21:12 +1100 Subject: [PATCH 60/77] todo --- .../java/org/session/libsession/network/NetworkErrorManager.kt | 3 ++- .../libsession/network/onion/http/HttpOnionTransport.kt | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt index 90192891cf..7320df729a 100644 --- a/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt @@ -26,7 +26,8 @@ class NetworkErrorManager @Inject constructor( val bodyText = status?.bodyText //todo ONION investigate why we got stuck in a invalid cyphertext state - //todo ONION how can we deal with errors sent from the destination, but not within the 200 > encrypted package? Currently they will become PathError that will wrongly penalise the path + + //todo ONION switch to a "don't penalise unless its a known issue" + time based path rotation // -------------------------------------------------------------------- // 1) "Found anywhere" rules (path OR destination) diff --git a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt index 77714cc362..8475957ffd 100644 --- a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt +++ b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt @@ -134,7 +134,6 @@ class HttpOnionTransport @Inject constructor( // - the destination is a ServerDestination // - the status code is 502 or 504 // - the message contains the server's destination url - //todo ONION since we can't know that this is happening in the last hop to the destination, is it possible to get 502/504 with the host in the message but that isn't while trying to reach the destination if(destination is OnionDestination.ServerDestination && statusCode in 500..504 && message?.contains(destination.host) == true ){ From 10fd561ec62583036def685eecf6a920c3d15cf1 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 9 Jan 2026 15:48:03 +1100 Subject: [PATCH 61/77] Real path manager logic, with strikes for paths and snodes --- .../libsession/network/onion/PathManager.kt | 303 +++++++++++++++--- 1 file changed, 262 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/onion/PathManager.kt b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt index 5a2b40aba1..95a6eb3762 100644 --- a/app/src/main/java/org/session/libsession/network/onion/PathManager.kt +++ b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt @@ -11,7 +11,6 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -19,6 +18,7 @@ import org.session.libsession.network.model.Path import org.session.libsession.network.model.PathStatus import org.session.libsession.network.snode.SnodeDirectory import org.session.libsession.network.snode.SnodePathStorage +import org.session.libsession.network.snode.SwarmDirectory import org.session.libsignal.crypto.secureRandom import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode @@ -30,7 +30,12 @@ class PathManager @Inject constructor( private val scope: CoroutineScope, private val directory: SnodeDirectory, private val storage: SnodePathStorage, + private val swarmDirectory: SwarmDirectory, ) { + companion object { + private const val STRIKE_THRESHOLD = 3 + } + private val pathSize: Int = 3 private val targetPathCount: Int = 2 @@ -41,9 +46,44 @@ class PathManager @Inject constructor( // Used for synchronization private val buildMutex = Mutex() - private val _isBuilding = MutableStateFlow(false) + // In-memory strike tracking (same lifetime as PathManager) + private val pathStrikes: MutableMap = mutableMapOf() + private val snodeStrikes: MutableMap = mutableMapOf() + + private fun snodeKey(snode: Snode): String = + snode.publicKeySet?.ed25519Key ?: snode.toString() + + private fun pathKey(path: Path): String = + path.joinToString(separator = "|") { it.publicKeySet?.ed25519Key ?: it.toString() } + + private fun increasePathStrike(path: Path): Int { + val key = pathKey(path) + val next = (pathStrikes[key] ?: 0) + 1 + pathStrikes[key] = next + return next + } + + private fun increaseSnodeStrike(snode: Snode): Int { + val key = snodeKey(snode) + val next = (snodeStrikes[key] ?: 0) + 1 + snodeStrikes[key] = next + return next + } + + private fun clearPathStrike(path: Path) { + pathStrikes.remove(pathKey(path)) + } + + private fun clearSnodeStrike(snode: Snode) { + snodeStrikes.remove(snodeKey(snode)) + } + + // ----------------------------- + // Flow Setup + // ----------------------------- + @OptIn(FlowPreview::class) val status: StateFlow = combine(_paths, _isBuilding) { paths, building -> @@ -70,6 +110,10 @@ class PathManager @Inject constructor( } } + // ----------------------------- + // Public API + // ----------------------------- + suspend fun getPath(exclude: Snode? = null): Path { val current = _paths.value if (current.size >= targetPathCount && current.any { exclude == null || !it.contains(exclude) }) { @@ -87,7 +131,6 @@ class PathManager @Inject constructor( } suspend fun rebuildPaths(reusablePaths: List) { - // This ensures callers wait their turn rather than skipping immediately buildMutex.withLock { // Double-check: Did someone populate paths while we were waiting for the lock? // If yes, we can skip building. @@ -99,7 +142,6 @@ class PathManager @Inject constructor( _isBuilding.value = true Log.w("Onion Request", "Rebuilding paths...") try { - // Ensure we actually have a usable pool before doing anything val pool = directory.ensurePoolPopulated() val safeReusable = sanitizePaths(reusablePaths) @@ -128,61 +170,241 @@ class PathManager @Inject constructor( val allPaths = (safeReusable + newPaths).take(targetPathCount) val sanitized = sanitizePaths(allPaths) _paths.value = sanitized + + // Keep strikes only for paths that still exist + val alive = sanitized.map(::pathKey).toSet() + pathStrikes.keys.retainAll(alive) + + Log.w("Onion Request", "Paths rebuilt successfully. Current path count: ${sanitized.size}") } finally { - Log.w("Onion Request", "New path(s) created.") _isBuilding.value = false } } } - //todo ONION bad path should have a strike system, not removing path directly - //todo ONION bad snode should have a strike system, not removing snode directly - //todo ONION iOS gives every node in a path a strike if the path is dropped + /** + * Called when we know a specific snode is bad. + * + * Rules: + * - Striking a snode ALSO strikes the containing path(s). + * - Third strike means drop snode immediately. + * - Dropping a snode swaps it out in any path(s) that contain it (drops path only if unrepairable). + * - Dropping a snode also removes it from pool and (if pubkey known) swarm. + */ + suspend fun handleBadSnode(snode: Snode, publicKey: String? = null) { + buildMutex.withLock { + val paths = _paths.value.toMutableList() + val droppedPathKeys = mutableSetOf() + val droppedSnodeKeys = mutableSetOf() + + val snodeStrikes = increaseSnodeStrike(snode) + Log.w("Onion Request", "Bad snode reported: ${snode.address} (strikes=$snodeStrikes/$STRIKE_THRESHOLD)") + + // Striking a snode also strikes the containing path(s) + val containing = paths.filter { it.contains(snode) }.toList() + for (p in containing) { + val pathStrikes = increasePathStrike(p) + Log.w("Onion Request", " -> Also struck containing path (strikes=$pathStrikes/$STRIKE_THRESHOLD)") + + if (pathStrikes >= STRIKE_THRESHOLD) { + Log.w("Onion Request", " -> Path hit threshold due to snode strike, dropping path (cascade)") + performPathDrop( + path = p, + paths = paths, + publicKey = publicKey, + droppedPathKeys = droppedPathKeys, + droppedSnodeKeys = droppedSnodeKeys, + ) + } + } + + // If snode reached strike threshold => drop snode + if (snodeStrikes >= STRIKE_THRESHOLD) { + Log.w("Onion Request", "Strike threshold reached for snode ${snode.address}, initiating drop cascade") + performSnodeDrop( + snode = snode, + working = paths, + publicKey = publicKey, + droppedPathKeys = droppedPathKeys, + droppedSnodeKeys = droppedSnodeKeys, + ) + } + + _paths.value = sanitizePaths(paths) + } + } - /** Called when we know a specific snode is bad. */ - suspend fun handleBadSnode(snode: Snode) { + /** + * Called when an entire path is considered unreliable. + * + * Rules: + * - Third strike means drop path immediately. + * - Dropping a path strikes each node in the path (which can cascade into node drops). + */ + suspend fun handleBadPath(path: Path) { buildMutex.withLock { - _paths.update { currentList -> - // Locate the bad path in the *current* snapshot - val pathIndex = currentList.indexOfFirst { it.contains(snode) } + val paths = _paths.value.toMutableList() + val target = paths.firstOrNull { it == path } ?: run { + Log.w("Onion Request", "Attempted to strike path not in current list, ignoring") + return + } - // If the node isn't found (e.g., paths were just rebuilt), do nothing - if (pathIndex == -1) return@update currentList + val pathStrikes = increasePathStrike(target) + Log.w("Onion Request", "Bad path reported (strikes=$pathStrikes/$STRIKE_THRESHOLD)") - val newPathsList = currentList.toMutableList() - val pathParams = newPathsList[pathIndex].toMutableList() + if (pathStrikes < STRIKE_THRESHOLD) return - val badIndex = pathParams.indexOfFirst { it == snode } - if (badIndex == -1) return@update currentList + Log.w("Onion Request", "Strike threshold reached for path, initiating drop cascade") - val usedSnodes = newPathsList.flatten().toSet() - val pool = directory.getSnodePool() - val unused = pool.minus(usedSnodes) + val droppedPathKeys = mutableSetOf() + val droppedSnodeKeys = mutableSetOf() - if (unused.isEmpty()) { - Log.w("Onion Request", "No unused snodes to repair path, dropping path entirely") - newPathsList.removeAt(pathIndex) - return@update sanitizePaths(newPathsList) - } + performPathDrop( + path = target, + paths = paths, + publicKey = null, // handleBadPath has no swarm context + droppedPathKeys = droppedPathKeys, + droppedSnodeKeys = droppedSnodeKeys, + ) - val replacement = unused.secureRandom() - Log.w("Onion Request", "Handling bad snode. Repaired path by replacing $snode with $replacement") - pathParams[badIndex] = replacement - newPathsList[pathIndex] = pathParams + _paths.value = sanitizePaths(paths) + } + } - sanitizePaths(newPathsList) + /** + * Drops a path immediately and strikes all nodes within it. + * If any node reaches threshold, drops that node (which swaps it out in any remaining paths). + */ + private suspend fun performPathDrop( + path: Path, + paths: MutableList, + publicKey: String?, + droppedPathKeys: MutableSet, + droppedSnodeKeys: MutableSet, + ) { + if (!paths.contains(path)) return + + val pk = pathKey(path) + if (!droppedPathKeys.add(pk)) return // already dropped in this cascade + + Log.w("Onion Request", "Dropping path: ${path.joinToString(" -> ") { it.address }}") + paths.remove(path) + clearPathStrike(path) + + // Dropping a path strikes each node in that path (may cascade) + for (node in path) { + val sStrikes = increaseSnodeStrike(node) + Log.w("Onion Request", " Struck node ${node.address} from dropped path (strikes=$sStrikes/$STRIKE_THRESHOLD)") + + if (sStrikes >= STRIKE_THRESHOLD) { + Log.w("Onion Request", " Node ${node.address} hit threshold from path drop, dropping snode (cascade)") + performSnodeDrop( + snode = node, + working = paths, + publicKey = publicKey, + droppedPathKeys = droppedPathKeys, + droppedSnodeKeys = droppedSnodeKeys, + ) } } } - /** Called when an entire path is considered unreliable. */ - suspend fun handleBadPath(path: Path) { - buildMutex.withLock { - Log.w("Onion Request", "Path considered bad - Removed from our paths list.") - _paths.update { currentList -> - currentList.filter { it != path } + /** + * Drops a snode from external systems and swaps it out of any paths that contain it. + * Only drops a path if it cannot be repaired. + * + * This does NOT rebuild paths; it swaps only the bad node. + */ + private suspend fun performSnodeDrop( + snode: Snode, + working: MutableList, + publicKey: String?, + droppedPathKeys: MutableSet, + droppedSnodeKeys: MutableSet, + ) { + val sk = snodeKey(snode) + if (!droppedSnodeKeys.add(sk)) return // already dropped in this cascade + + Log.w("Onion Request", "Dropping snode ${snode.address} from systems + swapping out of paths") + + // External cleanup (pool + swarm) + snode.publicKeySet?.ed25519Key?.let { ed25519 -> + directory.dropSnodeFromPool(ed25519) + Log.d("Onion Request", " Removed snode from pool: ${snode.address}") + } + if (publicKey != null) { + swarmDirectory.dropSnodeFromSwarmIfNeeded(snode, publicKey) + Log.d("Onion Request", " Removed snode from swarm for pubkey=$publicKey: ${snode.address}") + } + + // Swap out in any path(s) that still contain it + val pathsToCheck = working.filter { it.contains(snode) }.toList() + for (path in pathsToCheck) { + val result = trySwapOutSnodeInPath(path, snode, working) + + if (result != null) { + val (repaired, replacement) = result + val idx = working.indexOf(path) + if (idx != -1) { + // Path identity changes; clear strikes for the old path identity + clearPathStrike(path) + working[idx] = repaired + Log.w("Onion Request", " Repaired path by swapping ${snode.address} -> ${replacement.address}") + } + } else { + Log.w("Onion Request", " Could not repair path after snode drop; dropping path (cascade)") + performPathDrop( + path = path, + paths = working, + publicKey = publicKey, + droppedPathKeys = droppedPathKeys, + droppedSnodeKeys = droppedSnodeKeys, + ) } } + + // Clear strike once it's dropped and we've finished processing + clearSnodeStrike(snode) + } + + /** + * Swap a single bad snode out of a path, respecting disjointness: + * - cannot use nodes already in other paths + * - cannot use nodes already in this path (except the bad one) + * - cannot use the bad node itself + * + * @return Pair(repairedPath, replacementSnode) or null if no replacement available. + */ + private fun trySwapOutSnodeInPath( + path: Path, + badSnode: Snode, + currentPaths: List, + ): Pair? { + val index = path.indexOfFirst { it == badSnode } + if (index == -1) return null + + val pool = directory.getSnodePool() + + val usedByOtherPaths = currentPaths + .filter { it != path } + .flatMap { it } + .toSet() + + val usedInThisPath = path.toSet() - badSnode + val forbidden = usedByOtherPaths + usedInThisPath + badSnode + + val candidates = pool - forbidden + if (candidates.isEmpty()) { + Log.w("Onion Request", " No available snodes for path repair") + return null + } + + val replacement = candidates.secureRandom() + val repaired = path.toMutableList() + repaired[index] = replacement + + Log.d("Onion Request", " Path repair: ${badSnode.address} -> ${replacement.address}") + return repaired to replacement } private fun selectPath(paths: List, exclude: Snode?): Path { @@ -191,10 +413,9 @@ class PathManager @Inject constructor( } else paths if (candidates.isEmpty()) { - // fallback: ignore exclude and just pick something + Log.w("Onion Request", "No valid paths excluding requested snode, using any available path") return paths.secureRandom() } - return candidates.secureRandom() } @@ -209,4 +430,4 @@ class PathManager @Inject constructor( val all = paths.flatten() return all.size == all.toSet().size } -} \ No newline at end of file +} From 9c1e2282639aa9febf21a09dea67ae7ca0cf8f31 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 9 Jan 2026 15:56:50 +1100 Subject: [PATCH 62/77] forceremove for handleBadSnode --- .../messaging/open_groups/OpenGroupApi.kt | 2 +- .../libsession/network/NetworkErrorManager.kt | 15 ++++++------ .../libsession/network/onion/PathManager.kt | 24 ++++++++++++++++--- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt index 078c664858..517ebfd23c 100644 --- a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt @@ -372,7 +372,7 @@ object OpenGroupApi { x25519PublicKey = serverPublicKey ) } catch (e: Exception) { - //todo ONION handle the case where we get a 400 with "Invalid authentication: this server requires the use of blinded ids" - call capabilities once and retry + //todo ONION handle the case where we get a 400 with "Invalid authentication: this server requires the use of blinded ids" - call capabilities once and retry < FANCHAO when (e) { is OnionError -> Log.e("SOGS", "Failed onion request: ${e.message}", e) else -> Log.e("SOGS", "Failed onion request", e) diff --git a/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt index 7320df729a..9b91da53c9 100644 --- a/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt @@ -49,17 +49,18 @@ class NetworkErrorManager @Inject constructor( snodeDirectory.dropSnodeFromPool(failedKey) } - // If we can map the failed key to an actual snode in this path, prefer handleBadSnode + // find snode from the path and strike it val bad = failedKey?.let { pk -> ctx.path.firstOrNull { it.publicKeySet?.ed25519Key == pk } } - //todo ONION we are actually supposed to handle the bad snode AND also penalise the path in this case, even if the node was swapped out - //todo ONION and in this case handleBadSnode should not strike but definitely remove the snode - if (bad != null) pathManager.handleBadSnode(bad) - else pathManager.handleBadPath(ctx.path) - - return FailureDecision.Retry + // in this case we want handleBadSnode to force remove this snode + if (bad != null) { + pathManager.handleBadSnode(bad, forceRemove = true) + return FailureDecision.Retry + } else { + return FailureDecision.Fail(error) // we couldn't find the snode in the paths + } } is OnionError.DestinationUnreachable -> { diff --git a/app/src/main/java/org/session/libsession/network/onion/PathManager.kt b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt index 95a6eb3762..a3dd23a302 100644 --- a/app/src/main/java/org/session/libsession/network/onion/PathManager.kt +++ b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt @@ -72,6 +72,12 @@ class PathManager @Inject constructor( return next } + private fun setSnodeStrikes(snode: Snode, strikes: Int): Int { + val key = snodeKey(snode) + snodeStrikes[key] = strikes + return strikes + } + private fun clearPathStrike(path: Path) { pathStrikes.remove(pathKey(path)) } @@ -191,14 +197,26 @@ class PathManager @Inject constructor( * - Dropping a snode swaps it out in any path(s) that contain it (drops path only if unrepairable). * - Dropping a snode also removes it from pool and (if pubkey known) swarm. */ - suspend fun handleBadSnode(snode: Snode, publicKey: String? = null) { + suspend fun handleBadSnode( + snode: Snode, + publicKey: String? = null, + forceRemove: Boolean = false + ) { buildMutex.withLock { val paths = _paths.value.toMutableList() val droppedPathKeys = mutableSetOf() val droppedSnodeKeys = mutableSetOf() - val snodeStrikes = increaseSnodeStrike(snode) - Log.w("Onion Request", "Bad snode reported: ${snode.address} (strikes=$snodeStrikes/$STRIKE_THRESHOLD)") + val snodeStrikes = if (forceRemove) { + setSnodeStrikes(snode, STRIKE_THRESHOLD) + } else { + increaseSnodeStrike(snode) + } + + Log.w( + "Onion Request", + "Bad snode reported: ${snode.address} (strikes=$snodeStrikes/$STRIKE_THRESHOLD, forceRemove=$forceRemove)" + ) // Striking a snode also strikes the containing path(s) val containing = paths.filter { it.contains(snode) }.toList() From 238337364d1a5345efae4c47b702b0afed727a71 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 12 Jan 2026 09:47:23 +1100 Subject: [PATCH 63/77] Using inject for the clock --- .../v2/ConversationReactionOverlay.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt index 99772b60f0..31d606ff42 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt @@ -36,8 +36,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.LocalisedTimeUtil.toShortTwoPartString -import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.StringSubstitutionConstants.TIME_LARGE_KEY import org.session.libsession.utilities.ThemeUtil @@ -105,6 +105,7 @@ class ConversationReactionOverlay : FrameLayout { @Inject lateinit var threadDatabase: ThreadDatabase @Inject lateinit var deprecationManager: LegacyGroupDeprecationManager @Inject lateinit var openGroupManager: OpenGroupManager + @Inject lateinit var snodeClock: SnodeClock private var job: Job? = null @@ -623,7 +624,7 @@ class ConversationReactionOverlay : FrameLayout { R.string.delete, { handleActionItemClicked(Action.DELETE) }, R.string.AccessibilityId_deleteMessage, - message.subtitle, + message.subtitle(snodeClock.currentTimeMills()), ThemeUtil.getThemedColor(context, R.attr.danger) ) } @@ -800,12 +801,11 @@ class ConversationReactionOverlay : FrameLayout { } } -private val MessageRecord.subtitle: ((Context) -> CharSequence?)? - get() = if (expiresIn <= 0 || expireStarted <= 0) { +private fun MessageRecord.subtitle(timeMilli: Long): ((Context) -> CharSequence?)? { + return if (expiresIn <= 0 || expireStarted <= 0) { null } else { context -> - //todo ONION is there a better way? >> pass time here - (expiresIn - (MessagingModuleConfiguration.shared.snodeClock.currentTimeMills() - expireStarted)) + (expiresIn - (timeMilli - expireStarted)) .coerceAtLeast(0L) .milliseconds .toShortTwoPartString() @@ -814,4 +814,5 @@ private val MessageRecord.subtitle: ((Context) -> CharSequence?)? .put(TIME_LARGE_KEY, it) .format().toString() } - } \ No newline at end of file + } +} \ No newline at end of file From a466f3e7a7ac8af3628eeb1d31f2de2b4bdf955d Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 12 Jan 2026 11:09:52 +1100 Subject: [PATCH 64/77] Better handling of COS --- .../libsession/network/NetworkErrorManager.kt | 11 ++++++----- .../libsession/network/SnodeClientErrorManager.kt | 14 ++++++++++---- .../session/libsession/network/model/OnionError.kt | 2 +- .../network/onion/http/HttpOnionTransport.kt | 2 +- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt index 9b91da53c9..7c53a8d7ec 100644 --- a/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt @@ -6,8 +6,6 @@ import org.session.libsession.network.model.OnionError import org.session.libsession.network.model.Path import org.session.libsession.network.onion.PathManager import org.session.libsession.network.snode.SnodeDirectory -import org.session.libsession.network.snode.SwarmDirectory -import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode import org.thoughtcrime.securesms.util.NetworkConnectivity import javax.inject.Inject @@ -42,7 +40,7 @@ class NetworkErrorManager @Inject constructor( // 2) Errors along the path (not destination) // -------------------------------------------------------------------- when (error) { - is OnionError.IntermediateNodeFailed -> { + is OnionError.IntermediateNodeUnreachable -> { // Drop snode from pool, rebuild paths without it, penalise path, retry val failedKey = error.failedPublicKey if (failedKey != null) { @@ -55,6 +53,7 @@ class NetworkErrorManager @Inject constructor( } // in this case we want handleBadSnode to force remove this snode + // handleBadSnode also penalises the path if (bad != null) { pathManager.handleBadSnode(bad, forceRemove = true) return FailureDecision.Retry @@ -70,8 +69,9 @@ class NetworkErrorManager @Inject constructor( } is OnionError.PathError -> { - // "Anything else along the path": penalise path; no retries (caller decides) - pathManager.handleBadPath(ctx.path) + // "Anything else along the path": New strategy is to NOT penalise path for unknown reasons; + // We will try to cater to known reasons first and otherwise not penalise and rely on p ath rotation + // no retries (caller decides) return FailureDecision.Fail(error) } @@ -89,6 +89,7 @@ class NetworkErrorManager @Inject constructor( is OnionError.InvalidResponse -> { // penalise path; retry + //todo ONION is this true? By the time we have an InvalidResponse it means we reached the destination, but couldn't decrypt the payload - penalising the path won't fix anything here... Should we instead penalise the destination? pathManager.handleBadPath(ctx.path) return FailureDecision.Retry } diff --git a/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt b/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt index 8d164d91f3..b1bc76e57f 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt @@ -42,10 +42,16 @@ class SnodeClientErrorManager @Inject constructor( return if(resync) FailureDecision.Retry else FailureDecision.Fail(error) } else { // if we already got a COS, and syncing the clock wasn't enough - // we should consider the destination snode faulty. Penalise it and retry - //todo ONION is this right? The snode in this case is that the destination, not in the path! So this isn't dealing with it properly - pathManager.handleBadSnode(ctx.targetSnode) - return FailureDecision.Retry + // we should consider the destination snode faulty. Drop from swarm and retry + if(ctx.publicKey != null) { + swarmDirectory.dropSnodeFromSwarmIfNeeded( + snode = ctx.targetSnode, + publicKey = ctx.publicKey + ) + return FailureDecision.Retry + } else { // if no public key is available, there is no swarm to heal, simply fail + return FailureDecision.Fail(error) + } } } diff --git a/app/src/main/java/org/session/libsession/network/model/OnionError.kt b/app/src/main/java/org/session/libsession/network/model/OnionError.kt index f666366b0e..51cac83145 100644 --- a/app/src/main/java/org/session/libsession/network/model/OnionError.kt +++ b/app/src/main/java/org/session/libsession/network/model/OnionError.kt @@ -29,7 +29,7 @@ sealed class OnionError( * The onion chain broke mid-path: one hop reported that the next node was not found. * failedPublicKey is the ed25519 key of the missing snode if known. */ - class IntermediateNodeFailed( + class IntermediateNodeUnreachable( val reportingNode: Snode?, status: ErrorStatus, val failedPublicKey: String?, diff --git a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt index 8475957ffd..f35e19fcd5 100644 --- a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt +++ b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt @@ -116,7 +116,7 @@ class HttpOnionTransport @Inject constructor( destination = destination ) } else { // the missing snode is along the path - return OnionError.IntermediateNodeFailed( + return OnionError.IntermediateNodeUnreachable( reportingNode = node, failedPublicKey = failedPk, status = ErrorStatus( From 8c16e4e697d9b3eb21b919843b08acfadfe14d8c Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 12 Jan 2026 14:55:12 +1100 Subject: [PATCH 65/77] Only resync the clock after a COS - moving to a "do not penalise path" default --- .../messaging/open_groups/OpenGroupApi.kt | 1 - .../libsession/network/NetworkErrorManager.kt | 20 ++------------ .../session/libsession/network/SnodeClient.kt | 2 +- .../network/SnodeClientErrorManager.kt | 27 ++++++++++++------- .../session/libsession/network/SnodeClock.kt | 15 +---------- .../chooseplan/ChoosePlanHomeScreen.kt | 2 ++ 6 files changed, 23 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt index 517ebfd23c..74c4b270bf 100644 --- a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt @@ -392,7 +392,6 @@ object OpenGroupApi { val nonce = ByteArray(16).also { SecureRandom().nextBytes(it) } - // If you want “strict after COS”: use waitForNetworkAdjustedTime()/1000 val timestamp = TimeUnit.MILLISECONDS.toSeconds( MessagingModuleConfiguration.shared.snodeClock.currentTimeMills() ) diff --git a/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt index 7c53a8d7ec..efa6e8ce17 100644 --- a/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt @@ -62,19 +62,6 @@ class NetworkErrorManager @Inject constructor( } } - is OnionError.DestinationUnreachable -> { - //todo ONION implement this properly - - return FailureDecision.Fail(error) - } - - is OnionError.PathError -> { - // "Anything else along the path": New strategy is to NOT penalise path for unknown reasons; - // We will try to cater to known reasons first and otherwise not penalise and rely on p ath rotation - // no retries (caller decides) - return FailureDecision.Fail(error) - } - is OnionError.GuardUnreachable -> { // We couldn't reach the guard, yet we seem to have network connectivity: // punish the node and try again @@ -98,8 +85,8 @@ class NetworkErrorManager @Inject constructor( return FailureDecision.Retry } - is OnionError.DestinationError -> { - FailureDecision.Fail(error) + else -> { + return FailureDecision.Fail(error) } } @@ -107,9 +94,6 @@ class NetworkErrorManager @Inject constructor( // 3) Destination payload rules - currently this doesn't handle // DestinatioErrors directly. The clients' error manager do. // -------------------------------------------------------------------- - - // Default: fail - return FailureDecision.Fail(error) } } diff --git a/app/src/main/java/org/session/libsession/network/SnodeClient.kt b/app/src/main/java/org/session/libsession/network/SnodeClient.kt index efd1a36f7d..1fae0cddf4 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClient.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClient.kt @@ -417,7 +417,7 @@ class SnodeClient @Inject constructor( publicKey = publicKey, pickTarget = { swarmDirectory.getSingleTargetSnode(publicKey) } ) { snode -> - val timestamp = snodeClock.waitForNetworkAdjustedTime() + val timestamp = snodeClock.currentTimeMills() val params = buildAuthenticatedParameters( auth = auth, diff --git a/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt b/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt index b1bc76e57f..da70315cff 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt @@ -24,6 +24,18 @@ class SnodeClientErrorManager @Inject constructor( val code = status?.code val bodyText = status?.bodyText + // -------------------------------------------------------------------- + // Path Errors + // -------------------------------------------------------------------- + if (error is OnionError.DestinationUnreachable) { + // in the case of Snode destination being unreachable, we should remove that snode + // from the pool and swarm (if pubkey is available) + // handleBadSnode will handle removing the snode from the paths/pool/swarm and clean up the strikes + // if needed + pathManager.handleBadSnode(snode = ctx.targetSnode, publicKey = ctx.publicKey, forceRemove = true) + return FailureDecision.Retry + } + // -------------------------------------------------------------------- // Destination payload rules // -------------------------------------------------------------------- @@ -42,16 +54,11 @@ class SnodeClientErrorManager @Inject constructor( return if(resync) FailureDecision.Retry else FailureDecision.Fail(error) } else { // if we already got a COS, and syncing the clock wasn't enough - // we should consider the destination snode faulty. Drop from swarm and retry - if(ctx.publicKey != null) { - swarmDirectory.dropSnodeFromSwarmIfNeeded( - snode = ctx.targetSnode, - publicKey = ctx.publicKey - ) - return FailureDecision.Retry - } else { // if no public key is available, there is no swarm to heal, simply fail - return FailureDecision.Fail(error) - } + // we should consider the destination snode faulty. Drop from pool and swarm swarm and retry + // handleBadSnode will handle removing the snode from the paths/pool/swarm and clean up the strikes + // if needed + pathManager.handleBadSnode(snode = ctx.targetSnode, publicKey = ctx.publicKey, forceRemove = true) + return FailureDecision.Retry } } diff --git a/app/src/main/java/org/session/libsession/network/SnodeClock.kt b/app/src/main/java/org/session/libsession/network/SnodeClock.kt index 3dc49279d5..a68bdb9902 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClock.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClock.kt @@ -35,7 +35,7 @@ class SnodeClock @Inject constructor( @param:ManagerScope private val scope: CoroutineScope, private val snodeDirectory: SnodeDirectory, private val snodeClient: Lazy, -) : OnAppStartupComponent { +) { private val instantState = MutableStateFlow(null) @@ -49,12 +49,6 @@ class SnodeClock @Inject constructor( // 10 Minutes in milliseconds private val minSyncIntervalMs = 10 * 60 * 1000L - override fun onPostAppStarted() { - scope.launch { - resyncClock() - } - } - /** * Resync by querying 3 random snodes and setting time to the median of their adjusted times. * * Rules: @@ -172,13 +166,6 @@ class SnodeClock @Inject constructor( return out.toList() } - /** - * Wait for the network adjusted time to come through. - */ - suspend fun waitForNetworkAdjustedTime(): Long { - return instantState.filterNotNull().first().now() - } - /** * Get the current time in milliseconds. If the network time is not available yet, this method * will return the current system time. diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanHomeScreen.kt index 135bad2487..689edc28fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanHomeScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanHomeScreen.kt @@ -36,6 +36,8 @@ fun ChoosePlanHomeScreen( // there is an active subscription but from a different platform or from the // same platform but a different account // or we have no billing APIs + // This check is to cover the case where the back end tells us we have a subscription, + // but the local subscription store sees no subscription for the logged user (logged on the subscription store) subscription.providerData.isFromAnotherPlatform() || !planData.hasValidSubscription || !planData.hasBillingCapacity -> From 2a637af6188428c48f628742d38415f52ee6c609 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 12 Jan 2026 15:00:19 +1100 Subject: [PATCH 66/77] Renamed method --- .../messaging/jobs/InviteContactsJob.kt | 2 +- .../messaging/open_groups/OpenGroupApi.kt | 2 +- .../sending_receiving/GroupMessageHandler.kt | 2 +- .../sending_receiving/MessageParser.kt | 2 +- .../MessageRequestResponseHandler.kt | 2 +- .../sending_receiving/MessageSender.kt | 6 +++--- .../sending_receiving/VisibleMessageHandler.kt | 2 +- .../sending_receiving/pollers/Poller.kt | 3 +-- .../session/libsession/network/SnodeClient.kt | 7 +++---- .../session/libsession/network/SnodeClock.kt | 11 ++++------- .../securesms/MediaPreviewActivity.kt | 4 ++-- .../securesms/configs/ConfigToDatabaseSync.kt | 2 +- .../securesms/configs/ConfigUploader.kt | 4 ++-- .../DisappearingMessages.kt | 2 +- .../conversation/v2/ConversationActivityV2.kt | 12 ++++++------ .../v2/ConversationReactionOverlay.kt | 2 +- .../v2/components/ExpirationTimerView.kt | 2 +- .../securesms/database/MmsDatabase.kt | 2 +- .../securesms/database/SmsDatabase.java | 2 +- .../thoughtcrime/securesms/database/Storage.kt | 10 +++++----- .../securesms/database/ThreadDatabase.java | 4 ++-- .../securesms/dependencies/ConfigFactory.kt | 6 +++--- .../securesms/groups/GroupManagerV2Impl.kt | 18 +++++++++--------- .../securesms/groups/GroupPoller.kt | 2 +- .../groups/handler/RemoveGroupMemberHandler.kt | 6 +++--- .../securesms/home/HomeActivity.kt | 2 +- .../securesms/media/MediaOverviewViewModel.kt | 2 +- .../notifications/AndroidAutoReplyReceiver.kt | 4 ++-- .../notifications/MarkReadProcessor.kt | 6 +++--- .../notifications/MarkReadReceiver.kt | 2 +- .../notifications/RemoteReplyReceiver.kt | 2 +- .../securesms/pro/FetchProDetailsWorker.kt | 2 +- .../securesms/pro/api/GetProDetails.kt | 2 +- .../DefaultConversationRepository.kt | 2 +- .../service/ExpiringMessageManager.kt | 8 ++++---- .../securesms/webrtc/CallManager.kt | 2 +- .../securesms/webrtc/CallMessageProcessor.kt | 2 +- 37 files changed, 74 insertions(+), 79 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt index e08fce174e..03d546c69a 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt @@ -72,7 +72,7 @@ class InviteContactsJob @AssistedInject constructor( configs.groupInfo.getName() to configs.groupKeys.makeSubAccount(memberSessionId) } - val timestamp = snodeClock.currentTimeMills() + val timestamp = snodeClock.currentTimeMillis() val signature = ED25519.sign( ed25519PrivateKey = adminKey.data, message = buildGroupInviteSignature(memberId, timestamp), diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt index 74c4b270bf..4c84007037 100644 --- a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt @@ -393,7 +393,7 @@ object OpenGroupApi { val nonce = ByteArray(16).also { SecureRandom().nextBytes(it) } val timestamp = TimeUnit.MILLISECONDS.toSeconds( - MessagingModuleConfiguration.shared.snodeClock.currentTimeMills() + MessagingModuleConfiguration.shared.snodeClock.currentTimeMillis() ) val bodyHash = when { diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/GroupMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/GroupMessageHandler.kt index df8926bb6c..611ce42722 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/GroupMessageHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/GroupMessageHandler.kt @@ -43,7 +43,7 @@ class GroupMessageHandler @Inject constructor( } // Update profile if needed - ProfileUpdateHandler.Updates.create(proto, clock.currentTimeMills(), pro)?.let { updates -> + ProfileUpdateHandler.Updates.create(proto, clock.currentTimeMillis(), pro)?.let { updates -> profileUpdateHandler.handleProfileUpdate( senderId = AccountId(message.sender!!), updates = updates, diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt index 7a74912d20..b1df041b03 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt @@ -139,7 +139,7 @@ class MessageParser @Inject constructor( message.sender = sender.hexString message.recipient = currentUserId.hexString message.sentTimestamp = messageTimestampMs - message.receivedTimestamp = snodeClock.currentTimeMills() + message.receivedTimestamp = snodeClock.currentTimeMillis() message.isSenderSelf = isSenderSelf // Only process pro features post pro launch diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt index bdbc4cee68..99e517673e 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt @@ -87,7 +87,7 @@ class MessageRequestResponseHandler @Inject constructor( // Always process the profile update if any. We don't need // to process profile for other kind of messages as they should be handled elsewhere - ProfileUpdateHandler.Updates.create(proto, clock.currentTimeMills(), pro)?.let { updates -> + ProfileUpdateHandler.Updates.create(proto, clock.currentTimeMillis(), pro)?.let { updates -> profileUpdateHandler.get().handleProfileUpdate( senderId = (sender.address as Address.Standard).accountId, updates = updates, diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt index a216800797..a5c1f7db7f 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt @@ -125,7 +125,7 @@ class MessageSender @Inject constructor( // Attach pro proof val proProof = configFactory.withUserConfigs { it.userProfile.getProConfig() }?.proProof - if (proProof != null && proProof.expiryMs > snodeClock.currentTimeMills()) { + if (proProof != null && proProof.expiryMs > snodeClock.currentTimeMillis()) { builder.proMessageBuilder.proofBuilder.copyFromLibSession(proProof) } else { // If we don't have any valid pro proof, clear the pro message @@ -156,7 +156,7 @@ class MessageSender @Inject constructor( "Missing user key" } // Set the timestamp, sender and recipient - val messageSendTime = snodeClock.currentTimeMills() + val messageSendTime = snodeClock.currentTimeMillis() if (message.sentTimestamp == null) { message.sentTimestamp = messageSendTime // Visible messages will already have their sent timestamp set @@ -306,7 +306,7 @@ class MessageSender @Inject constructor( // Open Groups private suspend fun sendToOpenGroupDestination(destination: Destination, message: Message) { if (message.sentTimestamp == null) { - message.sentTimestamp = snodeClock.currentTimeMills() + message.sentTimestamp = snodeClock.currentTimeMillis() } // Attach the blocks message requests info configFactory.withUserConfigs { configs -> diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt index 60d8dc6839..eea91d02d9 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt @@ -211,7 +211,7 @@ class VisibleMessageHandler @Inject constructor( if (runProfileUpdate && senderAddress is Address.WithAccountId) { val updates = ProfileUpdateHandler.Updates.create( content = proto, - nowMills = clock.currentTimeMills(), + nowMills = clock.currentTimeMillis(), pro = pro ) diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt index 52344092a8..52192f4bd3 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt @@ -33,7 +33,6 @@ import org.session.libsession.messaging.sending_receiving.ReceivedMessageProcess import org.session.libsession.network.SnodeClient import org.session.libsession.network.SnodeClock import org.session.libsession.network.snode.SwarmDirectory -import org.session.libsession.network.snode.SwarmStorage import org.session.libsession.snode.model.RetrieveMessageResponse import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress @@ -375,7 +374,7 @@ class Poller @AssistedInject constructor( snodeClient.buildAuthenticatedAlterTtlBatchRequest( messageHashes = hashesToExtend.toList(), auth = userAuth, - newExpiry = snodeClock.currentTimeMills() + 14.days.inWholeMilliseconds, + newExpiry = snodeClock.currentTimeMillis() + 14.days.inWholeMilliseconds, extend = true ) ) diff --git a/app/src/main/java/org/session/libsession/network/SnodeClient.kt b/app/src/main/java/org/session/libsession/network/SnodeClient.kt index 1fae0cddf4..d85054e32f 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClient.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClient.kt @@ -19,7 +19,6 @@ import network.loki.messenger.libsession_util.ED25519 import network.loki.messenger.libsession_util.Hash import network.loki.messenger.libsession_util.SessionEncrypt import org.session.libsession.network.model.ErrorStatus -import org.session.libsession.network.model.FailureDecision import org.session.libsession.network.model.OnionDestination import org.session.libsession.network.model.OnionError import org.session.libsession.network.onion.Version @@ -306,7 +305,7 @@ class SnodeClient @Inject constructor( "Message sent to ${message.recipient} but authenticated with ${auth.accountId.hexString}" } - val timestamp = snodeClock.currentTimeMills() + val timestamp = snodeClock.currentTimeMillis() buildAuthenticatedParameters( auth = auth, @@ -417,7 +416,7 @@ class SnodeClient @Inject constructor( publicKey = publicKey, pickTarget = { swarmDirectory.getSingleTargetSnode(publicKey) } ) { snode -> - val timestamp = snodeClock.currentTimeMills() + val timestamp = snodeClock.currentTimeMillis() val params = buildAuthenticatedParameters( auth = auth, @@ -571,7 +570,7 @@ class SnodeClient @Inject constructor( auth: SwarmAuth, namespace: Int?, verificationData: ((namespaceText: String, timestamp: Long) -> Any)? = null, - timestamp: Long = snodeClock.currentTimeMills(), + timestamp: Long = snodeClock.currentTimeMillis(), builder: MutableMap.() -> Unit = {} ): Map { return buildMap { diff --git a/app/src/main/java/org/session/libsession/network/SnodeClock.kt b/app/src/main/java/org/session/libsession/network/SnodeClock.kt index a68bdb9902..ac1815b5c6 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClock.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClock.kt @@ -10,8 +10,6 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.sync.Mutex @@ -20,7 +18,6 @@ import kotlinx.coroutines.withTimeout import org.session.libsession.network.snode.SnodeDirectory import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.dependencies.ManagerScope -import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import java.util.Date import javax.inject.Inject import javax.inject.Singleton @@ -170,13 +167,13 @@ class SnodeClock @Inject constructor( * Get the current time in milliseconds. If the network time is not available yet, this method * will return the current system time. */ - fun currentTimeMills(): Long { + fun currentTimeMillis(): Long { return instantState.value?.now() ?: System.currentTimeMillis() } - fun currentTimeSeconds(): Long = currentTimeMills() / 1000 + fun currentTimeSeconds(): Long = currentTimeMillis() / 1000 - fun currentTime(): java.time.Instant = java.time.Instant.ofEpochMilli(currentTimeMills()) + fun currentTime(): java.time.Instant = java.time.Instant.ofEpochMilli(currentTimeMillis()) /** * Delay until the specified instant. If the instant is in the past or now, this method returns @@ -185,7 +182,7 @@ class SnodeClock @Inject constructor( * @return true if delayed, false if the instant is in the past */ suspend fun delayUntil(instant: java.time.Instant): Boolean { - val now = currentTimeMills() + val now = currentTimeMillis() val target = instant.toEpochMilli() return if (target > now) { delay(target - now) diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt index 4e55f94bd7..86cc81efba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt @@ -524,7 +524,7 @@ class MediaPreviewActivity : ScreenLockActionBarActivity(), } .onAllGranted { val saveTask = SaveAttachmentTask(this@MediaPreviewActivity) - val saveDate = if (mediaItem.date > 0) mediaItem.date else snodeClock.currentTimeMills() + val saveDate = if (mediaItem.date > 0) mediaItem.date else snodeClock.currentTimeMillis() saveTask.executeOnExecutor( AsyncTask.THREAD_POOL_EXECUTOR, SaveAttachmentTask.Attachment( @@ -555,7 +555,7 @@ class MediaPreviewActivity : ScreenLockActionBarActivity(), if (conversationAddress == null || conversationAddress?.isGroupOrCommunity == true) return val message = DataExtractionNotification( MediaSaved( - snodeClock.currentTimeMills() + snodeClock.currentTimeMillis() ) ) messageSender.send(message, conversationAddress!!) diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt index d98ae62c7c..2f3636ca8e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt @@ -204,7 +204,7 @@ class ConfigToDatabaseSync @Inject constructor( storage.addClosedGroupPublicKey(group.accountId) // Store the encryption key pair val keyPair = ECKeyPair(DjbECPublicKey(group.encPubKey.data), DjbECPrivateKey(group.encSecKey.data)) - storage.addClosedGroupEncryptionKeyPair(keyPair, group.accountId, clock.currentTimeMills()) + storage.addClosedGroupEncryptionKeyPair(keyPair, group.accountId, clock.currentTimeMillis()) // Notify the PN server PushRegistryV1.subscribeGroup(group.accountId, publicKey = myAccountId.hexString) threadDatabase.setCreationDate(threadId, formationTimestamp) diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt index a42c5640e2..bfbba69922 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt @@ -218,7 +218,7 @@ class ConfigUploader @Inject constructor( auth.accountId.hexString, Base64.encodeBytes(push), SnodeMessage.CONFIG_TTL, - clock.currentTimeMills(), + clock.currentTimeMillis(), ), auth ), @@ -281,7 +281,7 @@ class ConfigUploader @Inject constructor( // process will be cancelled. This is the requirement of pushing config: all messages have // to be sent successfully for us to consider this process as success val responses = coroutineScope { - val timestamp = clock.currentTimeMills() + val timestamp = clock.currentTimeMillis() Log.d(TAG, "Pushing ${push.messages.size} config messages") diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt index 33d31d0938..009ca70af0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt @@ -42,7 +42,7 @@ class DisappearingMessages @Inject constructor( sender = loginStateRepository.getLocalNumber() isSenderSelf = true recipient = address.toString() - sentTimestamp = clock.currentTimeMills() + sentTimestamp = clock.currentTimeMillis() } messageExpirationManager.insertExpirationTimerMessage(message) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 400fbf08d3..c1623e8732 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -841,7 +841,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, if (isUnread) { storage.markConversationAsRead( viewModel.threadId, - clock.currentTimeMills() + clock.currentTimeMillis() ) } } @@ -1793,7 +1793,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, // Create the message val recipient = viewModel.recipient val reactionMessage = VisibleMessage() - val emojiTimestamp = snodeClock.currentTimeMills() + val emojiTimestamp = snodeClock.currentTimeMillis() reactionMessage.sentTimestamp = emojiTimestamp val author = loginStateRepository.getLocalNumber() @@ -1861,7 +1861,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, private fun sendEmojiRemoval(emoji: String, originalMessage: MessageRecord) { val recipient = viewModel.recipient val message = VisibleMessage() - val emojiTimestamp = snodeClock.currentTimeMills() + val emojiTimestamp = snodeClock.currentTimeMillis() message.sentTimestamp = emojiTimestamp val author = loginStateRepository.getLocalNumber() @@ -2156,7 +2156,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, private fun sendTextOnlyMessage(hasPermissionToSendSeed: Boolean = false): Pair? { val recipient = viewModel.recipient - val sentTimestamp = snodeClock.currentTimeMills() + val sentTimestamp = snodeClock.currentTimeMillis() viewModel.implicitlyApproveRecipient()?.let { conversationApprovalJob = it } val text = getMessageBody() val isNoteToSelf = recipient.isLocalNumber @@ -2215,7 +2215,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, deleteAttachmentFilesAfterSave: Boolean = false, ): Pair? { val recipient = viewModel.recipient - val sentTimestamp = snodeClock.currentTimeMills() + val sentTimestamp = snodeClock.currentTimeMillis() viewModel.implicitlyApproveRecipient()?.let { conversationApprovalJob = it } // Create the message @@ -2801,7 +2801,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, private fun sendMediaSavedNotification() { val recipient = viewModel.recipient if (recipient.isGroupOrCommunityRecipient) { return } - val timestamp = snodeClock.currentTimeMills() + val timestamp = snodeClock.currentTimeMillis() val kind = DataExtractionNotification.Kind.MediaSaved(timestamp) val message = DataExtractionNotification(kind) messageSender.send(message, recipient.address) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt index 31d606ff42..447527134c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt @@ -624,7 +624,7 @@ class ConversationReactionOverlay : FrameLayout { R.string.delete, { handleActionItemClicked(Action.DELETE) }, R.string.AccessibilityId_deleteMessage, - message.subtitle(snodeClock.currentTimeMills()), + message.subtitle(snodeClock.currentTimeMillis()), ThemeUtil.getThemedColor(context, R.attr.danger) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.kt index 74843c8333..169b478912 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.kt @@ -46,7 +46,7 @@ class ExpirationTimerView @JvmOverloads constructor( return } - val elapsedTime = MessagingModuleConfiguration.shared.snodeClock.currentTimeMills() - startedAt + val elapsedTime = MessagingModuleConfiguration.shared.snodeClock.currentTimeMillis() - startedAt val remainingTime = expiresIn - elapsedTime val remainingPercent = (remainingTime / expiresIn.toFloat()).coerceIn(0f, 1f) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt index 82e0dc5e5d..047af1b67c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -586,7 +586,7 @@ class MmsDatabase @Inject constructor( // In open groups messages should be sorted by their server timestamp var receivedTimestamp = serverTimestamp if (serverTimestamp == 0L) { - receivedTimestamp = snodeClock.currentTimeMills() + receivedTimestamp = snodeClock.currentTimeMillis() } contentValues.put(DATE_RECEIVED, receivedTimestamp) contentValues.put(EXPIRES_IN, message.expiresInMillis) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index c4b2a97a13..48159a509a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -548,7 +548,7 @@ public long insertMessageOutbox(long threadId, OutgoingTextMessage message, contentValues.put(ADDRESS, address.toString()); contentValues.put(THREAD_ID, threadId); contentValues.put(BODY, message.getMessage()); - contentValues.put(DATE_RECEIVED, snodeClock.currentTimeMills()); + contentValues.put(DATE_RECEIVED, snodeClock.currentTimeMillis()); contentValues.put(DATE_SENT, message.getSentTimestampMillis()); contentValues.put(READ, 1); contentValues.put(TYPE, type); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index 6ffb1e4739..84719c59d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -750,7 +750,7 @@ open class Storage @Inject constructor( } override fun insertGroupInfoChange(message: GroupUpdated, closedGroup: AccountId) { - val sentTimestamp = message.sentTimestamp ?: clock.currentTimeMills() + val sentTimestamp = message.sentTimestamp ?: clock.currentTimeMillis() val senderPublicKey = message.sender val groupName = configFactory.withGroupConfigs(closedGroup) { it.groupInfo.getName() } ?: configFactory.getGroup(closedGroup)?.name @@ -761,7 +761,7 @@ open class Storage @Inject constructor( } override fun insertGroupInfoLeaving(closedGroup: AccountId) { - val sentTimestamp = clock.currentTimeMills() + val sentTimestamp = clock.currentTimeMillis() val senderPublicKey = getUserPublicKey() ?: return val updateData = UpdateMessageData.buildGroupLeaveUpdate(UpdateMessageData.Kind.GroupLeaving) @@ -769,7 +769,7 @@ open class Storage @Inject constructor( } override fun insertGroupInfoErrorQuit(closedGroup: AccountId) { - val sentTimestamp = clock.currentTimeMills() + val sentTimestamp = clock.currentTimeMillis() val senderPublicKey = getUserPublicKey() ?: return val groupName = configFactory.withGroupConfigs(closedGroup) { it.groupInfo.getName() } ?: configFactory.getGroup(closedGroup)?.name @@ -1096,7 +1096,7 @@ open class Storage @Inject constructor( val message = IncomingMediaMessage( from = fromSerialized(userPublicKey), - sentTimeMillis = clock.currentTimeMills(), + sentTimeMillis = clock.currentTimeMillis(), expiresIn = 0, expireStartedAt = 0, isMessageRequestResponse = true, @@ -1118,7 +1118,7 @@ open class Storage @Inject constructor( val recipient = recipientRepository.getRecipientSync(address) val expiryMode = recipient.expiryMode.coerceSendToRead() val expiresInMillis = expiryMode.expiryMillis - val expireStartedAt = if (expiryMode != ExpiryMode.NONE) clock.currentTimeMills() else 0 + val expireStartedAt = if (expiryMode != ExpiryMode.NONE) clock.currentTimeMillis() else 0 val callMessage = IncomingTextMessage( callMessageType = callMessageType, sender = address, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index 5d06140349..26d1957150 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -446,7 +446,7 @@ public List setRead(long threadId, boolean lastSeen) { contentValues.put(UNREAD_MENTION_COUNT, 0); if (lastSeen) { - contentValues.put(LAST_SEEN, snodeClock.currentTimeMills()); + contentValues.put(LAST_SEEN, snodeClock.currentTimeMillis()); } SQLiteDatabase db = getWritableDatabase(); @@ -555,7 +555,7 @@ public boolean setLastSeen(long threadId, long timestamp) { SQLiteDatabase db = getWritableDatabase(); ContentValues contentValues = new ContentValues(1); - long lastSeenTime = timestamp == -1 ? snodeClock.currentTimeMills() : timestamp; + long lastSeenTime = timestamp == -1 ? snodeClock.currentTimeMillis() : timestamp; contentValues.put(LAST_SEEN, lastSeenTime); db.beginTransaction(); db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(threadId)}); diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt index 56cbd27f86..23a45531fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt @@ -165,7 +165,7 @@ class ConfigFactory @Inject constructor( if (dumped != null) { coroutineScope.launch { val userAccountId = requiresCurrentUserAccountId() - val currentTimeMs = clock.currentTimeMills() + val currentTimeMs = clock.currentTimeMillis() for ((type, data) in dumped) { configDatabase.storeConfig( @@ -218,7 +218,7 @@ class ConfigFactory @Inject constructor( keysConfig = dumped.first, infoConfig = dumped.second, memberConfig = dumped.third, - timestamp = clock.currentTimeMills() + timestamp = clock.currentTimeMillis() ) } } @@ -649,7 +649,7 @@ private class GroupConfigsImpl( keysConfig = groupKeys.dump(), infoConfig = groupInfo.dump(), memberConfig = groupMembers.dump(), - timestamp = clock.currentTimeMills() + timestamp = clock.currentTimeMillis() ) return true } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt index f55c79b4a8..d1d7248bb0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -130,7 +130,7 @@ class GroupManagerV2Impl @Inject constructor( val ourAccountId = requireNotNull(storage.getUserPublicKey()) { "Our account ID is not available" } - val groupCreationTimestamp = clock.currentTimeMills() + val groupCreationTimestamp = clock.currentTimeMillis() // Create a group in the user groups config val group = configFactory.withUserConfigs { configs -> @@ -299,7 +299,7 @@ class GroupManagerV2Impl @Inject constructor( recipient = group.hexString, data = Base64.encodeBytes(memberKey), ttl = SnodeMessage.CONFIG_TTL, - timestamp = clock.currentTimeMills(), + timestamp = clock.currentTimeMillis(), ), auth = groupAuth, ) @@ -377,7 +377,7 @@ class GroupManagerV2Impl @Inject constructor( newMembers: Collection, shareHistory : Boolean = false ) { - val timestamp = clock.currentTimeMills() + val timestamp = clock.currentTimeMillis() val signature = ED25519.sign( message = buildMemberChangeSignature(GroupUpdateMemberChangeMessage.Type.ADDED, timestamp), ed25519PrivateKey = adminKey @@ -415,7 +415,7 @@ class GroupManagerV2Impl @Inject constructor( alsoRemoveMembersMessage = removeMessages, ) - val timestamp = clock.currentTimeMills() + val timestamp = clock.currentTimeMillis() val signature = ED25519.sign( message = buildMemberChangeSignature( GroupUpdateMemberChangeMessage.Type.REMOVED, @@ -527,7 +527,7 @@ class GroupManagerV2Impl @Inject constructor( } // Build a group update message to the group telling members someone has been promoted - val timestamp = clock.currentTimeMills() + val timestamp = clock.currentTimeMillis() val signature = ED25519.sign( message = buildMemberChangeSignature( GroupUpdateMemberChangeMessage.Type.PROMOTED, @@ -697,7 +697,7 @@ class GroupManagerV2Impl @Inject constructor( configFactory.withMutableUserConfigs { configs -> configs.userGroups.set(group.copy( invited = false, - joinedAtSecs = TimeUnit.MILLISECONDS.toSeconds(clock.currentTimeMills()) + joinedAtSecs = TimeUnit.MILLISECONDS.toSeconds(clock.currentTimeMillis()) )) } @@ -972,7 +972,7 @@ class GroupManagerV2Impl @Inject constructor( return@launchAndWait } - val timestamp = clock.currentTimeMills() + val timestamp = clock.currentTimeMillis() val signature = ED25519.sign( message = buildInfoChangeSignature(Type.NAME, timestamp), ed25519PrivateKey = adminKey @@ -1044,7 +1044,7 @@ class GroupManagerV2Impl @Inject constructor( } // Construct a message to ask members to delete the messages, sign if we are admin, then send - val timestamp = clock.currentTimeMills() + val timestamp = clock.currentTimeMillis() val signature = group.adminKey?.data?.let { key -> ED25519.sign( message = buildDeleteMemberContentSignature( @@ -1187,7 +1187,7 @@ class GroupManagerV2Impl @Inject constructor( val adminKey = requireAdminAccess(groupId) // Construct a message to notify the group members about the expiration timer change - val timestamp = clock.currentTimeMills() + val timestamp = clock.currentTimeMillis() val signature = ED25519.sign( message = buildInfoChangeSignature(Type.DISAPPEARING_MESSAGES, timestamp), ed25519PrivateKey = adminKey diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt index 00c8574cc5..3de7ca2db3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt @@ -272,7 +272,7 @@ class GroupPoller @AssistedInject constructor( snodeClient.buildAuthenticatedAlterTtlBatchRequest( messageHashes = configHashesToExtends.toList(), auth = groupAuth, - newExpiry = clock.currentTimeMills() + 14.days.inWholeMilliseconds, + newExpiry = clock.currentTimeMillis() + 14.days.inWholeMilliseconds, extend = true ), ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt index eca6447457..26873744fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt @@ -184,7 +184,7 @@ class RemoveGroupMemberHandler @Inject constructor( if (deletingMessagesForMembers.isNotEmpty()) { val threadId = storage.getThreadId(Address.fromSerialized(groupAccountId.hexString)) if (threadId != null) { - val until = clock.currentTimeMills() + val until = clock.currentTimeMillis() for ((member, _) in deletingMessagesForMembers) { try { messageDataProvider.markUserMessagesAsDeleted( @@ -206,7 +206,7 @@ class RemoveGroupMemberHandler @Inject constructor( groupAccountId: String, memberSessionIDs: Sequence ): SnodeMessage { - val timestamp = clock.currentTimeMills() + val timestamp = clock.currentTimeMillis() return messageSender.buildWrappedMessageToSnode( destination = Destination.ClosedGroup(groupAccountId), @@ -260,6 +260,6 @@ class RemoveGroupMemberHandler @Inject constructor( ) ), ttl = SnodeMessage.DEFAULT_TTL, - timestamp = clock.currentTimeMills() + timestamp = clock.currentTimeMillis() ) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 552a09dfd9..07dc49c6fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -748,7 +748,7 @@ class HomeActivity : ScreenLockActionBarActivity(), private fun markAllAsRead(thread: ThreadRecord) { lifecycleScope.launch(Dispatchers.Default) { - storage.markConversationAsRead(thread.threadId, clock.currentTimeMills()) + storage.markConversationAsRead(thread.threadId, clock.currentTimeMillis()) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt index a4276808ab..43214116cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt @@ -288,7 +288,7 @@ class MediaOverviewViewModel @AssistedInject constructor( successCount > 0 && !address.isGroupOrCommunity) { withContext(Dispatchers.Default) { - val timestamp = snodeClock.currentTimeMills() + val timestamp = snodeClock.currentTimeMillis() val kind = DataExtractionNotification.Kind.MediaSaved(timestamp) val message = DataExtractionNotification(kind) messageSender.send(message, address) diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.kt index daf1c6a0fa..6e9c764009 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.kt @@ -100,7 +100,7 @@ class AndroidAutoReplyReceiver : BroadcastReceiver() { val message = VisibleMessage() message.text = responseText.toString() proStatusManager.addProFeatures(message) - message.sentTimestamp = snodeClock.currentTimeMills() + message.sentTimestamp = snodeClock.currentTimeMillis() messageSender.send(message, address!!) val expiryMode = recipientRepository.getRecipientSync(address).expiryMode val expiresInMillis = expiryMode.expiryMillis @@ -140,7 +140,7 @@ class AndroidAutoReplyReceiver : BroadcastReceiver() { replyThreadId, reply, false, - snodeClock.currentTimeMills(), + snodeClock.currentTimeMillis(), true ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt index 285c39c5ff..d2e55e5f27 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt @@ -68,7 +68,7 @@ class MarkReadProcessor @Inject constructor( smsDatabase } - db.markExpireStarted(it.expirationInfo.id.id, snodeClock.currentTimeMills()) + db.markExpireStarted(it.expirationInfo.id.id, snodeClock.currentTimeMillis()) } hashToDisappearAfterReadMessage(context, markedReadMessages)?.let { hashToMessages -> @@ -103,7 +103,7 @@ class MarkReadProcessor @Inject constructor( ).forEach { (expiresIn, hashes) -> snodeClient.alterTtl( messageHashes = hashes, - newExpiry = snodeClock.currentTimeMills() + expiresIn, + newExpiry = snodeClock.currentTimeMillis() + expiresIn, auth = checkNotNull(storage.userAuth) { "No authorized user" }, shorten = true ) @@ -129,7 +129,7 @@ class MarkReadProcessor @Inject constructor( .forEach { (address, messages) -> messages.map { it.timetamp } .let(::ReadReceipt) - .apply { sentTimestamp = snodeClock.currentTimeMills() } + .apply { sentTimestamp = snodeClock.currentTimeMillis() } .let { messageSender.send(it, address) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt index bc2bce320c..5f98b954dd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt @@ -26,7 +26,7 @@ class MarkReadReceiver : BroadcastReceiver() { val threadIds = intent.getLongArrayExtra(THREAD_IDS_EXTRA) ?: return NotificationManagerCompat.from(context).cancel(intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1)) GlobalScope.launch { - val currentTime = clock.currentTimeMills() + val currentTime = clock.currentTimeMillis() threadIds.forEach { Log.i(TAG, "Marking as read: $it") storage.markConversationAsRead( diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.kt index 19da51fe10..27ff5975d8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.kt @@ -98,7 +98,7 @@ class RemoteReplyReceiver : BroadcastReceiver() { override fun doInBackground(vararg params: Void?): Void? { val threadId = threadDatabase.getOrCreateThreadIdFor(address) val message = VisibleMessage() - message.sentTimestamp = clock.currentTimeMills() + message.sentTimestamp = clock.currentTimeMillis() message.text = responseText.toString() proStatusManager.addProFeatures(message) val expiryMode = recipientRepository.getRecipientSync(address).expiryMode diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt index 4efd89d4d4..ee9f6449dd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt @@ -108,7 +108,7 @@ class FetchProDetailsWorker @AssistedInject constructor( private suspend fun scheduleProofGenerationIfNeeded(details: ProDetails) { - val now = snodeClock.currentTimeMills() + val now = snodeClock.currentTimeMillis() if (details.status != ProDetails.DETAILS_STATUS_ACTIVE) { Log.d(TAG, "Pro is not active, cancelling any existing proof generation work") diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetails.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetails.kt index a1734b07f0..52d7decf71 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetails.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetails.kt @@ -23,7 +23,7 @@ class GetProDetailsRequest @AssistedInject constructor( return BackendRequests.buildGetProDetailsRequestJson( version = 0, proMasterPrivateKey = masterPrivateKey, - nowMs = snodeClock.currentTimeMills(), + nowMs = snodeClock.currentTimeMillis(), count = 10, ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/DefaultConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/DefaultConversationRepository.kt index 04cd3f9717..6bf88aae39 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/DefaultConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/DefaultConversationRepository.kt @@ -201,7 +201,7 @@ class DefaultConversationRepository @Inject constructor( val info = community?.roomInfo ?: return for (contact in contacts) { val message = VisibleMessage() - message.sentTimestamp = clock.currentTimeMills() + message.sentTimestamp = clock.currentTimeMillis() val openGroupInvitation = OpenGroupInvitation().apply { name = info.details.name url = community.joinURL diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt index 08480c3e67..1f4dfe5c09 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt @@ -186,7 +186,7 @@ class ExpiringMessageManager @Inject constructor( val messageId = message.id if (message.expiryMode != ExpiryMode.NONE && messageId != null) { getDatabase(messageId.mms) - .markExpireStarted(messageId.id, clock.currentTimeMills()) + .markExpireStarted(messageId.id, clock.currentTimeMillis()) } } @@ -200,13 +200,13 @@ class ExpiringMessageManager @Inject constructor( if (message.expiryMode is ExpiryMode.AfterSend || (message.expiryMode != ExpiryMode.NONE && message.isSenderSelf)) { getDatabase(messageId.mms) - .markExpireStarted(messageId.id, clock.currentTimeMills()) + .markExpireStarted(messageId.id, clock.currentTimeMillis()) } } private suspend fun processDatabase(db: MessagingDatabase) { while (true) { - val expiredMessages = db.getExpiredMessageIDs(clock.currentTimeMills()) + val expiredMessages = db.getExpiredMessageIDs(clock.currentTimeMillis()) if (expiredMessages.isNotEmpty()) { Log.d(TAG, "Deleting ${expiredMessages.size} expired messages from ${db.javaClass.simpleName}") @@ -220,7 +220,7 @@ class ExpiringMessageManager @Inject constructor( } val nextExpiration = db.nextExpiringTimestamp - val now = clock.currentTimeMills() + val now = clock.currentTimeMillis() if (nextExpiration > 0 && nextExpiration <= now) { continue // Proceed to the next iteration if the next expiration is already or about go to in the past diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt index e658ac5456..61fe3b3f5a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt @@ -690,7 +690,7 @@ class CallManager @Inject constructor( } } - fun insertCallMessage(threadPublicKey: String, callMessageType: CallMessageType, signal: Boolean = false, sentTimestamp: Long = snodeClock.currentTimeMills()) { + fun insertCallMessage(threadPublicKey: String, callMessageType: CallMessageType, signal: Boolean = false, sentTimestamp: Long = snodeClock.currentTimeMillis()) { storage.insertCallMessage(threadPublicKey, callMessageType, sentTimestamp) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallMessageProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallMessageProcessor.kt index fcb6fbebde..0f4e461a2b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallMessageProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallMessageProcessor.kt @@ -61,7 +61,7 @@ class CallMessageProcessor @Inject constructor( continue } - val isVeryExpired = (nextMessage.sentTimestamp?:0) + VERY_EXPIRED_TIME < snodeClock.currentTimeMills() + val isVeryExpired = (nextMessage.sentTimestamp?:0) + VERY_EXPIRED_TIME < snodeClock.currentTimeMillis() if (isVeryExpired) { Log.e("Loki", "Dropping very expired call message") continue From 36fad728bf360aa6a4dc16b075f3324788e850e5 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 12 Jan 2026 16:26:21 +1100 Subject: [PATCH 67/77] Tweaking error management logic --- .../libsession/network/NetworkErrorManager.kt | 56 +++++++++---------- .../network/ServerClientErrorManager.kt | 3 - .../network/SnodeClientErrorManager.kt | 3 - .../libsession/network/model/OnionError.kt | 6 ++ .../network/onion/http/HttpOnionTransport.kt | 4 +- .../network/snode/SnodeDirectory.kt | 5 ++ 6 files changed, 39 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt index efa6e8ce17..72659b9893 100644 --- a/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt @@ -25,41 +25,22 @@ class NetworkErrorManager @Inject constructor( //todo ONION investigate why we got stuck in a invalid cyphertext state - //todo ONION switch to a "don't penalise unless its a known issue" + time based path rotation + //todo ONION add missing known errors + //todo ONION add time based path rotation // -------------------------------------------------------------------- - // 1) "Found anywhere" rules (path OR destination) + // 1) "Found anywhere" rules (path OR destination) - currently no custom handling here + // as we now default to non penalising path logic // -------------------------------------------------------------------- - // 400, 403, 404: do not penalise path or snode; No retries - if (code == 400 || code == 403 || code == 404) { - return FailureDecision.Fail(error) - } // -------------------------------------------------------------------- // 2) Errors along the path (not destination) // -------------------------------------------------------------------- when (error) { - is OnionError.IntermediateNodeUnreachable -> { - // Drop snode from pool, rebuild paths without it, penalise path, retry - val failedKey = error.failedPublicKey - if (failedKey != null) { - snodeDirectory.dropSnodeFromPool(failedKey) - } - - // find snode from the path and strike it - val bad = failedKey?.let { pk -> - ctx.path.firstOrNull { it.publicKeySet?.ed25519Key == pk } - } - - // in this case we want handleBadSnode to force remove this snode - // handleBadSnode also penalises the path - if (bad != null) { - pathManager.handleBadSnode(bad, forceRemove = true) - return FailureDecision.Retry - } else { - return FailureDecision.Fail(error) // we couldn't find the snode in the paths - } + // we got an error building the request. Warrants retrying + is OnionError.EncodingError -> { + return FailureDecision.Retry } is OnionError.GuardUnreachable -> { @@ -74,6 +55,25 @@ class NetworkErrorManager @Inject constructor( return FailureDecision.Fail(error) } + is OnionError.IntermediateNodeUnreachable -> { + val failedKey = error.failedPublicKey ?: return FailureDecision.Fail(error) + + // Get the snode from the path (it should be there based on the error type) + val snodeInPath = ctx.path.firstOrNull { it.publicKeySet?.ed25519Key == failedKey } + + // Fall back to pool instance only for cleanup (won’t help this request’s path) + // If for some reason it isn't in the path, we'll still look for it in the pool + val snodeToRemove = snodeInPath ?: snodeDirectory.getSnodeByKey(failedKey) + + // drop the bad snode, including cascading clean ups + if (snodeToRemove != null) { + pathManager.handleBadSnode(snode = snodeToRemove, forceRemove = true) + } + + // Only retry if we actually changed the path used by this request + return if (snodeInPath != null) FailureDecision.Retry else FailureDecision.Fail(error) + } + is OnionError.InvalidResponse -> { // penalise path; retry //todo ONION is this true? By the time we have an InvalidResponse it means we reached the destination, but couldn't decrypt the payload - penalising the path won't fix anything here... Should we instead penalise the destination? @@ -81,10 +81,6 @@ class NetworkErrorManager @Inject constructor( return FailureDecision.Retry } - is OnionError.Unknown -> { - return FailureDecision.Retry - } - else -> { return FailureDecision.Fail(error) } diff --git a/app/src/main/java/org/session/libsession/network/ServerClientErrorManager.kt b/app/src/main/java/org/session/libsession/network/ServerClientErrorManager.kt index d1d01fc2e0..c4153aa450 100644 --- a/app/src/main/java/org/session/libsession/network/ServerClientErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/ServerClientErrorManager.kt @@ -45,9 +45,6 @@ class ServerClientErrorManager @Inject constructor( return FailureDecision.Fail(error) } } - - // Anything else from destination: do not penalise path; no retries - return FailureDecision.Fail(error) } // Default: fail diff --git a/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt b/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt index da70315cff..0e48312d24 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt @@ -84,9 +84,6 @@ class SnodeClientErrorManager @Inject constructor( return FailureDecision.Retry } - - // Anything else from destination: do not penalise path; no retries - return FailureDecision.Fail(error) } // Default: fail diff --git a/app/src/main/java/org/session/libsession/network/model/OnionError.kt b/app/src/main/java/org/session/libsession/network/model/OnionError.kt index 51cac83145..ca5c8c80a7 100644 --- a/app/src/main/java/org/session/libsession/network/model/OnionError.kt +++ b/app/src/main/java/org/session/libsession/network/model/OnionError.kt @@ -18,6 +18,12 @@ sealed class OnionError( cause: Throwable? = null ) : Exception("Onion error with status code ${status?.code}. Message: ${status?.message}. Destination: ${if(destination is OnionDestination.SnodeDestination) "Snode: "+destination.snode.address else if(destination is OnionDestination.ServerDestination) "Server: "+destination.host else "Unknown"}", cause) { + /** + * We got an issue building the path or encoding the payload + */ + class EncodingError(destination: OnionDestination, cause: Throwable) + : OnionError(destination = destination, cause = cause) + /** * We couldn't even talk to the guard node. * Typical causes: offline, DNS failure, TCP connect fails, TLS failure. diff --git a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt index f35e19fcd5..1f1c04304b 100644 --- a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt +++ b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt @@ -44,7 +44,7 @@ class HttpOnionTransport @Inject constructor( val built = try { OnionBuilder.build(path, destination, payload, version) } catch (t: Throwable) { - throw OnionError.Unknown(destination, t,) + throw OnionError.EncodingError(destination, t,) } val url = "${guard.address}:${guard.port}/onion_req/v2" @@ -59,7 +59,7 @@ class HttpOnionTransport @Inject constructor( json = params ) } catch (t: Throwable) { - throw OnionError.Unknown(destination, t) + throw OnionError.EncodingError(destination, t) } val responseBytes: ByteArray = try { diff --git a/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt b/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt index 13e5061d2b..c36b744d4a 100644 --- a/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt +++ b/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt @@ -230,4 +230,9 @@ class SnodeDirectory @Inject constructor( Log.w("Loki", "Got stale fork info $newForkInfo (current: $current)") } } + + fun getSnodeByKey(ed25519Key: String?): Snode?{ + if(ed25519Key == null) return null + return getSnodePool().firstOrNull { it.publicKeySet?.ed25519Key == ed25519Key } + } } \ No newline at end of file From 4291825b1e8bd9e908906b34d973680c80eb930c Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 12 Jan 2026 16:39:17 +1100 Subject: [PATCH 68/77] InvalidResponse no longer warrants custom behaviour --- .../session/libsession/network/NetworkErrorManager.kt | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt index 72659b9893..f886c6528b 100644 --- a/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt @@ -25,7 +25,7 @@ class NetworkErrorManager @Inject constructor( //todo ONION investigate why we got stuck in a invalid cyphertext state - //todo ONION add missing known errors + //todo ONION add missing known errors //todo ONION add time based path rotation // -------------------------------------------------------------------- @@ -74,13 +74,6 @@ class NetworkErrorManager @Inject constructor( return if (snodeInPath != null) FailureDecision.Retry else FailureDecision.Fail(error) } - is OnionError.InvalidResponse -> { - // penalise path; retry - //todo ONION is this true? By the time we have an InvalidResponse it means we reached the destination, but couldn't decrypt the payload - penalising the path won't fix anything here... Should we instead penalise the destination? - pathManager.handleBadPath(ctx.path) - return FailureDecision.Retry - } - else -> { return FailureDecision.Fail(error) } From 71af40866e32e8c25439f8f2e5d685736f0c25ae Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 12 Jan 2026 16:47:02 +1100 Subject: [PATCH 69/77] fixing up app startup component --- .../securesms/dependencies/OnAppStartupComponents.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/OnAppStartupComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/OnAppStartupComponents.kt index 647c928eb8..2022368c42 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/OnAppStartupComponents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/OnAppStartupComponents.kt @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.dependencies import org.session.libsession.messaging.notifications.TokenFetcher import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerManager -import org.session.libsession.network.SnodeClock import org.thoughtcrime.securesms.auth.AuthAwareComponentsHandler import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.disguise.AppDisguiseManager @@ -28,7 +27,6 @@ class OnAppStartupComponents private constructor( } @Inject constructor( - snodeClock: SnodeClock, appVisibilityManager: AppVisibilityManager, groupPollerManager: GroupPollerManager, expiredGroupManager: ExpiredGroupManager, @@ -48,7 +46,6 @@ class OnAppStartupComponents private constructor( subscriptionManagers: Set<@JvmSuppressWildcards SubscriptionManager>, ): this( components = listOf( - snodeClock, appVisibilityManager, groupPollerManager, expiredGroupManager, From 625c31a30f16d93fce2a918a57b659bf0a40fc0a Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 13 Jan 2026 09:49:53 +1100 Subject: [PATCH 70/77] Removing Kovenant!! --- app/build.gradle.kts | 2 -- .../notifications/PushRegistryV1.kt | 3 -- .../network/utilities/NetworkRetry.kt | 2 +- .../libsignal/utilities/PromiseUtilities.kt | 32 ------------------- .../session/libsignal/utilities/Retrying.kt | 29 ----------------- .../org/thoughtcrime/securesms/AppContext.kt | 21 ------------ .../securesms/ApplicationContext.kt | 11 ------- .../v2/utilities/MentionUtilities.kt | 14 ++------ .../community/JoinCommunityViewModel.kt | 3 -- gradle/libs.versions.toml | 3 -- 10 files changed, 3 insertions(+), 117 deletions(-) delete mode 100644 app/src/main/java/org/session/libsignal/utilities/PromiseUtilities.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/AppContext.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index df16c9b7cd..de6fb20093 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -389,8 +389,6 @@ dependencies { implementation(libs.copper.flow) implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.guava) - implementation(libs.kovenant) - implementation(libs.kovenant.android) implementation(libs.opencsv) implementation(libs.androidx.work.runtime.ktx) implementation(libs.rxbinding) diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt index 1c89261404..07d0b988d1 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt @@ -4,7 +4,6 @@ import android.annotation.SuppressLint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -import nl.komponents.kovenant.Promise import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody @@ -13,8 +12,6 @@ import org.session.libsession.network.onion.Version import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.emptyPromise -import org.session.libsignal.utilities.retryWithUniformInterval @SuppressLint("StaticFieldLeak") object PushRegistryV1 { diff --git a/app/src/main/java/org/session/libsession/network/utilities/NetworkRetry.kt b/app/src/main/java/org/session/libsession/network/utilities/NetworkRetry.kt index f72aab9379..2f323ac53a 100644 --- a/app/src/main/java/org/session/libsession/network/utilities/NetworkRetry.kt +++ b/app/src/main/java/org/session/libsession/network/utilities/NetworkRetry.kt @@ -30,7 +30,7 @@ suspend inline fun retryWithBackOff( } catch (currentError: Throwable) { if (currentError is CancellationException) throw currentError - Log.w("Network", "Got an error sending a network request. Retrying. Attempt $attempt/$maxAttempts", currentError) + Log.w("Network", "Got an error sending a network request for $operationName. Retrying. Attempt $attempt/$maxAttempts", currentError) val onionError = currentError as? OnionError ?: OnionError.Unknown(null, currentError) diff --git a/app/src/main/java/org/session/libsignal/utilities/PromiseUtilities.kt b/app/src/main/java/org/session/libsignal/utilities/PromiseUtilities.kt deleted file mode 100644 index d4f869aa24..0000000000 --- a/app/src/main/java/org/session/libsignal/utilities/PromiseUtilities.kt +++ /dev/null @@ -1,32 +0,0 @@ -@file:JvmName("PromiseUtilities") -package org.session.libsignal.utilities - -import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.deferred -import nl.komponents.kovenant.functional.map - -fun emptyPromise() = Promise.of(Unit) - - -fun Promise.recover(callback: (exception: E) -> V): Promise { - val deferred = deferred() - success { - deferred.resolve(it) - }.fail { - try { - val value = callback(it) - deferred.resolve(value) - } catch (e: Throwable) { - deferred.reject(it) - } - } - return deferred.promise -} - - -infix fun Promise.sideEffect( - callback: (value: V) -> Unit -) = map { - callback(it) - it -} diff --git a/app/src/main/java/org/session/libsignal/utilities/Retrying.kt b/app/src/main/java/org/session/libsignal/utilities/Retrying.kt index 4ee17f52a4..dd7ca7859c 100644 --- a/app/src/main/java/org/session/libsignal/utilities/Retrying.kt +++ b/app/src/main/java/org/session/libsignal/utilities/Retrying.kt @@ -1,38 +1,9 @@ package org.session.libsignal.utilities import kotlinx.coroutines.delay -import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.deferred import org.session.libsignal.exceptions.NonRetryableException -import java.util.* import kotlin.coroutines.cancellation.CancellationException -@Deprecated("Use retrySuspendAsPromise instead") -fun > retryIfNeeded(maxRetryCount: Int, retryInterval: Long = 1000L, body: () -> T): Promise { - var retryCount = 0 - val deferred = deferred() - val thread = Thread.currentThread() - fun retryIfNeeded() { - body().success { - deferred.resolve(it) - }.fail { - if (retryCount == maxRetryCount) { - deferred.reject(it) - } else { - retryCount += 1 - Timer().schedule(object : TimerTask() { - - override fun run() { - thread.run { retryIfNeeded() } - } - }, retryInterval) - } - } - } - retryIfNeeded() - return deferred.promise -} - suspend fun retryWithUniformInterval(maxRetryCount: Int = 3, retryIntervalMills: Long = 1000L, body: suspend () -> T): T { var retryCount = 0 while (true) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/AppContext.kt b/app/src/main/java/org/thoughtcrime/securesms/AppContext.kt deleted file mode 100644 index 34ab960021..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/AppContext.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.thoughtcrime.securesms - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.asExecutor -import nl.komponents.kovenant.Kovenant -import nl.komponents.kovenant.jvm.asDispatcher -import org.session.libsignal.utilities.Log -import java.util.concurrent.Executors - -object AppContext { - - fun configureKovenant() { - Kovenant.context { - callbackContext.dispatcher = Executors.newSingleThreadExecutor().asDispatcher() - workerContext.dispatcher = Dispatchers.IO.asExecutor().asDispatcher() - multipleCompletion = { v1, v2 -> - Log.d("Loki", "Promise resolved more than once (first with $v1, then with $v2); ignoring $v2.") - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt index e171f6ceb2..d4756864d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt @@ -37,8 +37,6 @@ import network.loki.messenger.BuildConfig import network.loki.messenger.R import network.loki.messenger.libsession_util.util.LogLevel import network.loki.messenger.libsession_util.util.Logger -import nl.komponents.kovenant.android.startKovenant -import nl.komponents.kovenant.android.stopKovenant import org.conscrypt.Conscrypt import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration.Companion.configure @@ -47,7 +45,6 @@ import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences.Companion.pushSuffix import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.AppContext.configureKovenant import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.debugmenu.DebugActivity import org.thoughtcrime.securesms.debugmenu.DebugLogger @@ -56,7 +53,6 @@ import org.thoughtcrime.securesms.dependencies.DatabaseModule.init import org.thoughtcrime.securesms.dependencies.OnAppStartupComponents import org.thoughtcrime.securesms.emoji.EmojiSource.Companion.refresh import org.thoughtcrime.securesms.glide.RemoteFileLoader -import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint import org.thoughtcrime.securesms.logging.AndroidLogger import org.thoughtcrime.securesms.logging.PersistentLogger import org.thoughtcrime.securesms.logging.UncaughtExceptionLogger @@ -136,13 +132,11 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, Configuratio configure(this) super.onCreate() - startKovenant() initializeSecurityProvider() initializeLogging() initializeCrashHandling() NotificationChannels.create(this) ProcessLifecycleOwner.get().lifecycle.addObserver(this) - configureKovenant() SSKEnvironment.sharedLazy = sskEnvironment initializeWebRtc() @@ -187,11 +181,6 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, Configuratio messageNotifier.setVisibleThread(-1) } - override fun onTerminate() { - stopKovenant() // Loki - super.onTerminate() - } - override fun newImageLoader(context: PlatformContext): ImageLoader { return imageLoaderProvider.get() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt index 5b6fe0ff06..5a037e060f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt @@ -4,27 +4,17 @@ import android.content.Context import android.graphics.Typeface import android.text.Spannable import android.text.SpannableString -import android.text.SpannableStringBuilder import android.text.style.ForegroundColorSpan import android.text.style.StyleSpan import android.util.Range import network.loki.messenger.R -import network.loki.messenger.libsession_util.util.BlindKeyAPI -import nl.komponents.kovenant.combine.Tuple2 -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.open_groups.OpenGroup -import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.getColorFromAttr -import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.displayName -import org.session.libsession.utilities.truncateIdForDisplay import org.thoughtcrime.securesms.conversation.v2.mention.MentionEditable import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel import org.thoughtcrime.securesms.database.RecipientRepository -import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.util.RoundedBackgroundSpan import org.thoughtcrime.securesms.util.getAccentColor import java.util.regex.Pattern @@ -84,7 +74,7 @@ object MentionUtilities { @Suppress("NAME_SHADOWING") var text = text var matcher = pattern.matcher(text) - val mentions = mutableListOf, String>>() + val mentions = mutableListOf, String>>() var startIndex = 0 // Format the mention text @@ -103,7 +93,7 @@ object MentionUtilities { text = text.subSequence(0, matcher.start()).toString() + mention + text.subSequence(matcher.end(), text.length) val endIndex = matcher.start() + 1 + userDisplayName.length startIndex = endIndex - mentions.add(Tuple2(Range.create(matcher.start(), endIndex), publicKey)) + mentions.add(Pair(Range.create(matcher.start(), endIndex), publicKey)) matcher = pattern.matcher(text) if (!matcher.find(startIndex)) { break } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/community/JoinCommunityViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/community/JoinCommunityViewModel.kt index ab7961b1b6..0b025e5d7a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/community/JoinCommunityViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/community/JoinCommunityViewModel.kt @@ -16,9 +16,6 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R -import nl.komponents.kovenant.functional.map -import okhttp3.HttpUrl.Companion.toHttpUrl -import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.utilities.Address import org.session.libsession.utilities.OpenGroupUrlParser diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b16eebe6de..82ce8f94fb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -42,7 +42,6 @@ glideVersion = "5.0.5" jacksonDatabindVersion = "2.9.8" junitVersion = "4.13.2" kotlinxJsonVersion = "1.9.0" -kovenantVersion = "3.3.0" opencsvVersion = "5.12.0" orchestratorVersion = "1.6.1" photoviewVersion = "2.3.0" @@ -146,8 +145,6 @@ kotlinx-coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines- kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutinesVersion" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxJsonVersion" } kotlinx-coroutines-testing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutinesVersion" } -kovenant-android = { module = "nl.komponents.kovenant:kovenant-android", version.ref = "kovenantVersion" } -kovenant = { module = "nl.komponents.kovenant:kovenant", version.ref = "kovenantVersion" } material = { module = "com.google.android.material:material", version.ref = "materialVersion" } mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockitoCoreVersion" } mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlinVersion" } From 86b6472050ddf41d22ec08b3cb37c80985281696 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 13 Jan 2026 11:37:37 +1100 Subject: [PATCH 71/77] missing snode pool refresh logic --- .../java/org/session/libsession/network/snode/SnodeDirectory.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt b/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt index c36b744d4a..522beeaad8 100644 --- a/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt +++ b/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt @@ -36,6 +36,8 @@ class SnodeDirectory @Inject constructor( private const val KEY_VERSION = "storage_server_version" } + //todo ONION we need to add the "refresh every 2h plus intersection" rules + private val poolMutex = Mutex() private val seedNodePool: Set = when (prefs.getEnvironment()) { From 48efef514605d838c441256ccf2ea6a39827d8b4 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 13 Jan 2026 15:09:21 +1100 Subject: [PATCH 72/77] Networking rules updated --- .../libsession/network/NetworkErrorManager.kt | 20 ++++ .../network/SnodeClientErrorManager.kt | 14 +++ .../libsession/network/model/OnionError.kt | 23 ++++ .../network/onion/http/HttpOnionTransport.kt | 107 +++++++++++------- 4 files changed, 121 insertions(+), 43 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt index f886c6528b..13374d1e4a 100644 --- a/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt @@ -74,6 +74,26 @@ class NetworkErrorManager @Inject constructor( return if (snodeInPath != null) FailureDecision.Retry else FailureDecision.Fail(error) } + is OnionError.SnodeNotReady -> { + // penalise the snode and retry + val failedKey = error.failedPublicKey ?: return FailureDecision.Fail(error) + val snodeToRemove = snodeDirectory.getSnodeByKey(failedKey) + if(snodeToRemove != null) { + pathManager.handleBadSnode(snodeToRemove, ctx.publicKey) + return FailureDecision.Retry + } else { + return FailureDecision.Fail(error) + } + } + + is OnionError.PathTimedOut, + is OnionError.InvalidHopResponse -> { + // we don't have enough information to penalise a specific snode, + // so we penalise the whole path and try again + pathManager.handleBadPath(ctx.path) + return FailureDecision.Retry + } + else -> { return FailureDecision.Fail(error) } diff --git a/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt b/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt index 0e48312d24..0f0a396cf7 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt @@ -84,6 +84,20 @@ class SnodeClientErrorManager @Inject constructor( return FailureDecision.Retry } + + // Unparseable data: 502 + "oxend returned unparsable data" + if (code == 502 && bodyText?.contains("oxend returned unparsable data", ignoreCase = true) == true) { + // penalise the destination snode and retry + pathManager.handleBadSnode(snode = ctx.targetSnode, publicKey = ctx.publicKey) + return FailureDecision.Retry + } + + // Destination snode not ready + if(code == 503 && bodyText?.contains("Snode not ready", ignoreCase = true) == true){ + // penalise the destination snode and retry + pathManager.handleBadSnode(snode = ctx.targetSnode, publicKey = ctx.publicKey) + return FailureDecision.Retry + } } // Default: fail diff --git a/app/src/main/java/org/session/libsession/network/model/OnionError.kt b/app/src/main/java/org/session/libsession/network/model/OnionError.kt index ca5c8c80a7..fb69d2d52b 100644 --- a/app/src/main/java/org/session/libsession/network/model/OnionError.kt +++ b/app/src/main/java/org/session/libsession/network/model/OnionError.kt @@ -42,6 +42,23 @@ sealed class OnionError( destination: OnionDestination, ) : OnionError(destination = destination, status = status) + /** + * The snode reported not being ready + */ + class SnodeNotReady( + status: ErrorStatus, + val failedPublicKey: String?, + destination: OnionDestination, + ) : OnionError(destination = destination, status = status) + + /** + * A snode reported a timeout + */ + class PathTimedOut( + status: ErrorStatus, + destination: OnionDestination, + ) : OnionError(destination = destination, status = status) + /** * We couldn't reach the destination from the final snode in the path */ @@ -54,6 +71,12 @@ sealed class OnionError( class PathError(val node: Snode?, status: ErrorStatus, destination: OnionDestination,) : OnionError(status = status, destination = destination) + /** + * If we get an invalid response along the path (differs from the InvalidResponse which comes from a 200 payload) + */ + class InvalidHopResponse(val node: Snode?, status: ErrorStatus, destination: OnionDestination,) + : OnionError(status = status, destination = destination) + /** * The error happened after decrypting a payload form the destination */ diff --git a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt index 1f1c04304b..df44d913c1 100644 --- a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt +++ b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt @@ -94,66 +94,87 @@ class HttpOnionTransport @Inject constructor( destination: OnionDestination ): OnionError { val message = ex.body - val statusCode = ex.statusCode - //Log.w("Onion Request", "Got an HTTP error. Status: $statusCode, message: $message", ex) - - // Special onion path error: "Next node not found: " - //todo ONION do we also need to care for "Next node is currently unreachable: or "" - val prefix = "Next node not found: " - if (message != null && message.startsWith(prefix)){ - val failedPk = message.removePrefix(prefix) - - // The missing Snode is the destination - if( failedPk == (destination as? OnionDestination.SnodeDestination)?.snode?.publicKeySet?.ed25519Key){ - return OnionError.DestinationUnreachable( - status = ErrorStatus( - code = statusCode, - message = message, - body = null - ), + // ---- 502: hop can't find/contact next hop ---- + val nextNodeNotFound = "Next node not found: " + val nextNodeUnreachable = "Next node is currently unreachable: " + + // to extract the key from the error message + fun parseNextHopPk(msg: String): String? = when { + msg.startsWith(nextNodeNotFound) -> msg.removePrefix(nextNodeNotFound).trim() + msg.startsWith(nextNodeUnreachable) -> msg.removePrefix(nextNodeUnreachable).trim() + else -> null + } + + val failedPk = message?.let(::parseNextHopPk) + if (statusCode == 502 && failedPk != null) { + val destPk = (destination as? OnionDestination.SnodeDestination)?.snode?.publicKeySet?.ed25519Key + + return if (destPk != null && failedPk == destPk) { + OnionError.DestinationUnreachable( + status = ErrorStatus(code = statusCode, message = message, body = null), destination = destination ) - } else { // the missing snode is along the path - return OnionError.IntermediateNodeUnreachable( + } else { + OnionError.IntermediateNodeUnreachable( reportingNode = node, failedPublicKey = failedPk, - status = ErrorStatus( - code = statusCode, - message = message, - body = null - ), + status = ErrorStatus(code = statusCode, message = message, body = null), destination = destination ) } } - // check for the case where the SERVER destination no longer exists. - // The rule is: - // - the destination is a ServerDestination - // - the status code is 502 or 504 - // - the message contains the server's destination url - if(destination is OnionDestination.ServerDestination - && statusCode in 500..504 - && message?.contains(destination.host) == true ){ - return OnionError.DestinationUnreachable( - status = ErrorStatus( - code = statusCode, - message = message, - body = null - ), + // ---- 503: "Snode not ready" ---- + if (statusCode == 503) { + val snodeNotReadyPrefix = "Snode not ready: " + val snodeNotReady = message?.startsWith(snodeNotReadyPrefix) == true + + val guardNotReady = + message?.startsWith("Service node is not ready:") == true || + message?.startsWith("Server busy, try again later") == true + + if(guardNotReady){ + return OnionError.SnodeNotReady( + failedPublicKey = path.first().publicKeySet?.ed25519Key, + status = ErrorStatus(code = statusCode, message = message, body = null), + destination = destination + ) + } + else if (snodeNotReady) { + val pk = message.removePrefix(snodeNotReadyPrefix).trim() + return OnionError.SnodeNotReady( + failedPublicKey = pk, + status = ErrorStatus(code = statusCode, message = message, body = null), + destination = destination + ) + } + } + + // ---- 504: timeouts along path ---- + //todo ONION what happens if a destination sends a 504 with that same body? Is that possible? Can a snode destination pack a 504 inside an encrypted payload from a 200? + if (statusCode == 504 && message?.contains("Request time out", ignoreCase = true) == true) { + return OnionError.PathTimedOut( + status = ErrorStatus(code = statusCode, message = message, body = null), + destination = destination + ) + } + + // ---- 500: invalid response from next hop ---- + //todo ONION currently we have no handling of 5xx for snode destination as it's unclear how to best handle them + if (statusCode == 500 && message?.contains("Invalid response from snode", ignoreCase = true) == true) { + return OnionError.InvalidHopResponse( + node = node, + status = ErrorStatus(code = statusCode, message = message, body = null), destination = destination ) } + // Default: generic path error return OnionError.PathError( node = node, - status = ErrorStatus( - code = statusCode, - message = message, - body = null - ), + status = ErrorStatus(code = statusCode, message = message, body = null), destination = destination ) } From a2112ac1ffaeb60192fc06dd7cedce2760369ea5 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 14 Jan 2026 09:35:18 +1100 Subject: [PATCH 73/77] Made the destination 502 a forceRemove --- .../java/org/session/libsession/network/NetworkErrorManager.kt | 1 - .../org/session/libsession/network/SnodeClientErrorManager.kt | 2 +- .../session/libsession/network/onion/http/HttpOnionTransport.kt | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt index 13374d1e4a..eb30fbfb75 100644 --- a/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/NetworkErrorManager.kt @@ -25,7 +25,6 @@ class NetworkErrorManager @Inject constructor( //todo ONION investigate why we got stuck in a invalid cyphertext state - //todo ONION add missing known errors //todo ONION add time based path rotation // -------------------------------------------------------------------- diff --git a/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt b/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt index 0f0a396cf7..3f076506b6 100644 --- a/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClientErrorManager.kt @@ -88,7 +88,7 @@ class SnodeClientErrorManager @Inject constructor( // Unparseable data: 502 + "oxend returned unparsable data" if (code == 502 && bodyText?.contains("oxend returned unparsable data", ignoreCase = true) == true) { // penalise the destination snode and retry - pathManager.handleBadSnode(snode = ctx.targetSnode, publicKey = ctx.publicKey) + pathManager.handleBadSnode(snode = ctx.targetSnode, publicKey = ctx.publicKey, forceRemove = true) return FailureDecision.Retry } diff --git a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt index df44d913c1..de055be400 100644 --- a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt +++ b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt @@ -153,7 +153,6 @@ class HttpOnionTransport @Inject constructor( } // ---- 504: timeouts along path ---- - //todo ONION what happens if a destination sends a 504 with that same body? Is that possible? Can a snode destination pack a 504 inside an encrypted payload from a 200? if (statusCode == 504 && message?.contains("Request time out", ignoreCase = true) == true) { return OnionError.PathTimedOut( status = ErrorStatus(code = statusCode, message = message, body = null), From f394159e8aa3e7ef90dbc2b5147eacff6c13eac9 Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:20:35 +1100 Subject: [PATCH 74/77] Clean up push registration retry logic (#1826) * Remove PushRegistryV1 as it is for legacy group only * Clean up push registration retry logic * Comments * Asserting API contract --- .../messaging/file_server/FileServerApi.kt | 3 +- .../messaging/open_groups/OpenGroupApi.kt | 2 +- .../notifications/PushRegistryV1.kt | 81 ------------------- .../libsession/network/ServerClient.kt | 50 ++++++++---- .../network/ServerClientErrorManager.kt | 8 +- .../securesms/configs/ConfigToDatabaseSync.kt | 5 -- .../securesms/groups/GroupLeavingWorker.kt | 6 +- .../notifications/PushRegistrationWorker.kt | 58 +++++-------- .../securesms/notifications/PushRegistryV2.kt | 71 ++++++++++------ 9 files changed, 109 insertions(+), 175 deletions(-) delete mode 100644 app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt diff --git a/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt b/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt index 9fbf437871..f2a32d8a83 100644 --- a/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt +++ b/app/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt @@ -24,7 +24,6 @@ import java.time.format.DateTimeFormatter import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds @Singleton class FileServerApi @Inject constructor( @@ -63,7 +62,7 @@ class FileServerApi @Inject constructor( val useOnionRouting: Boolean = true, // Computed fresh for each attempt (after clock resync etc.) - val dynamicHeaders: (suspend () -> Map)? = null + val dynamicHeaders: (() -> Map)? = null ) private fun createBody(body: ByteArray?, parameters: Any?): RequestBody? { diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt index 4c84007037..414e65ed35 100644 --- a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt @@ -269,7 +269,7 @@ object OpenGroupApi { * this when running over Lokinet. */ val useOnionRouting: Boolean = true, - val dynamicHeaders: (suspend () -> Map)? = null + val dynamicHeaders: (() -> Map)? = null ) private fun createBody(body: ByteArray?, parameters: Any?): RequestBody? { diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt deleted file mode 100644 index 07d0b988d1..0000000000 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt +++ /dev/null @@ -1,81 +0,0 @@ -package org.session.libsession.messaging.sending_receiving.notifications - -import android.annotation.SuppressLint -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.network.onion.Version -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsignal.utilities.JsonUtil -import org.session.libsignal.utilities.Log - -@SuppressLint("StaticFieldLeak") -object PushRegistryV1 { - val context = MessagingModuleConfiguration.shared.context - - private val server = Server.LEGACY - - @Suppress("OPT_IN_USAGE") - private val scope: CoroutineScope = GlobalScope - - // Legacy Closed Groups - - fun subscribeGroup( - closedGroupSessionId: String, - isPushEnabled: Boolean = TextSecurePreferences.isPushEnabled(context), - publicKey: String = MessagingModuleConfiguration.shared.storage.getUserPublicKey()!! - ) { - if (!isPushEnabled) return - scope.launch { - performGroupOperation("subscribe_closed_group", closedGroupSessionId, publicKey) - } - } - - fun unsubscribeGroup( - closedGroupPublicKey: String, - isPushEnabled: Boolean = TextSecurePreferences.isPushEnabled(context), - publicKey: String = MessagingModuleConfiguration.shared.storage.getUserPublicKey()!! - ) { - if (!isPushEnabled) return - scope.launch { - performGroupOperation("unsubscribe_closed_group", closedGroupPublicKey, publicKey) - } - } - - private suspend fun performGroupOperation( - operation: String, - closedGroupPublicKey: String, - publicKey: String - ) { - val url = "${server.url}/$operation" - - try { - MessagingModuleConfiguration.shared.serverClient.send( - operationName = operation, - requestFactory = { - val parameters = mapOf( - "closedGroupPublicKey" to closedGroupPublicKey, - "pubKey" to publicKey - ) - - val body = JsonUtil.toJson(parameters) - .toRequestBody("application/json".toMediaType()) - - Request.Builder() - .url(url) - .post(body) - .build() - }, - serverBaseUrl = server.url, - x25519PublicKey = server.publicKey, - version = Version.V2 - ) - } catch (e: Exception) { - Log.w("PushRegistryV1", "Failed to perform group operation ($operation): $e") - } - } -} diff --git a/app/src/main/java/org/session/libsession/network/ServerClient.kt b/app/src/main/java/org/session/libsession/network/ServerClient.kt index 19ef8d00ef..b82e26b9ad 100644 --- a/app/src/main/java/org/session/libsession/network/ServerClient.kt +++ b/app/src/main/java/org/session/libsession/network/ServerClient.kt @@ -2,7 +2,6 @@ package org.session.libsession.network import okhttp3.Request import org.session.libsession.network.model.OnionDestination -import org.session.libsession.network.model.OnionError import org.session.libsession.network.model.OnionResponse import org.session.libsession.network.onion.Version import org.session.libsession.network.utilities.getBodyForOnionRequest @@ -23,33 +22,32 @@ class ServerClient @Inject constructor( ) { /** - * The request is sent as a lambda in order to be recalculated as part of the retry strategy. - * This is useful for things like timestamps that might have been updated - * as part of a clock resync + * Send a request to a server destination. The request is sent as a lambda in order to be + * recalculated as part of the retry strategy. + * + * This version of the method returns both the data generated by the requestFactory + * and the OnionResponse from the network send. */ - suspend fun send( - requestFactory: suspend () -> Request, + suspend fun sendWithData( + requestFactory: suspend () -> Pair, serverBaseUrl: String, x25519PublicKey: String, version: Version = Version.V4, operationName: String = "ServerClient.send", - ): OnionResponse { - val initialRequest = requestFactory() - val url = initialRequest.url - + ): Pair { return retryWithBackOff( operationName = operationName, classifier = { error, previous -> errorManager.onFailure( error = error, ctx = ServerClientFailureContext( - url = url, + url = serverBaseUrl, previousError = previous ) ) } - ) { attempt -> - val request = if (attempt == 1) initialRequest else requestFactory() + ) { _ -> + val (data, request) = requestFactory() val url = request.url val destination = OnionDestination.ServerDestination( @@ -62,7 +60,7 @@ class ServerClient @Inject constructor( val payload = generatePayload(request, serverBaseUrl, version) - sessionNetwork.sendWithRetry( + data to sessionNetwork.sendWithRetry( destination = destination, payload = payload, version = version, @@ -73,6 +71,30 @@ class ServerClient @Inject constructor( } } + /** + * The request is sent as a lambda in order to be recalculated as part of the retry strategy. + * This is useful for things like timestamps that might have been updated + * as part of a clock resync + */ + suspend fun send( + requestFactory: suspend () -> Request, + serverBaseUrl: String, + x25519PublicKey: String, + version: Version = Version.V4, + operationName: String = "ServerClient.send", + ): OnionResponse { + return sendWithData( + requestFactory = { + val request = requestFactory() + Unit to request + }, + serverBaseUrl = serverBaseUrl, + x25519PublicKey = x25519PublicKey, + version = version, + operationName = operationName + ).second + } + private fun generatePayload(request: Request, server: String, version: Version): ByteArray { val headers = request.getHeadersForOnionRequest().toMutableMap() val url = request.url diff --git a/app/src/main/java/org/session/libsession/network/ServerClientErrorManager.kt b/app/src/main/java/org/session/libsession/network/ServerClientErrorManager.kt index c4153aa450..c3d2792f33 100644 --- a/app/src/main/java/org/session/libsession/network/ServerClientErrorManager.kt +++ b/app/src/main/java/org/session/libsession/network/ServerClientErrorManager.kt @@ -1,14 +1,8 @@ package org.session.libsession.network -import okhttp3.HttpUrl import org.session.libsession.network.model.FailureDecision -import org.session.libsession.network.model.OnionDestination import org.session.libsession.network.model.OnionError -import org.session.libsession.network.onion.PathManager -import org.session.libsession.network.snode.SnodeDirectory -import org.session.libsession.network.snode.SwarmDirectory import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.Snode import javax.inject.Inject import javax.inject.Singleton @@ -53,6 +47,6 @@ class ServerClientErrorManager @Inject constructor( } data class ServerClientFailureContext( - val url: HttpUrl, + val url: String, val previousError: OnionError? = null // in some situations we could be coming from a retry to a previous error ) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt index 2f3636ca8e..2444ec34b9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt @@ -18,7 +18,6 @@ import network.loki.messenger.libsession_util.util.UserPic import org.session.libsession.avatars.AvatarCacheCleaner import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier -import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1 import org.session.libsession.network.SnodeClient import org.session.libsession.network.SnodeClock import org.session.libsession.snode.OwnedSwarmAuth @@ -205,8 +204,6 @@ class ConfigToDatabaseSync @Inject constructor( // Store the encryption key pair val keyPair = ECKeyPair(DjbECPublicKey(group.encPubKey.data), DjbECPrivateKey(group.encSecKey.data)) storage.addClosedGroupEncryptionKeyPair(keyPair, group.accountId, clock.currentTimeMillis()) - // Notify the PN server - PushRegistryV1.subscribeGroup(group.accountId, publicKey = myAccountId.hexString) threadDatabase.setCreationDate(threadId, formationTimestamp) } @@ -256,8 +253,6 @@ class ConfigToDatabaseSync @Inject constructor( // Remove the key pairs storage.removeAllClosedGroupEncryptionKeyPairs(address.groupPublicKeyHex) storage.removeMember(address.address, myAccountId.toAddress()) - // Notify the PN server - PushRegistryV1.unsubscribeGroup(closedGroupPublicKey = address.groupPublicKeyHex, publicKey = myAccountId.hexString) messageNotifier.updateNotification(context) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt index f9d3e79ff6..1154d43366 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt @@ -70,11 +70,11 @@ class GroupLeavingWorker @AssistedInject constructor( if (groupAuth != null) { val resp = pushRegistryV2.unregister { - listOf(pushRegistryV2.buildUnregisterRequest(currentToken, groupAuth)) + listOf(runCatching { pushRegistryV2.buildUnregisterRequest(currentToken, groupAuth) }) }.firstOrNull() - check(resp?.success == true) { - "Unsubscription failed: code = ${resp?.error}, message = ${resp?.message}" + check(resp?.getOrNull()?.success == true) { + "Unsubscription failed: $resp" } Log.d(TAG, "Unsubscribed from group $groupId successfully") } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt index 816694ce5d..08d02ed77a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt @@ -170,59 +170,39 @@ class PushRegistrationWorker @AssistedInject constructor( private suspend inline fun batchRequest( items: List, crossinline buildRequest: (T) -> Req, - crossinline sendBatchRequest: suspend (suspend () -> Collection) -> List, + sendBatchRequest: suspend (requestsBuilder: () -> Collection>) -> List>, ): List>> { if (items.isEmpty()) return emptyList() - val results = ArrayList>>(items.size) - - // Items that are valid to send, and their per-attempt builders - val batchItems = mutableListOf() - val requestBuilders = mutableListOf<() -> Req>() - - for (item in items) { - try { - //todo ONION I have to double the buildRequest here, once for validation and again to recompute... Is this ok? FANCHAO - buildRequest(item) - - batchItems += item - requestBuilders += { buildRequest(item) } // <- rebuilt each retry attempt - } catch (ec: Exception) { - results += item to kotlin.Result.failure( - NonRetryableException("Failed to build a request", ec) - ) - } - } - - if (batchItems.isEmpty()) return results - - try { + return try { val responses = sendBatchRequest { - requestBuilders.map { it() } + items.map { item -> + try { + kotlin.Result.success(buildRequest(item)) + } catch (e: Exception) { + kotlin.Result.failure(NonRetryableException("Error building request", e)) + } + } } - responses.forEachIndexed { idx, response -> - val item = batchItems[idx] - results += item to when { - response.isSuccess() -> kotlin.Result.success(Unit) - response.error == 403 -> kotlin.Result.failure( - NonRetryableException("Request failed: code = ${response.error}, message = ${response.message}") - ) - else -> kotlin.Result.failure( - RuntimeException("Request failed: code = ${response.error}, message = ${response.message}") - ) + responses.mapIndexed { idx, result -> + val item = items[idx] + item to result.map { response -> + when { + response.isSuccess() -> Unit + response.error == 403 -> throw NonRetryableException("Request failed: code = ${response.error}, message = ${response.message}") + else -> throw RuntimeException("Request failed: code = ${response.error}, message = ${response.message}") + } } } } catch (e: CancellationException) { throw e } catch (e: Exception) { // Batch call failed -> mark all *sent* items as failed - batchItems.forEach { item -> - results += item to kotlin.Result.failure(e) + items.map { item -> + item to kotlin.Result.failure(e) } } - - return results } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt index fe758afdee..8d00ed2ebb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.notifications import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -39,12 +38,9 @@ class PushRegistryV2 @Inject constructor( ) { suspend fun register( - requestsFactory: suspend () -> Collection - ): List { - return getResponseBody( - "subscribe", - { Json.encodeToString(requestsFactory()) } - ) + requestsFactory: () -> Collection> + ): List> { + return sendRequest("subscribe", requestsFactory) } fun buildRegisterRequest( @@ -67,16 +63,14 @@ class PushRegistryV2 @Inject constructor( service = device.service, sig_ts = timestamp, service_info = mapOf("token" to token), - enc_key = requireNotNull(loginStateRepository.peekLoginState()) { - "User must be logged in to register for push notifications" - }.notificationKey.data.toHexString(), + enc_key = loginStateRepository.requireLoggedInState().notificationKey.data.toHexString(), ).let(Json::encodeToJsonElement).jsonObject + signed } suspend fun unregister( - requestsFactory: suspend () -> Collection - ): List { - return getResponseBody("unsubscribe", { Json.encodeToString(requestsFactory()) }) + requestsFactory: () -> Collection> + ): List> { + return sendRequest("unsubscribe", requestsFactory) } fun buildUnregisterRequest( @@ -108,30 +102,61 @@ class PushRegistryV2 @Inject constructor( }) } - @OptIn(ExperimentalSerializationApi::class) - private suspend inline fun getResponseBody( + private suspend inline fun sendRequest( path: String, - crossinline bodyFactory: suspend () -> String - ): T { + crossinline builder: () -> Collection> + ): List> { val server = Server.LATEST val url = "${server.url}/$path" - val response = serverClient.send( + val (r, rawApiResponse) = serverClient.sendWithData( operationName = "PushRegistryV2.$path", requestFactory = { - val bodyString = bodyFactory() + val requests = builder() + val results = ArrayList?>(requests.size) + + val successfullyBuiltRequests = arrayListOf() + + for (requestBuildResult in requests) { + if (requestBuildResult.isSuccess) { + successfullyBuiltRequests.add(requestBuildResult.getOrThrow()) + results.add(null) // placeholder for now + } else { + results.add(Result.failure(requestBuildResult.exceptionOrNull()!!)) + } + } + + val bodyString = Json.encodeToString(successfullyBuiltRequests) val body = bodyString.toRequestBody("application/json".toMediaType()) - Request.Builder().url(url).post(body).build() + results to successfullyBuiltRequests.size to Request.Builder().url(url).post(body).build() }, serverBaseUrl = server.url, x25519PublicKey = server.publicKey, version = Version.V4 ) - return withContext(Dispatchers.IO) { - requireNotNull(response.body) { "Response doesn't have a body" } + val (intermediateResults, numSuccessfullyBuiltRequests) = r + + @Suppress("OPT_IN_USAGE") val apiResponses = withContext(Dispatchers.Default) { + requireNotNull(rawApiResponse.body) { "Response doesn't have a body" } .inputStream() - .use { Json.decodeFromStream(it) } + .use { Json.decodeFromStream>(it) } + } + + check(numSuccessfullyBuiltRequests == apiResponses.size) { + "Number of API responses (${apiResponses.size}) does not match number of successfully built requests ($numSuccessfullyBuiltRequests)" + } + + val apiResponseIterator = apiResponses.iterator() + + intermediateResults.forEachIndexed { idx, result -> + if (result == null) { + // this was a successfully built request, meaning we will get a corresponding API result + intermediateResults[idx] = Result.success(apiResponseIterator.next()) + } } + + @Suppress("UNCHECKED_CAST") + return intermediateResults as List> } } From d10e807c180276f4ffebdea6301915b95d1c02b8 Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:07:49 +1100 Subject: [PATCH 75/77] refetch community caps after 400 error (#1828) --- .../org/session/libsession/database/StorageProtocol.kt | 1 + .../libsession/messaging/open_groups/OpenGroupApi.kt | 10 ++++++++-- .../thoughtcrime/securesms/database/LokiAPIDatabase.kt | 4 ++++ .../org/thoughtcrime/securesms/database/Storage.kt | 4 ++++ 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/session/libsession/database/StorageProtocol.kt b/app/src/main/java/org/session/libsession/database/StorageProtocol.kt index 1d99e66c52..030b5071c9 100644 --- a/app/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/app/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -57,6 +57,7 @@ interface StorageProtocol { // Servers fun setServerCapabilities(server: String, capabilities: List) fun getServerCapabilities(server: String): List? + fun clearServerCapabilities(server: String) // Open Groups suspend fun addOpenGroup(urlAsString: String) diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt index 414e65ed35..95fa616069 100644 --- a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt @@ -372,9 +372,15 @@ object OpenGroupApi { x25519PublicKey = serverPublicKey ) } catch (e: Exception) { - //todo ONION handle the case where we get a 400 with "Invalid authentication: this server requires the use of blinded ids" - call capabilities once and retry < FANCHAO when (e) { - is OnionError -> Log.e("SOGS", "Failed onion request: ${e.message}", e) + is OnionError -> { + Log.e("SOGS", "Failed onion request: ${e.message}", e) + + if (e.status?.code == 400 && + e.status.bodyText?.contains("Invalid authentication: this server requires the use of blinded ids", ignoreCase = true) == true) { + MessagingModuleConfiguration.shared.storage.clearServerCapabilities(request.server) + } + } else -> Log.e("SOGS", "Failed onion request", e) } throw e diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt index 6f2e3ec679..3b41b30e62 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt @@ -513,6 +513,10 @@ class LokiAPIDatabase(context: Context, helper: Provider) : }?.split(",") } + fun clearServerCapabilities(serverName: String) { + writableDatabase.delete(serverCapabilitiesTable, "$server = ?", wrap(serverName)) + } + fun setLastInboxMessageId(serverName: String, newValue: Long) { val database = writableDatabase val row = wrap(mapOf(server to serverName, lastInboxMessageServerId to newValue.toString())) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index 84719c59d9..f77a908220 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -864,6 +864,10 @@ open class Storage @Inject constructor( return lokiAPIDatabase.getServerCapabilities(server) } + override fun clearServerCapabilities(server: String) { + lokiAPIDatabase.clearServerCapabilities(server) + } + override fun getAllGroups(includeInactive: Boolean): List { return groupDatabase.getAllGroups(includeInactive) } From 2ac7994a8058d2c5d6f3420ff07e5834507f815a Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 14 Jan 2026 16:10:30 +1100 Subject: [PATCH 76/77] Added unit tests base on networking logic --- .../network/onion/http/HttpOnionTransport.kt | 4 +- .../securesms/LogMockingTestBase.kt | 30 +++ .../network/HttpOnionTransportTest.kt | 203 +++++++++++++++++ .../network/NetworkErrorManagerTest.kt | 115 ++++++++++ .../securesms/network/PathManagerTest.kt | 144 ++++++++++++ .../network/ServerClientErrorManagerTest.kt | 90 ++++++++ .../network/SnodeClientErrorManagerTest.kt | 210 ++++++++++++++++++ 7 files changed, 795 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/org/thoughtcrime/securesms/LogMockingTestBase.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/network/HttpOnionTransportTest.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/network/NetworkErrorManagerTest.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/network/PathManagerTest.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/network/ServerClientErrorManagerTest.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/network/SnodeClientErrorManagerTest.kt diff --git a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt index de055be400..1a791b21ee 100644 --- a/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt +++ b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt @@ -1,5 +1,6 @@ package org.session.libsession.network.onion.http +import androidx.annotation.VisibleForTesting import dagger.Lazy import org.session.libsession.network.model.ErrorStatus import org.session.libsession.network.model.OnionDestination @@ -87,7 +88,8 @@ class HttpOnionTransport @Inject constructor( /** * Errors thrown by the guard / path hop BEFORE we get an onion-encrypted reply. */ - private fun mapPathHttpError( + @VisibleForTesting + internal fun mapPathHttpError( node: Snode, ex: HTTP.HTTPRequestFailedException, path: Path, diff --git a/app/src/test/java/org/thoughtcrime/securesms/LogMockingTestBase.kt b/app/src/test/java/org/thoughtcrime/securesms/LogMockingTestBase.kt new file mode 100644 index 0000000000..62d4a2a679 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/LogMockingTestBase.kt @@ -0,0 +1,30 @@ +package org.thoughtcrime.securesms + +import org.junit.After +import org.junit.Before +import org.mockito.MockedStatic +import org.mockito.Mockito.any +import org.mockito.Mockito.mockStatic +import org.session.libsignal.utilities.Log + +abstract class LogMockingTestBase { + private lateinit var logMock: MockedStatic + + @Before + fun mockLog() { + logMock = mockStatic(Log::class.java).apply { + // Stub the ones you hit. PathManager uses Log.w + Log.d. + `when` { Log.w(any(), any()) }.then { } + `when` { Log.w(any(), any(), any()) }.then { } + `when` { Log.d(any(), any()) }.then { } + `when` { Log.d(any(), any(), any()) }.then { } + `when` { Log.e(any(), any()) }.then { } + `when` { Log.e(any(), any(), any()) }.then { } + } + } + + @After + fun unmockLog() { + logMock.close() + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/network/HttpOnionTransportTest.kt b/app/src/test/java/org/thoughtcrime/securesms/network/HttpOnionTransportTest.kt new file mode 100644 index 0000000000..f27a10bd21 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/network/HttpOnionTransportTest.kt @@ -0,0 +1,203 @@ +package org.thoughtcrime.securesms.network + +import com.google.common.truth.Truth.assertThat +import dagger.Lazy +import org.junit.Test +import org.mockito.kotlin.mock +import org.session.libsession.network.model.OnionDestination +import org.session.libsession.network.model.OnionError +import org.session.libsession.network.model.Path +import org.session.libsession.network.onion.http.HttpOnionTransport +import org.session.libsession.network.snode.SnodeDirectory +import org.session.libsignal.utilities.HTTP +import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.LogMockingTestBase + +class HttpOnionTransportMappingTest: LogMockingTestBase() { + + + private val snodeDirLazy: Lazy = Lazy { mock() } + private val transport = HttpOnionTransport(snodeDirectory = snodeDirLazy) + + private fun snode(id: String): Snode = + Snode( + address = "https://$id.example", + port = 443, + publicKeySet = Snode.KeySet(ed25519Key = "ed_$id", x25519Key = "x_$id"), + version = Snode.Version.ZERO + ) + + private fun serverDest(): OnionDestination = + OnionDestination.ServerDestination( + host = "example.com", + target = "v4", + x25519PublicKey = "xkey", + scheme = "https", + port = 443 + ) + + private fun httpFail(code: Int, body: String?): HTTP.HTTPRequestFailedException = + HTTP.HTTPRequestFailedException(statusCode = code, body = body) + + // ----- 502 mapping ----- + + @Test + fun `502 next node not found matching snode destination pk to DestinationUnreachable`() { + val guard = snode("guard") + val dest = snode("dest") + val path: Path = listOf(guard, snode("m1"), snode("m2")) + + val ex = httpFail(502, "Next node not found: ${dest.publicKeySet!!.ed25519Key}") + + val err = transport.mapPathHttpError( + node = guard, + ex = ex, + path = path, + destination = OnionDestination.SnodeDestination(dest) + ) + + assertThat(err).isInstanceOf(OnionError.DestinationUnreachable::class.java) + } + + @Test + fun `502 next node not found non-matching pk to IntermediateNodeUnreachable with failed pk`() { + val guard = snode("guard") + val dest = snode("dest") + val failedPk = "ed_other" + val path: Path = listOf(guard, snode("m1"), snode("m2")) + + val ex = httpFail(502, "Next node not found: $failedPk") + + val err = transport.mapPathHttpError(guard, ex, path, OnionDestination.SnodeDestination(dest)) + + assertThat(err).isInstanceOf(OnionError.IntermediateNodeUnreachable::class.java) + assertThat((err as OnionError.IntermediateNodeUnreachable).failedPublicKey).isEqualTo(failedPk) + } + + @Test + fun `502 next node unreachable server destination to IntermediateNodeUnreachable`() { + val guard = snode("guard") + val failedPk = "ed_other" + val path: Path = listOf(guard, snode("m1"), snode("m2")) + + val ex = httpFail(502, "Next node is currently unreachable: $failedPk") + + val err = transport.mapPathHttpError(guard, ex, path, serverDest()) + + assertThat(err).isInstanceOf(OnionError.IntermediateNodeUnreachable::class.java) + assertThat((err as OnionError.IntermediateNodeUnreachable).failedPublicKey).isEqualTo(failedPk) + } + + @Test + fun `502 trims parsed pk`() { + val guard = snode("guard") + val dest = snode("dest") + val pk = "ed_trim" + val path: Path = listOf(guard, snode("m1"), snode("m2")) + + val ex = httpFail(502, "Next node not found: $pk ") + + val err = transport.mapPathHttpError(guard, ex, path, OnionDestination.SnodeDestination(dest)) + + assertThat(err).isInstanceOf(OnionError.IntermediateNodeUnreachable::class.java) + assertThat((err as OnionError.IntermediateNodeUnreachable).failedPublicKey).isEqualTo(pk) + } + + // ----- 503 mapping ----- + + @Test + fun `503 service node not ready to SnodeNotReady with guard pk`() { + val guard = snode("guard") + val path: Path = listOf(guard, snode("m1"), snode("m2")) + + val ex = httpFail(503, "Service node is not ready: something") + + val err = transport.mapPathHttpError(guard, ex, path, serverDest()) + + assertThat(err).isInstanceOf(OnionError.SnodeNotReady::class.java) + assertThat((err as OnionError.SnodeNotReady).failedPublicKey) + .isEqualTo(guard.publicKeySet!!.ed25519Key) + } + + @Test + fun `503 server busy to SnodeNotReady with guard pk`() { + val guard = snode("guard") + val path: Path = listOf(guard, snode("m1"), snode("m2")) + + val ex = httpFail(503, "Server busy, try again later") + + val err = transport.mapPathHttpError(guard, ex, path, serverDest()) + + assertThat(err).isInstanceOf(OnionError.SnodeNotReady::class.java) + assertThat((err as OnionError.SnodeNotReady).failedPublicKey) + .isEqualTo(guard.publicKeySet!!.ed25519Key) + } + + @Test + fun `503 snode not ready prefix to SnodeNotReady with parsed pk`() { + val guard = snode("guard") + val path: Path = listOf(guard, snode("m1"), snode("m2")) + val failedPk = "ed_target" + + val ex = httpFail(503, "Snode not ready: $failedPk") + + val err = transport.mapPathHttpError(guard, ex, path, serverDest()) + + assertThat(err).isInstanceOf(OnionError.SnodeNotReady::class.java) + assertThat((err as OnionError.SnodeNotReady).failedPublicKey).isEqualTo(failedPk) + } + + // ----- 504 mapping ----- + + @Test + fun `504 request time out to PathTimedOut`() { + val guard = snode("guard") + val path: Path = listOf(guard, snode("m1"), snode("m2")) + + val ex = httpFail(504, "Request time out") + + val err = transport.mapPathHttpError(guard, ex, path, serverDest()) + + assertThat(err).isInstanceOf(OnionError.PathTimedOut::class.java) + } + + @Test + fun `504 without request time out to PathError`() { + val guard = snode("guard") + val path: Path = listOf(guard, snode("m1"), snode("m2")) + + val ex = httpFail(504, "Gateway timeout") // doesn't match your substring + + val err = transport.mapPathHttpError(guard, ex, path, serverDest()) + + assertThat(err).isInstanceOf(OnionError.PathError::class.java) + } + + // ----- 500 mapping ----- + + @Test + fun `500 invalid response from snode to InvalidHopResponse`() { + val guard = snode("guard") + val path: Path = listOf(guard, snode("m1"), snode("m2")) + + val ex = httpFail(500, "Invalid response from snode") + + val err = transport.mapPathHttpError(guard, ex, path, serverDest()) + + assertThat(err).isInstanceOf(OnionError.InvalidHopResponse::class.java) + } + + // ----- default mapping ----- + + @Test + fun `other status to PathError`() { + val guard = snode("guard") + val path: Path = listOf(guard, snode("m1"), snode("m2")) + + val ex = httpFail(418, "I'm a teapot") + + val err = transport.mapPathHttpError(guard, ex, path, serverDest()) + + assertThat(err).isInstanceOf(OnionError.PathError::class.java) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/network/NetworkErrorManagerTest.kt b/app/src/test/java/org/thoughtcrime/securesms/network/NetworkErrorManagerTest.kt new file mode 100644 index 0000000000..d93f1c4c34 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/network/NetworkErrorManagerTest.kt @@ -0,0 +1,115 @@ +package org.thoughtcrime.securesms.network + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.session.libsession.network.NetworkErrorManager +import org.session.libsession.network.NetworkFailureContext +import org.session.libsession.network.model.ErrorStatus +import org.session.libsession.network.model.FailureDecision +import org.session.libsession.network.model.OnionDestination +import org.session.libsession.network.model.OnionError +import org.session.libsession.network.onion.PathManager +import org.session.libsession.network.snode.SnodeDirectory +import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.LogMockingTestBase +import org.thoughtcrime.securesms.util.NetworkConnectivity + +class NetworkErrorManagerTest: LogMockingTestBase() { + + + private val pathManager = mock() + private val snodeDirectory = mock() + private val connectivity = mock { + on { networkAvailable } doReturn MutableStateFlow(true) + } + + private val manager = NetworkErrorManager( + pathManager = pathManager, + snodeDirectory = snodeDirectory, + connectivity = connectivity + ) + + private fun snode(id: String) = + Snode( + address = "https://$id.example", + port = 443, + publicKeySet = Snode.KeySet("ed_$id", "x_$id"), + version = Snode.Version.ZERO + ) + + @Test + fun `GuardUnreachable with network penalises guard and retries`() = runTest { + val guard = snode("guard") + val path = listOf(guard, snode("m1"), snode("m2")) + + val error = OnionError.GuardUnreachable( + guard = guard, + destination = OnionDestination.ServerDestination("h", "v4", "x", "https", 443), + cause = RuntimeException() + ) + + val decision = manager.onFailure( + error, + NetworkFailureContext(path = path, destination = error.destination!!) + ) + + assertThat(decision).isEqualTo(FailureDecision.Retry) + verify(pathManager).handleBadSnode(guard) + } + + @Test + fun `IntermediateNodeUnreachable in path retries`() = runTest { + val bad = snode("bad") + val path = listOf(snode("g"), bad, snode("m")) + + whenever(snodeDirectory.getSnodeByKey(bad.publicKeySet!!.ed25519Key)) + .thenReturn(bad) + + val error = OnionError.IntermediateNodeUnreachable( + reportingNode = path.first(), + failedPublicKey = bad.publicKeySet.ed25519Key, + status = ErrorStatus( + code = 502, + message = "Next node not found: ${bad.publicKeySet.ed25519Key}", + body = null + ), + destination = OnionDestination.ServerDestination("h", "v4", "x", "https", 443) + ) + + val decision = manager.onFailure( + error, + NetworkFailureContext(path = path, destination = error.destination!!) + ) + + assertThat(decision).isEqualTo(FailureDecision.Retry) + verify(pathManager).handleBadSnode(bad, forceRemove = true) + } + + @Test + fun `PathTimedOut penalises path and retries`() = runTest { + val path = listOf(snode("g"), snode("m1"), snode("m2")) + + val error = OnionError.PathTimedOut( + status = ErrorStatus( + code = 504, + message = "Request time out", + body = null + ), + destination = OnionDestination.ServerDestination("h", "v4", "x", "https", 443) + ) + + val decision = manager.onFailure( + error, + NetworkFailureContext(path = path, destination = error.destination!!) + ) + + assertThat(decision).isEqualTo(FailureDecision.Retry) + verify(pathManager).handleBadPath(path) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/network/PathManagerTest.kt b/app/src/test/java/org/thoughtcrime/securesms/network/PathManagerTest.kt new file mode 100644 index 0000000000..af5b02a8e3 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/network/PathManagerTest.kt @@ -0,0 +1,144 @@ +package org.thoughtcrime.securesms.network + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Test +import org.mockito.kotlin.* +import org.session.libsession.network.model.Path +import org.session.libsession.network.onion.PathManager +import org.session.libsession.network.snode.SnodeDirectory +import org.session.libsession.network.snode.SnodePathStorage +import org.session.libsession.network.snode.SwarmDirectory +import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.LogMockingTestBase + +class PathManagerTest: LogMockingTestBase() { + + private fun snode(id: String): Snode = + Snode( + address = "https://$id.example", + port = 443, + publicKeySet = Snode.KeySet(ed25519Key = "ed_$id", x25519Key = "x_$id"), + version = Snode.Version.ZERO + ) + + private class FakePathStorage(initial: List) : SnodePathStorage { + private var value: List = initial + var lastSet: List? = null + var cleared = false + + override fun getOnionRequestPaths(): List = value + + override fun setOnionRequestPaths(paths: List) { + value = paths + lastSet = paths + } + + override fun clearOnionRequestPaths() { + value = emptyList() + cleared = true + } + } + + @Test + fun `init sanitize drops backup when overlapping`() = runTest { + val a = snode("a"); val b = snode("b"); val c = snode("c"); val d = snode("d") + + val p1: Path = listOf(a, b, c) + val p2: Path = listOf(a, d, c) // overlaps + + val storage = FakePathStorage(listOf(p1, p2)) + val directory = mock() + val swarmDirectory = mock() + + val pm = PathManager( + scope = backgroundScope, + directory = directory, + storage = storage, + swarmDirectory = swarmDirectory + ) + + assertThat(pm.paths.value).hasSize(1) + assertThat(pm.paths.value.first()).isEqualTo(p1) + } + + @Test + fun `getPath excludes node when possible`() = runTest { + val a = snode("a"); val b = snode("b"); val c = snode("c") + val d = snode("d"); val e = snode("e"); val f = snode("f") + + val p1: Path = listOf(a, b, c) + val p2: Path = listOf(d, e, f) + + val storage = FakePathStorage(listOf(p1, p2)) + val directory = mock() + val swarmDirectory = mock() + + val pm = PathManager(backgroundScope, directory, storage, swarmDirectory) + + val chosen = pm.getPath(exclude = b) + assertThat(chosen).isEqualTo(p2) + } + + @Test + fun `forceRemove drops snode from pool and swarm and repairs path when possible`() = runTest { + val a = snode("a"); val b = snode("b"); val c = snode("c") + val d = snode("d"); val e = snode("e"); val f = snode("f") + val x = snode("x") // replacement candidate + + val p1: Path = listOf(a, b, c) + val p2: Path = listOf(d, e, f) + + val storage = FakePathStorage(listOf(p1, p2)) + + val directory = mock { + // repair uses getSnodePool() + on { getSnodePool() } doReturn setOf(a,b,c,d,e,f,x) + } + val swarmDirectory = mock() + + val pm = PathManager(backgroundScope, directory, storage, swarmDirectory) + + pm.handleBadSnode(snode = b, publicKey = "pubkey123", forceRemove = true) + advanceUntilIdle() + + val newPaths = pm.paths.value + assertThat(newPaths).hasSize(2) + assertThat(newPaths.flatten()).doesNotContain(b) + + // verify external cleanup + verify(directory).dropSnodeFromPool("ed_b") // called when ed25519 present :contentReference[oaicite:9]{index=9} + verify(swarmDirectory).dropSnodeFromSwarmIfNeeded(b, "pubkey123") // pubKey context :contentReference[oaicite:10]{index=10} + + // disjoint invariant + val flat = newPaths.flatten() + assertThat(flat.toSet().size).isEqualTo(flat.size) + } + + @Test + fun `forceRemove drops path when no replacement candidate exists`() = runTest { + val a = snode("a"); val b = snode("b"); val c = snode("c") + val d = snode("d"); val e = snode("e"); val f = snode("f") + + val p1: Path = listOf(a, b, c) + val p2: Path = listOf(d, e, f) + + val storage = FakePathStorage(listOf(p1, p2)) + + val directory = mock { + // pool has only used nodes, so no candidates remain after forbidding + on { getSnodePool() } doReturn setOf(a,b,c,d,e,f) + } + val swarmDirectory = mock() + + val pm = PathManager(backgroundScope, directory, storage, swarmDirectory) + + pm.handleBadSnode(snode = b, publicKey = "pubkey123", forceRemove = true) + advanceUntilIdle() + + val newPaths = pm.paths.value + assertThat(newPaths.flatten()).doesNotContain(b) + assertThat(newPaths.size).isLessThan(2) // irreparable path dropped :contentReference[oaicite:11]{index=11} + } +} \ No newline at end of file diff --git a/app/src/test/java/org/thoughtcrime/securesms/network/ServerClientErrorManagerTest.kt b/app/src/test/java/org/thoughtcrime/securesms/network/ServerClientErrorManagerTest.kt new file mode 100644 index 0000000000..c9d9b525ea --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/network/ServerClientErrorManagerTest.kt @@ -0,0 +1,90 @@ +package org.thoughtcrime.securesms.network + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.session.libsession.network.ServerClientErrorManager +import org.session.libsession.network.ServerClientFailureContext +import org.session.libsession.network.SnodeClock +import org.session.libsession.network.model.ErrorStatus +import org.session.libsession.network.model.FailureDecision +import org.session.libsession.network.model.OnionDestination +import org.session.libsession.network.model.OnionError +import org.thoughtcrime.securesms.LogMockingTestBase + +class ServerClientErrorManagerTest: LogMockingTestBase() { + + private val snodeClock = mock() + private val manager = ServerClientErrorManager(snodeClock = snodeClock) + + private fun serverDest() = OnionDestination.ServerDestination( + host = "example.com", + target = "v4", + x25519PublicKey = "xkey", + scheme = "https", + port = 443 + ) + + @Test + fun `COS 425 first time - resync true to Retry`() = runTest { + whenever(snodeClock.resyncClock()).thenReturn(true) + + val status = ErrorStatus(code = 425, message = "Clock out of sync", body = null) + val error = OnionError.DestinationError(destination = serverDest(), status = status) + + val decision = manager.onFailure( + error = error, + ctx = ServerClientFailureContext(url = "https://example.com", previousError = null) + ) + + assertThat(decision).isEqualTo(FailureDecision.Retry) + verify(snodeClock).resyncClock() + } + + @Test + fun `COS 425 first time - resync false to Fail`() = runTest { + whenever(snodeClock.resyncClock()).thenReturn(false) + + val status = ErrorStatus(code = 425, message = "Clock out of sync", body = null) + val error = OnionError.DestinationError(destination = serverDest(), status = status) + + val decision = manager.onFailure( + error = error, + ctx = ServerClientFailureContext(url = "https://example.com", previousError = null) + ) + + assertThat(decision).isInstanceOf(FailureDecision.Fail::class.java) + verify(snodeClock).resyncClock() + } + + @Test + fun `COS 425 second time to Fail (no more remediation)`() = runTest { + val status = ErrorStatus(code = 425, message = "Clock out of sync", body = null) + val error = OnionError.DestinationError(destination = serverDest(), status = status) + + val decision = manager.onFailure( + error = error, + ctx = ServerClientFailureContext(url = "https://example.com", previousError = error) + ) + + assertThat(decision).isInstanceOf(FailureDecision.Fail::class.java) + verify(snodeClock, never()).resyncClock() + } + + @Test + fun `default - non COS DestinationError to Fail`() = runTest { + val status = ErrorStatus(code = 500, message = "nope", body = null) + val error = OnionError.DestinationError(destination = serverDest(), status = status) + + val decision = manager.onFailure( + error = error, + ctx = ServerClientFailureContext(url = "https://example.com", previousError = null) + ) + + assertThat(decision).isInstanceOf(FailureDecision.Fail::class.java) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/network/SnodeClientErrorManagerTest.kt b/app/src/test/java/org/thoughtcrime/securesms/network/SnodeClientErrorManagerTest.kt new file mode 100644 index 0000000000..67bd580948 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/network/SnodeClientErrorManagerTest.kt @@ -0,0 +1,210 @@ +package org.thoughtcrime.securesms.network + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import org.session.libsession.network.SnodeClientErrorManager +import org.session.libsession.network.SnodeClientFailureContext +import org.session.libsession.network.SnodeClock +import org.session.libsession.network.model.ErrorStatus +import org.session.libsession.network.model.FailureDecision +import org.session.libsession.network.model.OnionDestination +import org.session.libsession.network.model.OnionError +import org.session.libsession.network.onion.PathManager +import org.session.libsession.network.snode.SwarmDirectory +import org.session.libsignal.utilities.ByteArraySlice.Companion.view +import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.LogMockingTestBase + +class SnodeClientErrorManagerTest: LogMockingTestBase() { + + private val pathManager = mock() + private val swarmDirectory = mock() + private val snodeClock = mock() + + private val manager = SnodeClientErrorManager( + pathManager = pathManager, + swarmDirectory = swarmDirectory, + snodeClock = snodeClock + ) + + private fun snode(id: String) = + Snode( + address = "https://$id.example", + port = 443, + publicKeySet = Snode.KeySet(ed25519Key = "ed_$id", x25519Key = "x_$id"), + version = Snode.Version.ZERO + ) + + private fun snodeDest(s: Snode) = OnionDestination.SnodeDestination(s) + + @Test + fun `DestinationUnreachable to forceRemove target snode and Retry`() = runTest { + val target = snode("target") + val dest = snodeDest(target) + val status = ErrorStatus(code = 502, message = "nope", body = null) + + val error = OnionError.DestinationUnreachable(status = status, destination = dest) + val ctx = SnodeClientFailureContext(targetSnode = target, publicKey = "pub") + + val decision = manager.onFailure(error, ctx) + + assertThat(decision).isEqualTo(FailureDecision.Retry) + verify(pathManager).handleBadSnode(snode = target, publicKey = "pub", forceRemove = true) + } + + @Test + fun `COS 406 first time - resync true to Retry`() = runTest { + val target = snode("target") + whenever(snodeClock.resyncClock()).thenReturn(true) + + val status = ErrorStatus(code = 406, message = "Clock out of sync", body = null) + val error = OnionError.DestinationError(destination = snodeDest(target), status = status) + val ctx = SnodeClientFailureContext(targetSnode = target, publicKey = "pub", previousError = null) + + val decision = manager.onFailure(error, ctx) + + assertThat(decision).isEqualTo(FailureDecision.Retry) + verify(snodeClock).resyncClock() + verifyNoInteractions(pathManager) + } + + @Test + fun `COS 406 first time - resync false to Fail`() = runTest { + val target = snode("target") + whenever(snodeClock.resyncClock()).thenReturn(false) + + val status = ErrorStatus(code = 406, message = "Clock out of sync", body = null) + val error = OnionError.DestinationError(destination = snodeDest(target), status = status) + val ctx = SnodeClientFailureContext(targetSnode = target, publicKey = "pub", previousError = null) + + val decision = manager.onFailure(error, ctx) + + assertThat(decision).isInstanceOf(FailureDecision.Fail::class.java) + verify(snodeClock).resyncClock() + verifyNoInteractions(pathManager) + } + + @Test + fun `COS 406 second time to forceRemove target snode and Retry`() = runTest { + val target = snode("target") + + val status = ErrorStatus(code = 406, message = "Clock out of sync", body = null) + val error = OnionError.DestinationError(destination = snodeDest(target), status = status) + val previous = OnionError.DestinationError(destination = snodeDest(target), status = status) + val ctx = SnodeClientFailureContext(targetSnode = target, publicKey = "pub", previousError = previous) + + val decision = manager.onFailure(error, ctx) + + assertThat(decision).isEqualTo(FailureDecision.Retry) + verify(pathManager).handleBadSnode(snode = target, publicKey = "pub", forceRemove = true) + verify(snodeClock, never()).resyncClock() + } + + @Test + fun `421 with pubKey - updateSwarmFromResponse true to Retry and no drop`() = runTest { + val target = snode("target") + whenever(swarmDirectory.updateSwarmFromResponse(eq("pub"), any())).thenReturn(true) + + val status = ErrorStatus(code = 421, message = "not in swarm", body = """{"snodes":[...]}""".toByteArray().view()) + val error = OnionError.DestinationError(destination = snodeDest(target), status = status) + val ctx = SnodeClientFailureContext(targetSnode = target, publicKey = "pub") + + val decision = manager.onFailure(error, ctx) + + assertThat(decision).isEqualTo(FailureDecision.Retry) + verify(swarmDirectory).updateSwarmFromResponse(eq("pub"), any()) + verify(swarmDirectory, never()).dropSnodeFromSwarmIfNeeded(any(), any()) + } + + @Test + fun `421 with pubKey - updateSwarmFromResponse false to drop target snode and Retry`() = runTest { + val target = snode("target") + whenever(swarmDirectory.updateSwarmFromResponse(eq("pub"), any())).thenReturn(false) + + val status = ErrorStatus(code = 421, message = "not in swarm", body = """{}""".toByteArray().view()) + val error = OnionError.DestinationError(destination = snodeDest(target), status = status) + val ctx = SnodeClientFailureContext(targetSnode = target, publicKey = "pub") + + val decision = manager.onFailure(error, ctx) + + assertThat(decision).isEqualTo(FailureDecision.Retry) + verify(swarmDirectory).updateSwarmFromResponse(eq("pub"), any()) + verify(swarmDirectory).dropSnodeFromSwarmIfNeeded(target, "pub") + } + + @Test + fun `421 without pubKey to Retry and no update-drop`() = runTest { + val target = snode("target") + + val status = ErrorStatus(code = 421, message = "not in swarm", body = """{}""".toByteArray().view()) + val error = OnionError.DestinationError(destination = snodeDest(target), status = status) + val ctx = SnodeClientFailureContext(targetSnode = target, publicKey = null) + + val decision = manager.onFailure(error, ctx) + + assertThat(decision).isEqualTo(FailureDecision.Retry) + verify(swarmDirectory, never()).updateSwarmFromResponse(any(), any()) + verify(swarmDirectory, never()).dropSnodeFromSwarmIfNeeded(any(), any()) + } + + @Test + fun `502 unparsable data to forceRemove target snode and Retry`() = runTest { + val target = snode("target") + + val status = ErrorStatus( + code = 502, + message = "bad", + body = "oxend returned unparsable data".toByteArray().view() + ) + val error = OnionError.DestinationError(destination = snodeDest(target), status = status) + val ctx = SnodeClientFailureContext(targetSnode = target, publicKey = "pub") + + val decision = manager.onFailure(error, ctx) + + assertThat(decision).isEqualTo(FailureDecision.Retry) + verify(pathManager).handleBadSnode(snode = target, publicKey = "pub", forceRemove = true) + } + + @Test + fun `503 destination snode not ready to normal strike and Retry`() = runTest { + val target = snode("target") + + val status = ErrorStatus( + code = 503, + message = "busy", + body = "Snode not ready".toByteArray().view() + ) + val error = OnionError.DestinationError(destination = snodeDest(target), status = status) + val ctx = SnodeClientFailureContext(targetSnode = target, publicKey = "pub") + + val decision = manager.onFailure(error, ctx) + + assertThat(decision).isEqualTo(FailureDecision.Retry) + // NOTE: forceRemove defaults false here + verify(pathManager).handleBadSnode(snode = target, publicKey = "pub") + verify(pathManager, never()).handleBadSnode(snode = target, publicKey = "pub", forceRemove = true) + } + + @Test + fun `default - non matching DestinationError to Fail`() = runTest { + val target = snode("target") + + val status = ErrorStatus(code = 418, message = "teapot", body = null) + val error = OnionError.DestinationError(destination = snodeDest(target), status = status) + val ctx = SnodeClientFailureContext(targetSnode = target, publicKey = "pub") + + val decision = manager.onFailure(error, ctx) + + assertThat(decision).isInstanceOf(FailureDecision.Fail::class.java) + verifyNoInteractions(pathManager) + } +} From c4f179ff24f458ec0e8d6cc864aa087e5283e18f Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 14 Jan 2026 16:30:03 +1100 Subject: [PATCH 77/77] Updated tests --- .../securesms/LogMockingTestBase.kt | 30 ------------------- .../network/HttpOnionTransportTest.kt | 7 +++-- .../network/NetworkErrorManagerTest.kt | 7 +++-- .../securesms/network/PathManagerTest.kt | 8 +++-- .../network/ServerClientErrorManagerTest.kt | 8 +++-- .../network/SnodeClientErrorManagerTest.kt | 8 +++-- 6 files changed, 28 insertions(+), 40 deletions(-) delete mode 100644 app/src/test/java/org/thoughtcrime/securesms/LogMockingTestBase.kt diff --git a/app/src/test/java/org/thoughtcrime/securesms/LogMockingTestBase.kt b/app/src/test/java/org/thoughtcrime/securesms/LogMockingTestBase.kt deleted file mode 100644 index 62d4a2a679..0000000000 --- a/app/src/test/java/org/thoughtcrime/securesms/LogMockingTestBase.kt +++ /dev/null @@ -1,30 +0,0 @@ -package org.thoughtcrime.securesms - -import org.junit.After -import org.junit.Before -import org.mockito.MockedStatic -import org.mockito.Mockito.any -import org.mockito.Mockito.mockStatic -import org.session.libsignal.utilities.Log - -abstract class LogMockingTestBase { - private lateinit var logMock: MockedStatic - - @Before - fun mockLog() { - logMock = mockStatic(Log::class.java).apply { - // Stub the ones you hit. PathManager uses Log.w + Log.d. - `when` { Log.w(any(), any()) }.then { } - `when` { Log.w(any(), any(), any()) }.then { } - `when` { Log.d(any(), any()) }.then { } - `when` { Log.d(any(), any(), any()) }.then { } - `when` { Log.e(any(), any()) }.then { } - `when` { Log.e(any(), any(), any()) }.then { } - } - } - - @After - fun unmockLog() { - logMock.close() - } -} diff --git a/app/src/test/java/org/thoughtcrime/securesms/network/HttpOnionTransportTest.kt b/app/src/test/java/org/thoughtcrime/securesms/network/HttpOnionTransportTest.kt index f27a10bd21..103d3ea218 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/network/HttpOnionTransportTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/network/HttpOnionTransportTest.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.network import com.google.common.truth.Truth.assertThat import dagger.Lazy +import org.junit.Rule import org.junit.Test import org.mockito.kotlin.mock import org.session.libsession.network.model.OnionDestination @@ -11,10 +12,12 @@ import org.session.libsession.network.onion.http.HttpOnionTransport import org.session.libsession.network.snode.SnodeDirectory import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.Snode -import org.thoughtcrime.securesms.LogMockingTestBase +import org.thoughtcrime.securesms.util.MockLoggingRule -class HttpOnionTransportMappingTest: LogMockingTestBase() { +class HttpOnionTransportMappingTest { + @get:Rule + val logRule = MockLoggingRule() private val snodeDirLazy: Lazy = Lazy { mock() } private val transport = HttpOnionTransport(snodeDirectory = snodeDirLazy) diff --git a/app/src/test/java/org/thoughtcrime/securesms/network/NetworkErrorManagerTest.kt b/app/src/test/java/org/thoughtcrime/securesms/network/NetworkErrorManagerTest.kt index d93f1c4c34..1d3049d25b 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/network/NetworkErrorManagerTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/network/NetworkErrorManagerTest.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.network import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest +import org.junit.Rule import org.junit.Test import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock @@ -17,11 +18,13 @@ import org.session.libsession.network.model.OnionError import org.session.libsession.network.onion.PathManager import org.session.libsession.network.snode.SnodeDirectory import org.session.libsignal.utilities.Snode -import org.thoughtcrime.securesms.LogMockingTestBase +import org.thoughtcrime.securesms.util.MockLoggingRule import org.thoughtcrime.securesms.util.NetworkConnectivity -class NetworkErrorManagerTest: LogMockingTestBase() { +class NetworkErrorManagerTest { + @get:Rule + val logRule = MockLoggingRule() private val pathManager = mock() private val snodeDirectory = mock() diff --git a/app/src/test/java/org/thoughtcrime/securesms/network/PathManagerTest.kt b/app/src/test/java/org/thoughtcrime/securesms/network/PathManagerTest.kt index af5b02a8e3..64bc0d9a04 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/network/PathManagerTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/network/PathManagerTest.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.network import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Rule import org.junit.Test import org.mockito.kotlin.* import org.session.libsession.network.model.Path @@ -11,9 +12,12 @@ import org.session.libsession.network.snode.SnodeDirectory import org.session.libsession.network.snode.SnodePathStorage import org.session.libsession.network.snode.SwarmDirectory import org.session.libsignal.utilities.Snode -import org.thoughtcrime.securesms.LogMockingTestBase +import org.thoughtcrime.securesms.util.MockLoggingRule -class PathManagerTest: LogMockingTestBase() { +class PathManagerTest { + + @get:Rule + val logRule = MockLoggingRule() private fun snode(id: String): Snode = Snode( diff --git a/app/src/test/java/org/thoughtcrime/securesms/network/ServerClientErrorManagerTest.kt b/app/src/test/java/org/thoughtcrime/securesms/network/ServerClientErrorManagerTest.kt index c9d9b525ea..9d157039b8 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/network/ServerClientErrorManagerTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/network/ServerClientErrorManagerTest.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.network import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.test.runTest +import org.junit.Rule import org.junit.Test import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -14,9 +15,12 @@ import org.session.libsession.network.model.ErrorStatus import org.session.libsession.network.model.FailureDecision import org.session.libsession.network.model.OnionDestination import org.session.libsession.network.model.OnionError -import org.thoughtcrime.securesms.LogMockingTestBase +import org.thoughtcrime.securesms.util.MockLoggingRule -class ServerClientErrorManagerTest: LogMockingTestBase() { +class ServerClientErrorManagerTest { + + @get:Rule + val logRule = MockLoggingRule() private val snodeClock = mock() private val manager = ServerClientErrorManager(snodeClock = snodeClock) diff --git a/app/src/test/java/org/thoughtcrime/securesms/network/SnodeClientErrorManagerTest.kt b/app/src/test/java/org/thoughtcrime/securesms/network/SnodeClientErrorManagerTest.kt index 67bd580948..8ab48d664c 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/network/SnodeClientErrorManagerTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/network/SnodeClientErrorManagerTest.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.network import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.test.runTest +import org.junit.Rule import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull @@ -22,9 +23,12 @@ import org.session.libsession.network.onion.PathManager import org.session.libsession.network.snode.SwarmDirectory import org.session.libsignal.utilities.ByteArraySlice.Companion.view import org.session.libsignal.utilities.Snode -import org.thoughtcrime.securesms.LogMockingTestBase +import org.thoughtcrime.securesms.util.MockLoggingRule -class SnodeClientErrorManagerTest: LogMockingTestBase() { +class SnodeClientErrorManagerTest { + + @get:Rule + val logRule = MockLoggingRule() private val pathManager = mock() private val swarmDirectory = mock()