From 2e14b0e94dac371fdd9a0a32740fa3845c60cb05 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 1 Oct 2024 10:40:48 +1000 Subject: [PATCH] More deletion logic Hndling unsend request retrieval as per figma docs --- .../attachments/DatabaseAttachmentProvider.kt | 7 +- .../conversation/v2/ConversationViewModel.kt | 88 +++++++++++++------ .../securesms/database/Storage.kt | 9 +- .../repository/ConversationRepository.kt | 78 ++++++++++------ .../v2/ConversationViewModelTest.kt | 24 +---- .../database/MessageDataProvider.kt | 2 +- .../libsession/database/StorageProtocol.kt | 2 + .../ReceivedMessageHandler.kt | 54 +++++++++--- .../utilities/recipients/MessageType.kt | 14 +++ 9 files changed, 179 insertions(+), 99 deletions(-) create mode 100644 libsession/src/main/java/org/session/libsession/utilities/recipients/MessageType.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt index 88fd235687..7d3c4abf81 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt @@ -199,7 +199,6 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) } override fun deleteMessages(messageIDs: List, threadId: Long, isSms: Boolean) { - val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase() else DatabaseComponent.get(context).mmsDatabase() @@ -216,10 +215,10 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) threadId?.let{ MessagingModuleConfiguration.shared.lastSentTimestampCache.delete(it, messages.map { it.timestamp }) } } - override fun markMessageAsDeleted(timestamp: Long, author: String, displayedMessage: String): Long? { + override fun markMessageAsDeleted(timestamp: Long, author: String, displayedMessage: String) { val database = DatabaseComponent.get(context).mmsSmsDatabase() val address = Address.fromSerialized(author) - val message = database.getMessageFor(timestamp, address) ?: return null + val message = database.getMessageFor(timestamp, address) ?: return markMessagesAsDeleted( messages = listOf(MarkAsDeletedMessage( @@ -229,8 +228,6 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) isSms = !message.isMms, displayedMessage = displayedMessage ) - - return message.id } override fun markMessagesAsDeleted( 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 70f439f425..3a9762f160 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 @@ -31,6 +31,8 @@ import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.recipients.MessageType +import org.session.libsession.utilities.recipients.getType import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.audio.AudioSlidePlayer @@ -210,24 +212,26 @@ class ConversationViewModel( viewModelScope.launch(Dispatchers.IO) { val allSentByCurrentUser = messages.all { it.isOutgoing } + + val conversationType = conversation.getType() + // hashes are required if wanting to delete messages from the 'storage server' - they are not required for communities - val canDeleteForEveryone = conversation.isCommunityRecipient || messages.all { + val canDeleteForEveryone = conversationType == MessageType.COMMUNITY || messages.all { lokiMessageDb.getMessageServerHash( it.id, it.isMms ) != null } // Determining is the current user is an admin will depend on the kind of conversation we are in - val isAdmin = when { - //todo GROUPS V2 add logic where code is commented to determine if user is an admin - CAREFUL in the current old code: - // isClosedGroup refers to the existing legacy groups. - // With the groupsV2 changes, isClosedGroup refers to groupsV2 and isLegacyClosedGroup is a new property to refer to old groups - + val isAdmin = when(conversationType) { // for Groups V2 - // conversation: check if it is a GroupsV2 conversation - then check if user is an admin + MessageType.GROUPS_V2 -> { + //todo GROUPS V2 add logic where code is commented to determine if user is an admin + false // FANCHAO - properly set up admin for groups v2 here + } // for legacy groups, check if the user created the group - conversation.isClosedGroupRecipient -> { //todo GROUPS V2 this property will change for groups v2. Check for legacyGroup here + MessageType.LEGACY_GROUP -> { // for legacy groups, we check if the current user is the one who created the group run { val localUserAddress = @@ -238,7 +242,7 @@ class ConversationViewModel( } // for communities the the `isUserModerator` field - conversation.isCommunityRecipient -> isUserCommunityManager() + MessageType.COMMUNITY -> isUserCommunityManager() // false in other cases else -> false @@ -250,7 +254,7 @@ class ConversationViewModel( // 3- Delete on device only - Used otherwise when { // the conversation is a note to self - conversation.isLocalNumber -> { + conversationType == MessageType.NOTE_TO_SELF -> { _dialogsState.update { it.copy(deleteAllDevices = DeleteForEveryoneDialogData( messages = messages, @@ -291,14 +295,6 @@ class ConversationViewModel( } } - /** - * This will delete these messages from the db - * Not to be confused with 'marking messages as deleted' - */ - fun deleteMessages(messages: Set, threadId: Long) { - repository.deleteMessages(messages, threadId) - } - /** * This will mark the messages as deleted, locally only. * Attachments and other related data will be removed from the db, @@ -306,7 +302,7 @@ class ConversationViewModel( * Instead they will appear as a special type of message * that says something like "This message was deleted" */ - private fun markAsDeletedLocally(messages: Set) { + fun markAsDeletedLocally(messages: Set) { // make sure to stop audio messages, if any messages.filterIsInstance() .mapNotNull { it.slideDeck.audioSlide } @@ -354,22 +350,18 @@ class ConversationViewModel( } private fun markAsDeletedForEveryoneNoteToSelf(data: DeleteForEveryoneDialogData){ + if(recipient == null) return showMessage(application.getString(R.string.errorUnknown)) + viewModelScope.launch(Dispatchers.IO) { // show a loading indicator _uiState.update { it.copy(showLoader = true) } // delete remotely try { - //todo DELETION need to delete remotely for note to self - repository.deleteCommunityMessagesRemotely(threadId, data.messages) + repository.deleteNoteToSelfMessagesRemotely(threadId, recipient!!, data.messages) - //todo DELETION send unsendRequest to own swarm - - // When this is done we simply need to remove the message locally - repository.markAsDeletedLocally( - messages = data.messages, - displayedMessage = application.getString(R.string.deleteMessageDeletedGlobally) - ) + // When this is done we simply need to remove the message locally (leave nothing behind) + repository.deleteMessages(messages = data.messages, threadId = threadId) // show confirmation toast withContext(Dispatchers.Main) { @@ -457,9 +449,47 @@ class ConversationViewModel( } private fun markAsDeletedForEveryoneLegacyGroup(messages: Set){ + if(recipient == null) return showMessage(application.getString(R.string.errorUnknown)) - } + viewModelScope.launch(Dispatchers.IO) { + // delete remotely + try { + repository.deleteLegacyGroupMessagesRemotely(recipient!!, messages) + + // When this is done we simply need to remove the message locally + repository.markAsDeletedLocally( + messages = messages, + displayedMessage = application.getString(R.string.deleteMessageDeletedGlobally) + ) + // show confirmation toast + withContext(Dispatchers.Main) { + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageDeleted, + messages.count(), + messages.count() + ), + Toast.LENGTH_SHORT + ).show() + } + } catch (e: Exception) { + Log.w("Loki", "FAILED TO delete messages ${messages} ") + // failed to delete - show a toast and get back on the modal + withContext(Dispatchers.Main) { + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageFailed, + messages.size, + messages.size + ), Toast.LENGTH_SHORT + ).show() + } + } + } + } private fun markAsDeletedForEveryoneGroupsV2(data: DeleteForEveryoneDialogData){ viewModelScope.launch(Dispatchers.IO) { 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 d7ad48b497..c2e75120fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.database import android.content.Context import android.net.Uri -import network.loki.messenger.R import java.security.MessageDigest import network.loki.messenger.libsession_util.ConfigBase import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN @@ -74,6 +73,8 @@ import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol.Co import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient.DisappearingState +import org.session.libsession.utilities.recipients.MessageType +import org.session.libsession.utilities.recipients.getType import org.session.libsignal.crypto.ecc.DjbECPrivateKey import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.ECKeyPair @@ -758,6 +759,12 @@ open class Storage( return database.getMessageFor(timestamp, address)?.run { getId() to isMms } } + override fun getMessageType(timestamp: Long, author: String): MessageType? { + val database = DatabaseComponent.get(context).mmsSmsDatabase() + val address = fromSerialized(author) + return database.getMessageFor(timestamp, address)?.individualRecipient?.getType() + } + override fun updateSentTimestamp( messageID: Long, isMms: Boolean, 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 8c5df6bbba..7fa68ad482 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -69,6 +69,16 @@ interface ConversationRepository { recipient: Recipient, messages: Set ) + suspend fun deleteNoteToSelfMessagesRemotely( + threadId: Long, + recipient: Recipient, + messages: Set + ) + suspend fun deleteLegacyGroupMessagesRemotely( + recipient: Recipient, + messages: Set + ) + fun buildUnsendRequest(recipient: Recipient, message: MessageRecord): UnsendRequest? suspend fun banUser(threadId: Long, recipient: Recipient): Result suspend fun banAndDeleteAll(threadId: Long, recipient: Recipient): Result @@ -270,6 +280,45 @@ class DefaultConversationRepository @Inject constructor( } } + override suspend fun deleteLegacyGroupMessagesRemotely( + recipient: Recipient, + messages: Set + ) { + if (recipient.isClosedGroupRecipient) { + val publicKey = recipient.address + + messages.forEach { message -> + // send an UnsendRequest to group's swarm + buildUnsendRequest(recipient, message)?.let { unsendRequest -> + MessageSender.send(unsendRequest, publicKey) + } + } + } + } + + override suspend fun deleteNoteToSelfMessagesRemotely( + threadId: Long, + recipient: Recipient, + messages: Set + ) { + // delete the messages remotely + val publicKey = recipient.address.serialize() + val userAddress: Address? = textSecurePreferences.getLocalNumber()?.let { Address.fromSerialized(it) } + + messages.forEach { message -> + // delete from swarm + messageDataProvider.getServerHashForMessage(message.id, message.isMms) + ?.let { serverHash -> + SnodeAPI.deleteMessage(publicKey, listOf(serverHash)) + } + + // send an UnsendRequest to user's swarm + buildUnsendRequest(recipient, message)?.let { unsendRequest -> + userAddress?.let { MessageSender.send(unsendRequest, it) } + } + } + } + /* override suspend fun markAsDeletedForEveryone( threadId: Long, recipient: Recipient, @@ -279,35 +328,6 @@ class DefaultConversationRepository @Inject constructor( MessageSender.send(unsendRequest, recipient.address) } - val openGroup = lokiThreadDb.getOpenGroupChat(threadId) - if (openGroup != null) { - val serverId = lokiMessageDb.getServerID(message.id, !message.isMms)?.let { messageServerID -> - OpenGroupApi.deleteMessage(messageServerID, openGroup.room, openGroup.server) - .success { - messageDataProvider.deleteMessage(message.id, !message.isMms) - continuation.resume(Result.success(Unit)) - }.fail { error -> - Log.w("TAG", "Call to OpenGroupApi.deleteForEveryone failed - attempting to resume..") - continuation.resume(Result.failure(error)) - } - } - - // If the server ID is null then this message is stuck in limbo (it has likely been - // deleted remotely but that deletion did not occur locally) - so we'll delete the - // message locally to clean up. - if (serverId == null) { - Log.w("ConversationRepository","Found community message without a server ID - deleting locally.") - - // Caution: The bool returned from `deleteMessage` is NOT "Was the message - // successfully deleted?" - it is "Was the thread itself also deleted because - // removing that message resulted in an empty thread?". - if (message.isMms) { - mmsDb.deleteMessage(message.id) - } else { - smsDb.deleteMessage(message.id) - } - } - } else // If this thread is NOT in a Community { messageDataProvider.deleteMessage(message.id, !message.isMms) diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt index f3a8392fb9..991acde792 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt @@ -34,7 +34,7 @@ class ConversationViewModelTest: BaseViewModelTest() { private lateinit var messageRecord: MessageRecord private val viewModel: ConversationViewModel by lazy { - ConversationViewModel(threadId, edKeyPair, repository, storage, mock()) + ConversationViewModel(threadId, edKeyPair, mock(), repository, storage, mock(), mock(), mock()) } @Before @@ -86,28 +86,6 @@ class ConversationViewModelTest: BaseViewModelTest() { verify(repository).setBlocked(recipient, false) } - @Test - fun `should delete locally`() { - val messages = mock>() - - viewModel.deleteMessages(messages, threadId) - - verify(repository).deleteMessages(messages, threadId) - } - - @Test - fun `should emit error message on failure to delete a message for everyone`() = runBlockingTest { - val message = mock() - val error = Throwable() - - whenever(repository.markAsDeletedForEveryone(anyLong(), any(), any())) - .thenReturn(Result.failure(error)) - - viewModel.markAsDeletedForEveryone(message) - - assertThat(viewModel.uiState.first().uiMessages.first().message, endsWith("$error")) - } - @Test fun `should emit error message on ban user failure`() = runBlockingTest { val error = Throwable() diff --git a/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt b/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt index 1b6dda5e99..8e6ed68993 100644 --- a/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt +++ b/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt @@ -24,7 +24,7 @@ interface MessageDataProvider { fun getMessageIDs(serverIDs: List, threadID: Long): Pair, List> fun deleteMessage(messageID: Long, isSms: Boolean) fun deleteMessages(messageIDs: List, threadId: Long, isSms: Boolean) - fun markMessageAsDeleted(timestamp: Long, author: String, displayedMessage: String): Long? + fun markMessageAsDeleted(timestamp: Long, author: String, displayedMessage: String) fun markMessagesAsDeleted(messages: List, isSms: Boolean, displayedMessage: String) fun getServerHashForMessage(messageID: Long, mms: Boolean): String? fun getDatabaseAttachment(attachmentId: Long): DatabaseAttachment? diff --git a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt index aac481776f..d81e0f5390 100644 --- a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -30,6 +30,7 @@ import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient.RecipientSettings +import org.session.libsession.utilities.recipients.MessageType import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.messages.SignalServiceAttachmentPointer import org.session.libsignal.messages.SignalServiceGroup @@ -115,6 +116,7 @@ interface StorageProtocol { fun persistAttachments(messageID: Long, attachments: List): List fun getAttachmentsForMessage(messageID: Long): List fun getMessageIdInDatabase(timestamp: Long, author: String): Pair? // TODO: This is a weird name + fun getMessageType(timestamp: Long, author: String): MessageType? fun updateSentTimestamp(messageID: Long, isMms: Boolean, openGroupSentTimestamp: Long, threadId: Long) fun markAsResyncing(timestamp: Long, author: String) fun markAsSyncing(timestamp: Long, author: String) 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 7454edf723..5fe3e416da 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 @@ -45,6 +45,7 @@ import org.session.libsession.utilities.ProfileKeyUtil import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.recipients.MessageType import org.session.libsignal.crypto.ecc.DjbECPrivateKey import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.ECKeyPair @@ -249,32 +250,63 @@ private fun handleConfigurationMessage(message: ConfigurationMessage) { } fun MessageReceiver.handleUnsendRequest(message: UnsendRequest): Long? { - //todo DELETION modify unsend request validation val userPublicKey = MessagingModuleConfiguration.shared.storage.getUserPublicKey() - if (message.sender != message.author && (message.sender != userPublicKey && userPublicKey != null)) { return null } - val context = MessagingModuleConfiguration.shared.context val storage = MessagingModuleConfiguration.shared.storage + val isLegacyGroupAdmin: Boolean = message.groupPublicKey?.let { key -> + var admin = false + val groupID = doubleEncodeGroupID(key) + val group = storage.getGroup(groupID) + if(group != null) { + admin = group.admins.map { it.toString() }.contains(message.sender) + } + admin + } ?: false + + // First we need to determine the validity of the UnsendRequest + // It is valid if: + val requestIsValid = message.sender == message.author || // the sender is the author of the message + message.author == userPublicKey || // the sender is the current user + isLegacyGroupAdmin // sender is an admin of legacy group + + if (!requestIsValid) { return null } + + val context = MessagingModuleConfiguration.shared.context val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider val timestamp = message.timestamp ?: return null val author = message.author ?: return null val (messageIdToDelete, mms) = storage.getMessageIdInDatabase(timestamp, author) ?: return null - messageDataProvider.getServerHashForMessage(messageIdToDelete, mms)?.let { 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 messageType = storage.getMessageType(timestamp, author) ?: return null + Log.d("", "*** message type: $messageType") + + // send a /delete rquest for 1on1 messages + if(messageType == MessageType.ONE_ON_ONE) { + messageDataProvider.getServerHashForMessage(messageIdToDelete, mms)?.let { 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( + + // the message is marked as deleted locally + // except for 'note to self' where the message is completely deleted + if(messageType == MessageType.NOTE_TO_SELF){ + messageDataProvider.deleteMessage(messageIdToDelete, !mms) + } else { + messageDataProvider.markMessageAsDeleted( timestamp = timestamp, author = author, - displayedMessage = context.getString(R.string.deleteMessageDeletedGlobally) + displayedMessage = context.getString(R.string.deleteMessageDeletedGlobally) ) + } + if (!messageDataProvider.isOutgoingMessage(timestamp)) { SSKEnvironment.shared.notificationManager.updateNotification(context) } - return deletedMessageId + return messageIdToDelete } fun handleMessageRequestResponse(message: MessageRequestResponse) { diff --git a/libsession/src/main/java/org/session/libsession/utilities/recipients/MessageType.kt b/libsession/src/main/java/org/session/libsession/utilities/recipients/MessageType.kt new file mode 100644 index 0000000000..de9f894e48 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/recipients/MessageType.kt @@ -0,0 +1,14 @@ +package org.session.libsession.utilities.recipients + +enum class MessageType { + ONE_ON_ONE, LEGACY_GROUP, GROUPS_V2, NOTE_TO_SELF, COMMUNITY +} + +fun Recipient.getType(): MessageType = + when{ + isCommunityRecipient -> MessageType.COMMUNITY + isLocalNumber -> MessageType.NOTE_TO_SELF + isClosedGroupRecipient -> MessageType.LEGACY_GROUP //todo GROUPS V2 this property will change for groups v2. Check for legacyGroup here + //isXXXXX -> RecipientType.GROUPS_V2 //todo GROUPS V2 this property will change for groups v2. Check for legacyGroup here + else -> MessageType.ONE_ON_ONE + } \ No newline at end of file