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..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,9 @@ 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.ServerClient +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 @@ -28,7 +30,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, @@ -36,6 +37,9 @@ class MessagingModuleConfiguration @Inject constructor( val proStatusManager: ProStatusManager, val messageSendJobFactory: MessageSendJob.Factory, val json: Json, + val snodeClock: SnodeClock, + val serverClient: ServerClient, + 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 e5128232f7..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 @@ -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.ServerClient 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 serverClient: ServerClient ) { companion object { @@ -95,15 +94,16 @@ class FileServerApi @Inject constructor( } return if (request.useOnionRouting) { try { - val response = OnionRequestAPI.sendOnionRequest( + val response = serverClient.send( request = requestBuilder.build(), - server = request.fileServer.url.host, + serverBaseUrl = request.fileServer.url.host, x25519PublicKey = Hex.toStringCondensed( Curve25519.pubKeyFromED25519(Hex.fromStringCondensed(request.fileServer.ed25519PublicKeyHex)) ) - ).await() + ) + //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/InviteContactsJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt index ad7f31f1b7..e08fce174e 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,7 +18,7 @@ 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.libsession.utilities.withGroupConfigs @@ -34,6 +34,7 @@ class InviteContactsJob @AssistedInject constructor( @Assisted val isReinvite: Boolean, private val configFactory: ConfigFactoryProtocol, private val messageSender: MessageSender, + private val snodeClock: SnodeClock ) : Job { @@ -71,7 +72,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/jobs/NotifyPNServerJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt index 62e2fb94bb..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 @@ -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.ServerClient +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 serverClient: ServerClient by lazy { + MessagingModuleConfiguration.shared.serverClient + } + 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) { + serverClient.send( + request = request, + serverBaseUrl = server.url, + x25519PublicKey = server.publicKey, + version = Version.V2 + ) + + // todo ONION the old code was checking the status code on success and if it is null or 0 it would log it as a fail + // the new structure however throws all non 200.299 status as an OnionError } - } 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/open_groups/OpenGroupApi.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt index 3d650db223..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 @@ -18,10 +18,8 @@ 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.OnionError +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 +337,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 +407,21 @@ 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) { + try { + return MessagingModuleConfiguration.shared.serverClient.send( + 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) } - }.await() + throw e + } } 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/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 9526287ec6..7a74912d20 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 8a9b97023c..bdbc4cee68 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 @@ -6,7 +6,7 @@ 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 0635c56eb3..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,8 +28,8 @@ 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.snode.SnodeAPI -import org.session.libsession.snode.SnodeClock +import org.session.libsession.network.SnodeClient +import org.session.libsession.network.SnodeClock import org.session.libsession.snode.SnodeMessage import org.session.libsession.utilities.Address import org.session.libsession.utilities.ConfigFactoryProtocol @@ -62,6 +62,7 @@ class MessageSender @Inject constructor( private val messageSendJobFactory: MessageSendJob.Factory, private val messageExpirationManager: ExpiringMessageManager, private val snodeClock: SnodeClock, + private val snodeClient: SnodeClient, @param:ManagerScope private val scope: CoroutineScope, ) { @@ -246,14 +247,14 @@ class MessageSender @Inject constructor( "Unable to authorize group message send" } - SnodeAPI.sendMessage( + snodeClient.sendMessage( auth = groupAuth, message = snodeMessage, namespace = Namespace.GROUP_MESSAGES(), ) } is Destination.Contact -> { - SnodeAPI.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 03a37c4fc1..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.snode.SnodeAPI +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,6 +77,7 @@ class ReceivedMessageProcessor @Inject constructor( private val visibleMessageHandler: Provider, private val blindMappingRepository: BlindMappingRepository, private val messageParser: MessageParser, + private val snodeClient: SnodeClient ) { private val threadMutexes = ConcurrentHashMap() @@ -453,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 { - SnodeAPI.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/VisibleMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt index 3fbb54ac21..60d8dc6839 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/notifications/PushRegistryV1.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt index 4a0509049f..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 @@ -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.serverClient.send( + 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/messaging/sending_receiving/pollers/Poller.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt index b6c7a7573d..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,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.snode.SnodeClock +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 -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 @@ -64,6 +64,8 @@ class Poller @AssistedInject constructor( private val receivedMessageHashDatabase: ReceivedMessageHashDatabase, private val processor: ReceivedMessageProcessor, private val messageParser: MessageParser, + private val snodeClient: SnodeClient, + private val swarmStorage: SwarmStorage, @Assisted scope: CoroutineScope ) { private val userPublicKey: String @@ -175,7 +177,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 @@ -303,7 +305,7 @@ class Poller @AssistedInject constructor( // Get messages call wrapped in an async val fetchMessageTask = if (!pollOnlyUserProfileConfig) { - val request = SnodeAPI.buildAuthenticatedRetrieveBatchRequest( + val request = snodeClient.buildAuthenticatedRetrieveBatchRequest( lastHash = lokiApiDatabase.getLastMessageHashValue( snode = snode, publicKey = userAuth.accountId.hexString, @@ -314,7 +316,7 @@ class Poller @AssistedInject constructor( this.async { runCatching { - SnodeAPI.sendBatchRequest( + snodeClient.sendBatchRequest( snode = snode, publicKey = userPublicKey, request = request, @@ -339,7 +341,7 @@ class Poller @AssistedInject constructor( .map { type -> val config = configs.getConfig(type) hashesToExtend += config.activeHashes() - val request = SnodeAPI.buildAuthenticatedRetrieveBatchRequest( + val request = snodeClient.buildAuthenticatedRetrieveBatchRequest( lastHash = lokiApiDatabase.getLastMessageHashValue( snode = snode, publicKey = userAuth.accountId.hexString, @@ -352,7 +354,7 @@ class Poller @AssistedInject constructor( this.async { type to runCatching { - SnodeAPI.sendBatchRequest( + snodeClient.sendBatchRequest( snode = snode, publicKey = userPublicKey, request = request, @@ -366,10 +368,10 @@ class Poller @AssistedInject constructor( if (hashesToExtend.isNotEmpty()) { launch { try { - SnodeAPI.sendBatchRequest( + snodeClient.sendBatchRequest( snode, userPublicKey, - SnodeAPI.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/NetworkModule.kt b/app/src/main/java/org/session/libsession/network/NetworkModule.kt new file mode 100644 index 0000000000..e20bd6d8ae --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/NetworkModule.kt @@ -0,0 +1,32 @@ +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.OnionTransport +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): OnionTransport + +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/network/ServerClient.kt b/app/src/main/java/org/session/libsession/network/ServerClient.kt 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 new file mode 100644 index 0000000000..e25f5fdcd0 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/SessionNetwork.kt @@ -0,0 +1,108 @@ +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 +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Snode +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.random.Random + +/** + * High-level onion request manager. + * + * 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) + */ +@Singleton +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 + + + internal suspend fun sendWithRetry( + destination: OnionDestination, + payload: ByteArray, + version: Version, + snodeToExclude: Snode?, + targetSnode: Snode?, + publicKey: String? + ): OnionResponse { + var lastError: Throwable? = null + + for (attempt in 1..maxAttempts) { + val path: Path = pathManager.getPath(exclude = snodeToExclude) + //Log.i("Onion Request", "Sending onion request to $destination - attempt $attempt/$maxAttempts") + + try { + val result = transport.send( + path = path, + destination = destination, + payload = payload, + version = version + ) + + return result + } catch (e: Throwable) { + val onionError = e as? OnionError ?: OnionError.Unknown(e) + + Log.w("Onion Request", "Onion error on attempt $attempt/$maxAttempts: $onionError") + + lastError = onionError + + // 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 -> throw decision.throwable + FailureDecision.Retry -> { + if (attempt >= maxAttempts) break + delay(computeBackoffDelayMs(attempt)) + continue + } + } + } + } + + throw lastError ?: IllegalStateException("Unknown onion error") + } + + private fun computeBackoffDelayMs(attempt: Int): Long { + // Exponential-ish: base * 2^(attempt-1), with jitter, capped + val exp = baseRetryDelayMs * (1L shl (attempt - 1).coerceAtMost(5)) + val capped = exp.coerceAtMost(maxRetryDelayMs) + val jitter = Random.nextLong(0, capped / 3 + 1) + return capped + jitter + } +} diff --git a/app/src/main/java/org/session/libsession/network/SnodeClient.kt b/app/src/main/java/org/session/libsession/network/SnodeClient.kt new file mode 100644 index 0000000000..7339aaf5a3 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/SnodeClient.kt @@ -0,0 +1,824 @@ +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.StoreMessageResponse +import org.session.libsession.utilities.mapValuesNotNull +import org.session.libsession.utilities.toByteArray +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 +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. + */ +@Singleton +class SnodeClient @Inject constructor( + private val sessionNetwork: SessionNetwork, + private val swarmDirectory: SwarmDirectory, + private val snodeDirectory: SnodeDirectory, + private val snodeClock: SnodeClock, + private val json: Json, + private val errorManager: OnionErrorManager +) { + + //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, + ) + } 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() + } + } + } + } + + private suspend fun sendToSnode( + method: Snode.Method, + snode: Snode, + parameters: Map, + publicKey: String? = null, + version: Version = Version.V3 + ): ByteArraySlice { + 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 + ?: throw Error.Generic("Empty body from snode for method $method") + } + + @OptIn(ExperimentalSerializationApi::class) + private suspend fun sendTyped( + method: Snode.Method, + snode: Snode, + parameters: Map, + responseDeserializationStrategy: DeserializationStrategy, + publicKey: String? = null, + version: Version = Version.V3 + ): Res { + val body = sendToSnode( + method = method, + snode = snode, + parameters = parameters, + publicKey = publicKey, + version = version + ) + + return body.inputStream().use { inputStream -> + json.decodeFromStream( + deserializer = responseDeserializationStrategy, + stream = inputStream + ) + } + } + + suspend fun send( + method: Snode.Method, + snode: Snode, + parameters: Map, + publicKey: String? = null, + version: Version = Version.V3 + ): Map<*, *> { + val body = sendToSnode( + method = method, + snode = snode, + parameters = parameters, + publicKey = publicKey, + version = version + ) + + @Suppress("UNCHECKED_CAST") + 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 + + /** + * 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 { + 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 = swarmDirectory.getSingleTargetSnode(message.recipient) + + return sendBatchRequest( + snode = target, + publicKey = message.recipient, + request = SnodeBatchRequestInfo( + method = Snode.Method.SendMessage.rawValue, + params = params, + namespace = namespace + ), + responseType = StoreMessageResponse.serializer(), + sequence = false, + ) + } + + @Suppress("UNCHECKED_CAST") + suspend fun deleteMessage( + publicKey: String, + swarmAuth: SwarmAuth, + serverHashes: List, + ): Map<*, *> { + val snode = swarmDirectory.getSingleTargetSnode(publicKey) + + val params = buildAuthenticatedParameters( + auth = swarmAuth, + namespace = null, + verificationData = { _, _ -> + buildString { + append(Snode.Method.DeleteMessage.rawValue) + serverHashes.forEach(this::append) + } + } + ) { + this["messages"] = serverHashes + } + + val rawResponse = send( + method = Snode.Method.DeleteMessage, + snode = snode, + parameters = params, + publicKey = publicKey, + ) + + val swarms = rawResponse["swarm"] as? Map ?: throw Error.Generic("Missing swarm in delete response") + + val deletedMessages: Map = swarms.mapValuesNotNull { (hexSnodePublicKey, rawJSON) -> + val json = rawJSON as? Map ?: return@mapValuesNotNull null + + val isFailed = json["failed"] as? Boolean ?: false + val statusCode = json["code"]?.toString() + val reason = json["reason"] as? String + + 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(swarmAuth.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, + ): Map { + val publicKey = auth.accountId.hexString + val snode = swarmDirectory.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 = send( + method = Snode.Method.DeleteAll, + snode = snode, + parameters = params, + publicKey = publicKey, + ) + + 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, + ): Pair { + val json = send( + method = Snode.Method.Info, + snode = snode, + parameters = emptyMap(), + ) + + val timestamp = when (val t = json["timestamp"]) { + is Long -> t + is Int -> t.toLong() + is Double -> t.toLong() + else -> -1 + } + + return snode to timestamp + } + + suspend fun getAccountID(onsName: String): String { + val validationCount = 3 + val onsNameLower = onsName.lowercase(Locale.US) + + val params: Map = buildMap { + this["method"] = "ons_resolve" + this["params"] = buildMap { + this["type"] = 0 + this["name_hash"] = Base64.encodeBytes(Hash.hash32(onsNameLower.toByteArray())) + } + } + + // Ask 3 different snodes + val results = mutableListOf() + + repeat(validationCount) { + val snode = snodeDirectory.getRandomSnode() + + val json = send( + method = Snode.Method.OxenDaemonRPCCall, + snode = snode, + parameters = params, + ) + + @Suppress("UNCHECKED_CAST") + val intermediate = json["result"] as? Map<*, *> + ?: throw Error.Generic("Invalid ONS response: missing 'result'") + + 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 + } + } + + suspend fun alterTtl( + auth: SwarmAuth, + messageHashes: List, + newExpiry: Long, + shorten: Boolean = false, + extend: Boolean = false, + ): Map<*, *> { + val snode = swarmDirectory.getSingleTargetSnode(auth.accountId.hexString) + val params = buildAlterTtlParams(auth, messageHashes, newExpiry, shorten, extend) + + return send( + method = Snode.Method.Expire, + snode = snode, + parameters = params, + publicKey = auth.accountId.hexString, + ) + } + + + // Batch logic + + 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, + ): BatchResponse { + val method = if (sequence) Snode.Method.Sequence else Snode.Method.Batch + val response = sendTyped( + method = method, + snode = snode, + parameters = mapOf("requests" to requests), + responseDeserializationStrategy = BatchResponse.serializer(), + publicKey = publicKey, + ) + + // IMPORTANT: batch subresponse failures do not go through OnionErrorManager + // because the outer response is usually 200. + val firstFailed = response.results.firstOrNull { !it.isSuccessful } + if (firstFailed != null) { + handleBatchItemFailure( + targetSnode = snode, + publicKey = publicKey, + item = firstFailed + ) + } + + return response + } + + 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( + status = ErrorStatus(code = item.code, message = null, body = bodySlice), + destination = OnionDestination.SnodeDestination(targetSnode) + ) + + 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, + ): T { + val callback = Channel>(capacity = 1) + + batchedRequestsSender.send( + RequestInfo( + snode = snode, + publicKey = publicKey, + request = request, + responseType = responseType, + callback = callback, + sequence = sequence, + ) + ) + + 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, + ): JsonElement { + return sendBatchRequest( + snode = snode, + publicKey = publicKey, + request = request, + responseType = JsonElement.serializer(), + sequence = sequence, + ) + } + + 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.V3, + ) + + // Error + sealed class Error(val description: String) : Exception(description) { + data class Generic(val info: String = "An error occurred.") : Error(info) + object ValidationFailed : Error("ONS name validation failed.") + } +} diff --git a/app/src/main/java/org/session/libsession/snode/SnodeClock.kt b/app/src/main/java/org/session/libsession/network/SnodeClock.kt similarity index 80% rename from app/src/main/java/org/session/libsession/snode/SnodeClock.kt rename to app/src/main/java/org/session/libsession/network/SnodeClock.kt index 9896f3d961..f4c51f34d3 100644 --- a/app/src/main/java/org/session/libsession/snode/SnodeClock.kt +++ b/app/src/main/java/org/session/libsession/network/SnodeClock.kt @@ -1,6 +1,7 @@ -package org.session.libsession.snode +package org.session.libsession.network import android.os.SystemClock +import dagger.Lazy import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -8,7 +9,7 @@ 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.libsession.network.snode.SnodeDirectory import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent @@ -26,8 +27,14 @@ 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, + private val snodeClient: Lazy, ) : OnAppStartupComponent { + + //todo ONION we have a lot of calls to MessagingModuleConfiguration.shared.snodeClock.currentTimeMills() + // can this be improved? + private val instantState = MutableStateFlow(null) private var job: Job? = null @@ -37,29 +44,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 + var networkTime = snodeClient.get().getNetworkTime(node).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) @@ -117,4 +125,4 @@ class SnodeClock @Inject constructor( return networkTime + elapsed } } -} \ 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/network/model/BatchResponse.kt similarity index 100% rename from app/src/main/java/org/session/libsession/snode/model/BatchResponse.kt rename to app/src/main/java/org/session/libsession/network/model/BatchResponse.kt diff --git a/app/src/main/java/org/session/libsession/snode/model/MessageResponses.kt b/app/src/main/java/org/session/libsession/network/model/MessageResponses.kt similarity index 100% rename from app/src/main/java/org/session/libsession/snode/model/MessageResponses.kt rename to app/src/main/java/org/session/libsession/network/model/MessageResponses.kt 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..227f578569 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/model/OnionError.kt @@ -0,0 +1,67 @@ +package org.session.libsession.network.model + +import org.session.libsignal.utilities.ByteArraySlice +import org.session.libsignal.utilities.Snode + +data class ErrorStatus( + val code: Int, + val message: String? = null, + val body: ByteArraySlice? = null +) { + val bodyText: String? + get() = body?.decodeToString() +} + +enum class ErrorOrigin { UNKNOWN, TRANSPORT_TO_GUARD, PATH_HOP, DESTINATION_REPLY } + +sealed class OnionError( + val origin: ErrorOrigin, + val status: ErrorStatus? = null, + val snode: Snode? = null, + cause: Throwable? = null +) : Exception(status?.message ?: "Onion error", cause) { + + /** + * We couldn't even talk to the guard node. + * Typical causes: offline, DNS failure, TCP connect fails, TLS failure. + */ + class GuardUnreachable(val guard: Snode, cause: Throwable) + : OnionError(ErrorOrigin.TRANSPORT_TO_GUARD, cause = cause) + + /** + * The onion chain broke mid-path: one hop reported that the next node was not found. + * failedPublicKey is the ed25519 key of the missing snode if known. + */ + class IntermediateNodeFailed( + val reportingNode: Snode?, + val failedPublicKey: String? + ) : 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, snode = node) + + /** + * The error happened after decrypting a payload form the destination + */ + 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. + */ + class InvalidResponse(cause: Throwable? = null) + : OnionError(ErrorOrigin.DESTINATION_REPLY, cause = cause) + + /** + * Fallback for anything we haven't classified yet. + */ + 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/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/snode/OwnedSwarmAuth.kt b/app/src/main/java/org/session/libsession/network/model/OwnedSwarmAuth.kt similarity index 100% rename from app/src/main/java/org/session/libsession/snode/OwnedSwarmAuth.kt rename to app/src/main/java/org/session/libsession/network/model/OwnedSwarmAuth.kt 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/snode/SnodeMessage.kt b/app/src/main/java/org/session/libsession/network/model/SnodeMessage.kt similarity index 100% rename from app/src/main/java/org/session/libsession/snode/SnodeMessage.kt rename to app/src/main/java/org/session/libsession/network/model/SnodeMessage.kt diff --git a/app/src/main/java/org/session/libsession/snode/SwarmAuth.kt b/app/src/main/java/org/session/libsession/network/model/SwarmAuth.kt similarity index 100% rename from app/src/main/java/org/session/libsession/snode/SwarmAuth.kt rename to app/src/main/java/org/session/libsession/network/model/SwarmAuth.kt 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/network/onion/OnionErrorManager.kt b/app/src/main/java/org/session/libsession/network/onion/OnionErrorManager.kt new file mode 100644 index 0000000000..c73e1e1400 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/onion/OnionErrorManager.kt @@ -0,0 +1,143 @@ +package org.session.libsession.network.onion + +import org.session.libsession.network.SnodeClock +import org.session.libsession.network.model.OnionDestination +import org.session.libsession.network.model.OnionError +import org.session.libsession.network.model.Path +import org.session.libsession.network.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) { + 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 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) + } + + // 400, 403, 404: do not penalise path or snode; No retries + if (code == 400 || code == 403 || code == 404) { + //todo ONION need to move the REQUIRE_BLINDING_MESSAGE logic out of here, it should be handled at the calling site, in this case the community poller, to then call /capabilities once + return FailureDecision.Fail(error) + } + + // -------------------------------------------------------------------- + // 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 -> { + // 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 + } + + // 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 + } + + 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.updateSwarmFromResponse( + publicKey = publicKey, + body = status.body + ) + } else { + Log.w("Onion Request", "Got 421 without an associated public key.") + false + } + + if (!updated && publicKey != null && targetSnode != null) { + swarmDirectory.dropSnodeFromSwarmIfNeeded(targetSnode, publicKey) + } + + return FailureDecision.Retry + } + + // Anything else from destination: do not penalise path; no retries + return FailureDecision.Fail(error) + } + + // Default: fail + return FailureDecision.Fail(error) + } +} + +data class 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/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..7fc2d9d634 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/onion/OnionTransport.kt @@ -0,0 +1,24 @@ +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. + * + */ + suspend fun send( + path: List, + destination: OnionDestination, + payload: ByteArray, + version: Version + ): OnionResponse +} + +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..7f84c323b9 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/onion/PathManager.kt @@ -0,0 +1,202 @@ +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.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 +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 + +@Singleton +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 _paths = MutableStateFlow( + sanitizePaths(storage.getOnionRequestPaths()) + ) + val paths: StateFlow> = _paths.asStateFlow() + + // Used for synchronization + private val buildMutex = Mutex() + + 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) + } + + // 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) { + // 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 + } + + _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 + } + + 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) { + _paths.update { currentList -> + // Locate the bad path in the *current* snapshot + val pathIndex = currentList.indexOfFirst { it.contains(snode) } + + // If the node isn't found (e.g., paths were just rebuilt), do nothing + if (pathIndex == -1) return@update currentList + + // Prepare mutable copies for modification + // We copy the outer list so we don't mutate the 'currentList' which might be needed for a CAS retry + val newPathsList = currentList.toMutableList() + val pathParams = newPathsList[pathIndex].toMutableList() + + // Remove the bad node + pathParams.remove(snode) + + // Find a replacement + val usedSnodes = newPathsList.flatten().toSet() + val pool = directory.getSnodePool() + val unused = pool.minus(usedSnodes) + + if (unused.isEmpty()) { + Log.w("Onion Request", "No unused snodes to repair path, dropping path entirely") + newPathsList.removeAt(pathIndex) + } else { + val replacement = unused.secureRandom() + pathParams.add(replacement) + newPathsList[pathIndex] = pathParams + } + + // Return the new clean list + sanitizePaths(newPathsList) + } + } + + /** Called when an entire path is considered unreliable. */ + fun handleBadPath(path: Path) { + _paths.update { currentList -> + // Filter returns a new list, so this is safe and atomic + currentList.filter { it != path } + } + } + + 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 Request", "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 + } +} \ 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 new file mode 100644 index 0000000000..78ebc36ff4 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/onion/http/HttpOnionTransport.kt @@ -0,0 +1,319 @@ +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 +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.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( + private val snodeDirectory: Lazy +) : OnionTransport { + + override suspend fun send( + path: List, + destination: OnionDestination, + payload: ByteArray, + version: Version + ): OnionResponse { + require(path.isNotEmpty()) { "Path must not be empty" } + + val guard = path.first() + + val built = try { + OnionBuilder.build(path, destination, payload, version) + } catch (t: Throwable) { + throw OnionError.Unknown(t) + } + + 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) { + 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) + throw mapPathHttpError(guard, httpEx) + } catch (t: Throwable) { + // TCP / DNS / TLS / timeout etc. reaching guard + throw OnionError.GuardUnreachable(guard, t) + } + + // We have an onion-level response from the guard; decrypt & interpret + return handleResponse( + rawResponse = responseBytes, + destinationSymmetricKey = built.destinationSymmetricKey, + destination = destination, + version = version + ) + } + + /** + * 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) + ?: (json?.get("message") as? String) + + 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)) { + val failedPk = message.removePrefix(prefix) + return OnionError.IntermediateNodeFailed( + reportingNode = node, + failedPublicKey = failedPk + ) + } + + return OnionError.PathError( + node = node, + status = ErrorStatus( + code = statusCode, + message = message, + body = null + ) + ) + } + + private fun handleResponse( + rawResponse: ByteArray, + destinationSymmetricKey: ByteArray, + 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 -> handleV2V3Response(rawResponse, destinationSymmetricKey, destination) + } + } + + private fun handleV4Response( + response: ByteArray, + destinationSymmetricKey: ByteArray, + destination: OnionDestination + ): OnionResponse { + try { + if (response.size <= AESGCM.ivSize) { + throw OnionError.InvalidResponse() + } + + val decrypted = AESGCM.decrypt(response, symmetricKey = destinationSymmetricKey) + + if (decrypted.isEmpty() || decrypted[0] != 'l'.code.toByte()) { + throw OnionError.InvalidResponse() + } + + val infoSepIdx = decrypted.indexOfFirst { it == ':'.code.toByte() } + if (infoSepIdx <= 1) throw OnionError.InvalidResponse() + + val infoLenSlice = decrypted.slice(1 until infoSepIdx) + val infoLength = infoLenSlice.toByteArray().toString(Charsets.US_ASCII).toIntOrNull() + ?: throw OnionError.InvalidResponse() + + val infoStartIndex = "l$infoLength".length + 1 + val infoEndIndex = infoStartIndex + infoLength + if (infoEndIndex > decrypted.size) throw OnionError.InvalidResponse() + + val infoSlice = decrypted.view(infoStartIndex until infoEndIndex) + val responseInfo = JsonUtil.fromJson(infoSlice, Map::class.java) as Map<*, *> + + 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 = + if (destination is OnionDestination.ServerDestination && statusCode == 400) { + decrypted.getBody(infoLength, infoEndIndex) + } else null + + throw + OnionError.DestinationError( + status = ErrorStatus( + code = statusCode, + message = responseInfo["message"]?.toString(), + body = bodySlice + ), + destination = destination + ) + } + + val responseBody = decrypted.getBody(infoLength, infoEndIndex) + return if (responseBody.isEmpty()) { + OnionResponse(info = responseInfo, body = null) + } else { + OnionResponse(info = responseInfo, body = responseBody) + } + } catch (e: OnionError) { + throw e + } catch (t: Throwable) { + throw OnionError.InvalidResponse(t) + } + } + + private fun handleV2V3Response( + rawResponse: ByteArray, + destinationSymmetricKey: ByteArray, + destination:OnionDestination + ): 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( + status = ErrorStatus( + code = statusCode, + message = extractMessage(errorMap), + body = JsonUtil.toJson(errorMap).toByteArray().view(), + ), + destination = destination + ) + } + + 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 + + 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 new file mode 100644 index 0000000000..fb3303d57f --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/snode/SnodeDirectory.kt @@ -0,0 +1,219 @@ +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.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.prettifiedDescription +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SnodeDirectory @Inject constructor( + private val storage: SnodePoolStorage, + private val prefs: TextSecurePreferences, + @ManagerScope private val scope: CoroutineScope, +) : OnAppStartupComponent { + + companion object { + private const val MINIMUM_SNODE_POOL_COUNT = 12 + 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 (prefs.getEnvironment()) { + 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 } + } + } + + 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) { + storage.setSnodePool(newPool) + } + + /** + * Ensure the snode pool is populated to at least [minCount] elements. + * + * - 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 the seed node returns an empty list or parsing fails. + */ + suspend fun ensurePoolPopulated( + minCount: Int = MINIMUM_SNODE_POOL_COUNT + ): Set { + val current = getSnodePool() + if (current.size >= minCount) { + return current + } + + val target = seedNodePool.random() + Log.d("SnodeDirectory", "Populating snode pool using seed node: $target") + + val url = "$target/json_rpc" + val responseBytes = HTTP.execute( + HTTP.Verb.POST, + url = url, + parameters = getRandomSnodeParams, + useSeedNodeConnection = true + ) + + 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 + } + + /** + * Returns a random snode from the generic snode pool. + * + * 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() + return pool.secureRandom() + } + + 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 + ) + } + + suspend fun getGuardSnodes( + existingGuards: Set, + targetGuardCount: Int + ): Set { + if (existingGuards.size >= targetGuardCount) return existingGuards + + var unused = ensurePoolPopulated().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 Request", "Selected guard snode: $candidate") + candidate + } + + 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) + } + + 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 new file mode 100644 index 0000000000..f76f6f9b17 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/snode/SnodeStorage.kt @@ -0,0 +1,74 @@ +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 +import javax.inject.Singleton + +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) + + fun getForkInfo(): ForkInfo + fun setForkInfo(forkInfo: ForkInfo) +} + + +@Singleton +class DbSnodePathStorage @Inject constructor(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() + } +} + +@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 + } + + override fun setSwarm(publicKey: String, swarm: Set) { + db.setSwarm(publicKey, swarm) + } +} + +@Singleton +class DbSnodePoolStorage @Inject constructor(private val db: LokiAPIDatabase) : SnodePoolStorage { + override fun getSnodePool(): Set { + return db.getSnodePool() + } + + 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 new file mode 100644 index 0000000000..7601e09a31 --- /dev/null +++ b/app/src/main/java/org/session/libsession/network/snode/SwarmDirectory.kt @@ -0,0 +1,110 @@ +package org.session.libsession.network.snode + +import dagger.Lazy +import org.session.libsession.network.SnodeClient +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 + +@Singleton +class SwarmDirectory @Inject constructor( + private val storage: SwarmStorage, + private val snodeDirectory: SnodeDirectory, + private val snodeClient: Lazy, +) { + private val minimumSwarmSize: Int = 3 + + suspend fun getSwarm(publicKey: String): Set { + val cached = storage.getSwarm(publicKey) + if (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 response = snodeClient.get().send( + method = Snode.Method.GetSwarm, + parameters = params, + snode = randomSnode, + ) + + return parseSnodes(response).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) + 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 -> + snodeDirectory.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() + } + + /** + * 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 updateSwarmFromResponse(publicKey: String, body: ByteArraySlice?): Boolean { + if (body == null || body.isEmpty()) return false + + val json: Map<*, *> = try { + JsonUtil.fromJson(body, 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 + } +} 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/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/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/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 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 { 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/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() 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/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/configs/ConfigToDatabaseSync.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt index 4a023de195..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,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.network.SnodeClient +import org.session.libsession.network.SnodeClock import org.session.libsession.snode.OwnedSwarmAuth -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.Address.Companion.toAddress @@ -89,6 +89,7 @@ class ConfigToDatabaseSync @Inject constructor( private val messageNotifier: MessageNotifier, private val recipientSettingsDatabase: RecipientSettingsDatabase, private val avatarCacheCleaner: AvatarCacheCleaner, + private val snodeClient: SnodeClient, @param:ManagerScope private val scope: CoroutineScope, ) : AuthAwareComponent { override suspend fun doWhileLoggedIn(loggedInState: LoggedInState) { @@ -314,11 +315,11 @@ 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() - if (cleanedHashes.isNotEmpty()) SnodeAPI.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 7543dac353..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,14 +20,15 @@ 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.snode.OnionRequestAPI +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 +import org.session.libsession.network.snode.SwarmDirectory import org.session.libsession.snode.OwnedSwarmAuth -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.SnodeClock 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 @@ -66,6 +67,9 @@ class ConfigUploader @Inject constructor( private val storageProtocol: StorageProtocol, private val clock: SnodeClock, private val networkConnectivity: NetworkConnectivity, + private val swarmDirectory: SwarmDirectory, + private val snodeClient: SnodeClient, + private val pathManager: PathManager ) : AuthAwareComponent { /** * A flow that only emits when @@ -78,7 +82,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() } @@ -199,16 +203,16 @@ class ConfigUploader @Inject constructor( Log.d(TAG, "Pushing group configs") - val snode = SnodeAPI.getSingleTargetSnode(groupId.hexString).await() + 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. // Keys must be pushed first because the other configs depend on it. val keysPushResult = keysPush?.let { push -> - SnodeAPI.sendBatchRequest( + snodeClient.sendBatchRequest( snode = snode, publicKey = auth.accountId.hexString, - request = SnodeAPI.buildAuthenticatedStoreBatchInfo( + request = snodeClient.buildAuthenticatedStoreBatchInfo( Namespace.GROUP_KEYS(), SnodeMessage( auth.accountId.hexString, @@ -284,10 +288,10 @@ class ConfigUploader @Inject constructor( push.messages .map { message -> async { - SnodeAPI.sendBatchRequest( + snodeClient.sendBatchRequest( snode = snode, publicKey = auth.accountId.hexString, - request = SnodeAPI.buildAuthenticatedStoreBatchInfo( + request = snodeClient.buildAuthenticatedStoreBatchInfo( namespace, SnodeMessage( auth.accountId.hexString, @@ -305,10 +309,10 @@ class ConfigUploader @Inject constructor( } if (push.obsoleteHashes.isNotEmpty()) { - SnodeAPI.sendBatchRequest( + snodeClient.sendBatchRequest( snode = snode, publicKey = auth.accountId.hexString, - request = SnodeAPI.buildAuthenticatedDeleteBatchInfo(auth, push.obsoleteHashes) + request = snodeClient.buildAuthenticatedDeleteBatchInfo(auth, push.obsoleteHashes) ) } @@ -344,7 +348,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/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 95b2350001..7612731bda 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,8 +97,7 @@ 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.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 @@ -266,6 +265,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 @@ -1791,7 +1791,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() @@ -1859,7 +1859,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() @@ -2154,7 +2154,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 @@ -2213,7 +2213,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 @@ -2799,7 +2799,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) 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/RecipientRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt index 755389882c..69ede234b5 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/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/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index 21425bdcfa..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.snode.SnodeClock +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/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/dependencies/ConfigFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt index fa66df6431..56cbd27f86 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt @@ -18,7 +18,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 38adfdf1eb..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,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.network.SnodeClient +import org.session.libsession.network.SnodeClock +import org.session.libsession.network.snode.SwarmDirectory import org.session.libsession.snode.OwnedSwarmAuth -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.SnodeClock 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 snodeClient: SnodeClient, + 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( + snodeClient.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 += snodeClient.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 = snodeClient.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) + snodeClient.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()) snodeClient.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( + snodeClient.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( + snodeClient.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( + snodeClient.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( + snodeClient.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( + snodeClient.deleteMessage( groupId.hexString, OwnedSwarmAuth.ofClosedGroup(groupId, adminKey), hashes @@ -1136,7 +1138,7 @@ class GroupManagerV2Impl @Inject constructor( } if (userMessageHashes.isNotEmpty()) { - SnodeAPI.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 f9a08c3468..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,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.snode.SnodeClock +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 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 snodeClient: SnodeClient, ) { 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( + snodeClient.sendBatchRequest( snode, groupId.hexString, - SnodeAPI.buildAuthenticatedRetrieveBatchRequest( + snodeClient.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( + snodeClient.sendBatchRequest( snode, groupId.hexString, - SnodeAPI.buildAuthenticatedAlterTtlBatchRequest( + snodeClient.buildAuthenticatedAlterTtlBatchRequest( messageHashes = configHashesToExtends.toList(), auth = groupAuth, newExpiry = clock.currentTimeMills() + 14.days.inWholeMilliseconds, @@ -284,10 +287,10 @@ class GroupPoller @AssistedInject constructor( ).orEmpty() - SnodeAPI.sendBatchRequest( + snodeClient.sendBatchRequest( snode = snode, publicKey = groupId.hexString, - request = SnodeAPI.buildAuthenticatedRetrieveBatchRequest( + request = snodeClient.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( + snodeClient.sendBatchRequest( snode = snode, publicKey = groupId.hexString, - request = SnodeAPI.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 c8bade5f28..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,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.network.SnodeClient +import org.session.libsession.network.SnodeClock +import org.session.libsession.network.snode.SwarmDirectory import org.session.libsession.snode.OwnedSwarmAuth -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.SnodeClock 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 snodeClient: SnodeClient, ) : 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( + snodeClient.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 += snodeClient.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 += snodeClient.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( + snodeClient.getBatchResponse( node, groupAccountId.hexString, batchCalls, 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 798385cfa7..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.snode.SnodeClock +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/home/startconversation/newmessage/NewMessageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt index 9e9b3b017c..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,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.SnodeClient 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: SnodeClient, ) : 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 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/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 04cfd04597..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,8 +8,8 @@ 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.snode.SnodeClock +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 import org.session.libsession.utilities.isGroupOrCommunity @@ -38,6 +38,7 @@ class MarkReadProcessor @Inject constructor( private val storage: StorageProtocol, private val snodeClock: SnodeClock, private val lokiMessageDatabase: LokiMessageDatabase, + private val snodeClient: SnodeClient ) { 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) -> + snodeClient.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/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..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,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.snode.SnodeClock +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 -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 serverClient: ServerClient ) { 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 = serverClient.send( 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/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/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/preferences/SettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt index 6aded95720..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,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.SnodeClient +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 snodeClient: SnodeClient ) : 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() + 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 @@ -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/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index d1711f0c11..c10ea35940 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 2e0f64debb..4efd89d4d4 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.libsession.utilities.withMutableUserConfigs import org.session.libsession.utilities.withUserConfigs 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 97a52d7844..d2fa3d76ed 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.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.auth.LoginStateRepository 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/ProStatusManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt index d4e9cd84ee..01e0184b40 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/pro/api/ProApiExecutor.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApiExecutor.kt index 6f28d3be8b..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,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.ServerClient 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 serverClient: ServerClient ) { @Serializable private data class RawProApiResponse( @@ -57,7 +57,7 @@ class ProApiExecutor @Inject constructor( ): ProApiResponse { val config = proConfigProvider.get() - val rawResp = sendOnionRequest( + val rawResp = serverClient.send( 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/repository/DefaultConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/DefaultConversationRepository.kt index 87ee280ef1..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,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.SnodeClient +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 snodeClient: SnodeClient ) : 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)) + snodeClient.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)) + snodeClient.deleteMessage(recipient.address, userAuth, listOf(serverHash)) } // send an UnsendRequest to user's swarm 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 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/tokenpage/TokenRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/tokenpage/TokenRepository.kt index 041e64a1cb..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,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.ServerClient 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 serverClient: ServerClient ): TokenRepository { private val TAG = "TokenRepository" @@ -89,11 +89,11 @@ class TokenRepositoryImpl @Inject constructor( var response: T? = null try { - val rawResponse = OnionRequestAPI.sendOnionRequest( + val rawResponse = serverClient.send( 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 } 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) } 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