From 792a4ca74d922a14aeed2e95f16dfc6b3afb9ff8 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 30 Sep 2024 12:46:51 +1000 Subject: [PATCH] Deletion logic rework Moving away from promises --- .../conversation/v2/ConversationViewModel.kt | 151 ++++++++++-------- .../repository/ConversationRepository.kt | 4 +- .../ReceivedMessageHandler.kt | 11 +- .../org/session/libsession/snode/SnodeAPI.kt | 99 +++++++----- .../exceptions/NonRetryableException.kt | 3 + .../session/libsignal/utilities/Retrying.kt | 23 +++ 6 files changed, 180 insertions(+), 111 deletions(-) create mode 100644 libsignal/src/main/java/org/session/libsignal/exceptions/NonRetryableException.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 6e367dca01..70f439f425 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.messages.ExpirationConfiguration @@ -371,26 +372,30 @@ class ConversationViewModel( ) // show confirmation toast - Toast.makeText( - application, - application.resources.getQuantityString( - R.plurals.deleteMessageDeleted, - data.messages.count(), - data.messages.count() - ), - Toast.LENGTH_SHORT - ).show() + withContext(Dispatchers.Main) { + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageDeleted, + data.messages.count(), + data.messages.count() + ), + Toast.LENGTH_SHORT + ).show() + } } catch (e: Exception) { Log.w("Loki", "FAILED TO delete messages ${data.messages} ") // failed to delete - show a toast and get back on the modal - Toast.makeText( - application, - application.resources.getQuantityString( - R.plurals.deleteMessageFailed, - data.messages.size, - data.messages.size - ), Toast.LENGTH_SHORT - ).show() + withContext(Dispatchers.Main) { + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageFailed, + data.messages.size, + data.messages.size + ), Toast.LENGTH_SHORT + ).show() + } _dialogsState.update { it.copy(deleteEveryone = data) } } @@ -418,26 +423,30 @@ class ConversationViewModel( ) // show confirmation toast - Toast.makeText( - application, - application.resources.getQuantityString( - R.plurals.deleteMessageDeleted, - data.messages.count(), - data.messages.count() - ), - Toast.LENGTH_SHORT - ).show() + withContext(Dispatchers.Main) { + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageDeleted, + data.messages.count(), + data.messages.count() + ), + Toast.LENGTH_SHORT + ).show() + } } catch (e: Exception) { Log.w("Loki", "FAILED TO delete messages ${data.messages} ") // failed to delete - show a toast and get back on the modal - Toast.makeText( - application, - application.resources.getQuantityString( - R.plurals.deleteMessageFailed, - data.messages.size, - data.messages.size - ), Toast.LENGTH_SHORT - ).show() + withContext(Dispatchers.Main) { + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageFailed, + data.messages.size, + data.messages.size + ), Toast.LENGTH_SHORT + ).show() + } _dialogsState.update { it.copy(deleteEveryone = data) } } @@ -470,25 +479,29 @@ class ConversationViewModel( ) // show confirmation toast - Toast.makeText( - application, - application.resources.getQuantityString( - R.plurals.deleteMessageDeleted, - data.messages.count(), data.messages.count() - ), - Toast.LENGTH_SHORT - ).show() + withContext(Dispatchers.Main) { + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageDeleted, + data.messages.count(), data.messages.count() + ), + Toast.LENGTH_SHORT + ).show() + } } catch (e: Exception) { Log.w("Loki", "FAILED TO delete messages ${data.messages} ") // failed to delete - show a toast and get back on the modal - Toast.makeText( - application, - application.resources.getQuantityString( - R.plurals.deleteMessageFailed, - data.messages.size, - data.messages.size - ), Toast.LENGTH_SHORT - ).show() + withContext(Dispatchers.Main) { + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageFailed, + data.messages.size, + data.messages.size + ), Toast.LENGTH_SHORT + ).show() + } _dialogsState.update { it.copy(deleteAllDevices = data) } } @@ -514,26 +527,30 @@ class ConversationViewModel( ) // show confirmation toast - Toast.makeText( - application, - application.resources.getQuantityString( - R.plurals.deleteMessageDeleted, - data.messages.count(), - data.messages.count() - ), - Toast.LENGTH_SHORT - ).show() + withContext(Dispatchers.Main) { + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageDeleted, + data.messages.count(), + data.messages.count() + ), + Toast.LENGTH_SHORT + ).show() + } } catch (e: Exception) { Log.w("Loki", "FAILED TO delete messages ${data.messages} ") // failed to delete - show a toast and get back on the modal - Toast.makeText( - application, - application.resources.getQuantityString( - R.plurals.deleteMessageFailed, - data.messages.size, - data.messages.size - ), Toast.LENGTH_SHORT - ).show() + withContext(Dispatchers.Main) { + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageFailed, + data.messages.size, + data.messages.size + ), Toast.LENGTH_SHORT + ).show() + } _dialogsState.update { it.copy(deleteEveryone = data) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index 924ff01906..8c5df6bbba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -233,7 +233,7 @@ class DefaultConversationRepository @Inject constructor( messages: Set ) { val community = lokiThreadDb.getOpenGroupChat(threadId) ?: - throw Error("Not a Community") + throw Exception("Not a Community") messages.forEach { message -> lokiMessageDb.getServerID(message.id, !message.isMms)?.let { messageServerID -> @@ -255,7 +255,7 @@ class DefaultConversationRepository @Inject constructor( // delete from swarm messageDataProvider.getServerHashForMessage(message.id, message.isMms) ?.let { serverHash -> - SnodeAPI.deleteMessage(publicKey, listOf(serverHash)).await() + SnodeAPI.deleteMessage(publicKey, listOf(serverHash)) } // send an UnsendRequest to user's swarm diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt index eca11bf472..7454edf723 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt @@ -1,6 +1,9 @@ package org.session.libsession.messaging.sending_receiving import android.text.TextUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.R import org.session.libsession.avatars.AvatarHelper @@ -256,12 +259,16 @@ fun MessageReceiver.handleUnsendRequest(message: UnsendRequest): Long? { val author = message.author ?: return null val (messageIdToDelete, mms) = storage.getMessageIdInDatabase(timestamp, author) ?: return null messageDataProvider.getServerHashForMessage(messageIdToDelete, mms)?.let { serverHash -> - SnodeAPI.deleteMessage(author, listOf(serverHash)) + GlobalScope.launch(Dispatchers.IO) { // using GlobalScope as we are slowly migrating to coroutines but we can't migrate everything at once + try { + SnodeAPI.deleteMessage(author, listOf(serverHash)) + }catch (e: Exception){} + } } val deletedMessageId = messageDataProvider.markMessageAsDeleted( timestamp = timestamp, author = author, - displayedMessage = context.getString(R.string.deleteMessageDeletedLocally) + displayedMessage = context.getString(R.string.deleteMessageDeletedGlobally) ) if (!messageDataProvider.isOutgoingMessage(timestamp)) { SSKEnvironment.shared.notificationManager.updateNotification(context) diff --git a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt b/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt index 414394dff7..f178120c43 100644 --- a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt +++ b/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt @@ -9,6 +9,7 @@ import com.goterl.lazysodium.interfaces.SecretBox import com.goterl.lazysodium.interfaces.Sign import com.goterl.lazysodium.utils.Key import com.goterl.lazysodium.utils.KeyPair +import kotlinx.coroutines.coroutineScope import nl.komponents.kovenant.Promise import nl.komponents.kovenant.all import nl.komponents.kovenant.functional.bind @@ -18,6 +19,7 @@ import nl.komponents.kovenant.unwrap import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.utilities.MessageWrapper import org.session.libsession.messaging.utilities.SodiumUtilities.sodium +import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.buildMutableMap import org.session.libsession.utilities.mapValuesNotNull import org.session.libsession.utilities.toByteArray @@ -35,6 +37,7 @@ import org.session.libsignal.utilities.Namespace import org.session.libsignal.utilities.Snode import org.session.libsignal.utilities.prettifiedDescription import org.session.libsignal.utilities.retryIfNeeded +import org.session.libsignal.utilities.retryWithUniformInterval import java.util.Locale import kotlin.collections.component1 import kotlin.collections.component2 @@ -557,50 +560,66 @@ object SnodeAPI { } } - fun deleteMessage(publicKey: String, serverHashes: List): Promise, Exception> = - retryIfNeeded(maxRetryCount) { - val userED25519KeyPair = getUserED25519KeyPair() ?: return@retryIfNeeded Promise.ofFail(Error.NoKeyPair) - val userPublicKey = getUserPublicKey() ?: return@retryIfNeeded Promise.ofFail(Error.NoKeyPair) - getSingleTargetSnode(publicKey).bind { snode -> - retryIfNeeded(maxRetryCount) { - val verificationData = sequenceOf(Snode.Method.DeleteMessage.rawValue).plus(serverHashes).toByteArray() - val deleteMessageParams = buildMap { - this["pubkey"] = userPublicKey - this["pubkey_ed25519"] = userED25519KeyPair.publicKey.asHexString - this["messages"] = serverHashes - this["signature"] = signAndEncode(verificationData, userED25519KeyPair) + suspend fun deleteMessage(publicKey: String, serverHashes: List) { + retryWithUniformInterval { + val userED25519KeyPair = + getUserED25519KeyPair() ?: throw (Error.NoKeyPair) + val userPublicKey = + getUserPublicKey() ?: throw (Error.NoKeyPair) + val snode = getSingleTargetSnode(publicKey).await() + val verificationData = + sequenceOf(Snode.Method.DeleteMessage.rawValue).plus(serverHashes).toByteArray() + val deleteMessageParams = buildMap { + this["pubkey"] = userPublicKey + this["pubkey_ed25519"] = userED25519KeyPair.publicKey.asHexString + this["messages"] = serverHashes + this["signature"] = signAndEncode(verificationData, userED25519KeyPair) + } + val rawResponse = invoke( + Snode.Method.DeleteMessage, + snode, + deleteMessageParams, + 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["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 + val snodePublicKey = Key.fromHexString(hexSnodePublicKey) + // The signature looks like ( PUBKEY_HEX || RMSG[0] || ... || RMSG[N] || DMSG[0] || ... || DMSG[M] ) + val message = sequenceOf(userPublicKey).plus(serverHashes).plus(hashes) + .toByteArray() + sodium.cryptoSignVerifyDetached( + Base64.decode(signature), + message, + message.size, + snodePublicKey.asBytes + ) } - invoke(Snode.Method.DeleteMessage, snode, deleteMessageParams, publicKey).map { rawResponse -> - val swarms = rawResponse["swarm"] as? Map ?: return@map mapOf() - swarms.mapValuesNotNull { (hexSnodePublicKey, rawJSON) -> - (rawJSON as? Map)?.let { json -> - val isFailed = json["failed"] as? Boolean ?: false - val statusCode = json["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 - val snodePublicKey = Key.fromHexString(hexSnodePublicKey) - // The signature looks like ( PUBKEY_HEX || RMSG[0] || ... || RMSG[N] || DMSG[0] || ... || DMSG[M] ) - val message = sequenceOf(userPublicKey).plus(serverHashes).plus(hashes).toByteArray() - sodium.cryptoSignVerifyDetached( - Base64.decode(signature), - message, - message.size, - snodePublicKey.asBytes - ) - } - } - } - }.fail { e -> Log.e("Loki", "Failed to delete messages", e) } } } + + // 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 = diff --git a/libsignal/src/main/java/org/session/libsignal/exceptions/NonRetryableException.kt b/libsignal/src/main/java/org/session/libsignal/exceptions/NonRetryableException.kt new file mode 100644 index 0000000000..959b24d56c --- /dev/null +++ b/libsignal/src/main/java/org/session/libsignal/exceptions/NonRetryableException.kt @@ -0,0 +1,3 @@ +package org.session.libsignal.exceptions + +class NonRetryableException(message: String? = null, cause: Throwable? = null): RuntimeException(message, cause) \ No newline at end of file diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/Retrying.kt b/libsignal/src/main/java/org/session/libsignal/utilities/Retrying.kt index 9f1b1a3e82..2664a5898f 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/Retrying.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/Retrying.kt @@ -1,8 +1,11 @@ package org.session.libsignal.utilities +import kotlinx.coroutines.delay import nl.komponents.kovenant.Promise import nl.komponents.kovenant.deferred +import org.session.libsignal.exceptions.NonRetryableException import java.util.* +import kotlin.coroutines.cancellation.CancellationException fun > retryIfNeeded(maxRetryCount: Int, retryInterval: Long = 1000L, body: () -> T): Promise { var retryCount = 0 @@ -28,3 +31,23 @@ fun > retryIfNeeded(maxRetryCount: Int, retryInterv retryIfNeeded() return deferred.promise } + +suspend fun retryWithUniformInterval(maxRetryCount: Int = 3, retryIntervalMills: Long = 1000L, body: suspend () -> T): T { + var retryCount = 0 + while (true) { + try { + return body() + } catch (e: CancellationException) { + throw e + } catch (e: NonRetryableException) { + throw e + } catch (e: Exception) { + if (retryCount == maxRetryCount) { + throw e + } else { + retryCount += 1 + delay(retryIntervalMills) + } + } + } +}