From de0494d393d1fe40f34603861691c9cbbab54621 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 11 Dec 2025 15:53:44 +1100 Subject: [PATCH 01/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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 ) }