diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index e623f38c9e..2d902cf042 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -44,7 +44,7 @@ import org.session.libsession.messaging.MessagingModuleConfiguration; import org.thoughtcrime.securesms.configs.ConfigToDatabaseSync; import org.thoughtcrime.securesms.configs.ConfigUploader; import org.session.libsession.messaging.groups.GroupManagerV2; -import org.session.libsession.messaging.groups.RemoveGroupMemberHandler; +import org.thoughtcrime.securesms.groups.handler.RemoveGroupMemberHandler; import org.session.libsession.messaging.notifications.TokenFetcher; import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier; import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2; @@ -85,10 +85,8 @@ import org.thoughtcrime.securesms.logging.AndroidLogger; import org.thoughtcrime.securesms.logging.PersistentLogger; import org.thoughtcrime.securesms.logging.UncaughtExceptionLogger; import org.thoughtcrime.securesms.notifications.BackgroundPollWorker; -import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier; import org.thoughtcrime.securesms.notifications.PushRegistrationHandler; import org.thoughtcrime.securesms.notifications.NotificationChannels; -import org.thoughtcrime.securesms.notifications.OptimizedMessageNotifier; import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.service.ExpiringMessageManager; import org.thoughtcrime.securesms.service.KeyCachingService; 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 06e344a239..6d1ff33d6a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt @@ -7,7 +7,6 @@ import org.greenrobot.eventbus.EventBus import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.messages.MarkAsDeletedMessage -import org.session.libsession.messaging.messages.control.UnsendRequest 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.AttachmentState @@ -249,6 +248,51 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) } } + override fun markMessagesAsDeleted( + threadId: Long, + serverHashes: List, + displayedMessage: String + ) { + val sendersForHashes = DatabaseComponent.get(context).lokiMessageDatabase() + .getSendersForHashes(threadId, serverHashes.toSet()) + + val smsMessages = sendersForHashes.asSequence() + .filter { it.isSms } + .map { msg -> MarkAsDeletedMessage(messageId = msg.messageId, isOutgoing = msg.isOutgoing) } + .toList() + + val mmsMessages = sendersForHashes.asSequence() + .filter { !it.isSms } + .map { msg -> MarkAsDeletedMessage(messageId = msg.messageId, isOutgoing = msg.isOutgoing) } + .toList() + + markMessagesAsDeleted(smsMessages, isSms = true, displayedMessage) + markMessagesAsDeleted(mmsMessages, isSms = false, displayedMessage) + } + + override fun markUserMessagesAsDeleted( + threadId: Long, + until: Long, + sender: String, + displayedMessage: String + ) { + val mmsMessages = mutableListOf() + val smsMessages = mutableListOf() + + DatabaseComponent.get(context).mmsSmsDatabase().getUserMessages(threadId, sender) + .filter { it.timestamp <= until } + .forEach { record -> + if (record.isMms) { + mmsMessages.add(MarkAsDeletedMessage(record.id, record.isOutgoing)) + } else { + smsMessages.add(MarkAsDeletedMessage(record.id, record.isOutgoing)) + } + } + + markMessagesAsDeleted(smsMessages, isSms = true, displayedMessage) + markMessagesAsDeleted(mmsMessages, isSms = false, displayedMessage) + } + override fun getServerHashForMessage(messageID: Long, mms: Boolean): String? = DatabaseComponent.get(context).lokiMessageDatabase().getMessageServerHash(messageID, mms) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index 7ee5f19ae8..3ff600eefc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -310,25 +310,6 @@ public class MmsSmsDatabase extends Database { return identifiedMessages; } - // Version of the above `getAllMessageRecordsFromSenderInThread` method that returns the message - // Ids rather than the set of MessageRecords - currently unused by potentially useful in the future. - public Set getAllMessageIdsFromSenderInThread(long threadId, String serializedAuthor) { - String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + MmsSmsColumns.ADDRESS + " = \"" + serializedAuthor + "\""; - - Set identifiedMessages = new HashSet(); - - // Try everything with resources so that they auto-close on end of scope - try (Cursor cursor = queryTables(PROJECTION, selection, null, null)) { - try (MmsSmsDatabase.Reader reader = readerFor(cursor)) { - MessageRecord messageRecord; - while ((messageRecord = reader.getNext()) != null) { - identifiedMessages.add(messageRecord.id); - } - } - } - return identifiedMessages; - } - public long getLastOutgoingTimestamp(long threadId) { String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt index c955984cea..b40bff5c87 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt @@ -30,7 +30,6 @@ import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.utilities.ConfigUpdateNotification import org.session.libsignal.utilities.AccountId -import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.dependencies.ConfigFactory const val MAX_GROUP_NAME_LENGTH = 100 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 a9df8d6cba..516eb0089e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext +import network.loki.messenger.R import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE import network.loki.messenger.libsession_util.util.Conversation import network.loki.messenger.libsession_util.util.GroupInfo @@ -939,6 +940,7 @@ class GroupManagerV2Impl @Inject constructor( override suspend fun handleDeleteMemberContent( groupId: AccountId, deleteMemberContent: GroupUpdateDeleteMemberContentMessage, + timestamp: Long, sender: AccountId, senderIsVerifiedAdmin: Boolean, ): Unit = withContext(dispatcher) { @@ -951,24 +953,31 @@ class GroupManagerV2Impl @Inject constructor( val memberIds = deleteMemberContent.memberSessionIdsList if (hashes.isNotEmpty()) { - if (senderIsVerifiedAdmin) { - // We'll delete everything the admin says - storage.deleteMessagesByHash(threadId, hashes) - } else if (storage.ensureMessageHashesAreSender( + // If the sender is a verified admin, or the sender is the actual sender of the messages, + // we can mark them as deleted locally. + if (senderIsVerifiedAdmin || + storage.ensureMessageHashesAreSender( hashes.toSet(), sender.hexString, groupId.hexString + )) { + // We'll delete everything the admin says + messageDataProvider.markMessagesAsDeleted( + threadId = threadId, + serverHashes = hashes, + displayedMessage = application.getString( + R.string.deleteMessageDeletedGlobally + ) ) - ) { - // For deleting message by hashes, we'll likely only need to mark - // them as deleted - storage.deleteMessagesByHash(threadId, hashes) } } + // To be able to delete a user's messages, the sender must be a verified admin if (memberIds.isNotEmpty() && senderIsVerifiedAdmin) { for (member in memberIds) { - storage.deleteMessagesByUser(threadId, member) + messageDataProvider.markUserMessagesAsDeleted(threadId, timestamp, member, application.getString( + R.string.deleteMessageDeletedGlobally + )) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt index c159178935..29f8b60829 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt @@ -303,6 +303,27 @@ private fun ConfirmRemovingMemberDialog( groupName: String, ) { val context = LocalContext.current + val buttons = buildList { + this += DialogButtonModel( + text = GetString(R.string.remove), + color = LocalColors.current.danger, + onClick = { onConfirmed(member.accountId, false) } + ) + + if (BuildConfig.DEBUG) { + this += DialogButtonModel( + text = GetString("Remove with messages"), + color = LocalColors.current.danger, + onClick = { onConfirmed(member.accountId, true) } + ) + } + + this += DialogButtonModel( + text = GetString(R.string.cancel), + onClick = onDismissRequest, + ) + } + AlertDialog( onDismissRequest = onDismissRequest, text = Phrase.from(context, R.string.groupRemoveDescription) @@ -311,17 +332,7 @@ private fun ConfirmRemovingMemberDialog( .format() .toString(), title = stringResource(R.string.remove), - buttons = listOf( - DialogButtonModel( - text = GetString(R.string.remove), - color = LocalColors.current.danger, - onClick = { onConfirmed(member.accountId, false) } - ), - DialogButtonModel( - text = GetString(R.string.cancel), - onClick = onDismissRequest, - ) - ) + buttons = buttons ) } @@ -346,7 +357,7 @@ private fun MemberOptionsDialog( ) } - if (false && member.canPromote) { + if (BuildConfig.DEBUG && member.canPromote) { this += BottomOptionsDialogItem( title = context.getString(R.string.adminPromoteToAdmin), iconRes = R.drawable.ic_profile_default, diff --git a/libsession/src/main/java/org/session/libsession/messaging/groups/RemoveGroupMemberHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt similarity index 74% rename from libsession/src/main/java/org/session/libsession/messaging/groups/RemoveGroupMemberHandler.kt rename to app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt index 601448a2f8..e54259f664 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/groups/RemoveGroupMemberHandler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt @@ -1,37 +1,45 @@ -package org.session.libsession.messaging.groups +package org.thoughtcrime.securesms.groups.handler +import android.content.Context import android.os.SystemClock -import kotlinx.coroutines.CoroutineScope +import com.google.protobuf.ByteString +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job -import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch +import network.loki.messenger.R import network.loki.messenger.libsession_util.ReadableGroupKeysConfig import network.loki.messenger.libsession_util.util.GroupMember import network.loki.messenger.libsession_util.util.Sodium +import org.session.libsession.database.MessageDataProvider +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupManagerV2 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.messaging.utilities.SodiumUtilities 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.TextSecurePreferences import org.session.libsession.utilities.waitUntilGroupConfigsPushed import org.session.libsignal.protos.SignalServiceProtos -import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMessage import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Namespace import javax.inject.Inject +import javax.inject.Singleton private const val TAG = "RemoveGroupMemberHandler" - private const val MIN_PROCESS_INTERVAL_MILLS = 1_000L /** @@ -39,10 +47,15 @@ private const val MIN_PROCESS_INTERVAL_MILLS = 1_000L * * It automatically does so by listening to the config updates changes and checking for any pending removals. */ +@Singleton class RemoveGroupMemberHandler @Inject constructor( + @ApplicationContext private val context: Context, private val configFactory: ConfigFactoryProtocol, private val textSecurePreferences: TextSecurePreferences, private val groupManager: GroupManagerV2, + private val clock: SnodeClock, + private val messageDataProvider: MessageDataProvider, + private val storage: StorageProtocol, ) { private var job: Job? = null @@ -121,7 +134,12 @@ class RemoveGroupMemberHandler @Inject constructor( // Call No 2. Send a "kicked" message to the revoked namespace calls += SnodeAPI.buildAuthenticatedStoreBatchInfo( namespace = Namespace.REVOKED_GROUP_MESSAGES(), - message = buildGroupKickMessage(groupAccountId.hexString, pendingRemovals, configs.groupKeys, adminKey), + message = buildGroupKickMessage( + groupAccountId.hexString, + pendingRemovals, + configs.groupKeys, + adminKey + ), auth = groupAuth, ) @@ -130,11 +148,12 @@ class RemoveGroupMemberHandler @Inject constructor( calls += SnodeAPI.buildAuthenticatedStoreBatchInfo( namespace = Namespace.CLOSED_GROUP_MESSAGES(), message = buildDeleteGroupMemberContentMessage( + adminKey = adminKey, groupAccountId = groupAccountId.hexString, memberSessionIDs = pendingRemovals .asSequence() .filter { it.shouldRemoveMessages } - .map { it.sessionId } + .map { it.sessionId }, ), auth = groupAuth, ) @@ -148,7 +167,8 @@ class RemoveGroupMemberHandler @Inject constructor( } val node = SnodeAPI.getSingleTargetSnode(groupAccountId.hexString).await() - val response = SnodeAPI.getBatchResponse(node, groupAccountId.hexString, batchCalls, sequence = true) + val response = + SnodeAPI.getBatchResponse(node, groupAccountId.hexString, batchCalls, sequence = true) val firstError = response.results.firstOrNull { !it.isSuccessful } check(firstError == null) { @@ -172,36 +192,58 @@ class RemoveGroupMemberHandler @Inject constructor( // cases (a.k.a the GroupUpdateDeleteMemberContent message handling) and could be by different admins. val deletingMessagesForMembers = pendingRemovals.filter { it.shouldRemoveMessages } if (deletingMessagesForMembers.isNotEmpty()) { - try { - groupManager.removeMemberMessages( - groupAccountId, - deletingMessagesForMembers.map { AccountId(it.sessionId) } - ) - } catch (e: Exception) { - Log.e(TAG, "Error deleting messages for removed members", e) + val threadId = storage.getThreadId(Address.fromSerialized(groupAccountId.hexString)) + if (threadId != null) { + val until = clock.currentTimeMills() + for (member in deletingMessagesForMembers) { + try { + messageDataProvider.markUserMessagesAsDeleted( + threadId = threadId, + until = until, + sender = member.sessionId, + displayedMessage = context.getString(R.string.deleteMessageDeletedGlobally) + ) + } catch (e: Exception) { + Log.e(TAG, "Error deleting messages for removed member", e) + } + } } } } private fun buildDeleteGroupMemberContentMessage( + adminKey: ByteArray, groupAccountId: String, memberSessionIDs: Sequence ): SnodeMessage { + val timestamp = clock.currentTimeMills() + return MessageSender.buildWrappedMessageToSnode( destination = Destination.ClosedGroup(groupAccountId), message = GroupUpdated( - GroupUpdateMessage.newBuilder() + SignalServiceProtos.DataMessage.GroupUpdateMessage.newBuilder() .setDeleteMemberContent( - SignalServiceProtos.DataMessage.GroupUpdateDeleteMemberContentMessage - .newBuilder() + SignalServiceProtos.DataMessage.GroupUpdateDeleteMemberContentMessage.newBuilder() .apply { for (id in memberSessionIDs) { addMemberSessionIds(id) } } + .setAdminSignature( + ByteString.copyFrom( + SodiumUtilities.sign( + MessageAuthentication.buildDeleteMemberContentSignature( + memberIds = memberSessionIDs.map { AccountId(it) } + .toList(), + messageHashes = emptyList(), + timestamp = timestamp, + ), adminKey + ) + ) + ) ) .build() - ), + ).apply { sentTimestamp = timestamp }, isSyncMessage = false ) } @@ -227,7 +269,6 @@ class RemoveGroupMemberHandler @Inject constructor( ) ), ttl = SnodeMessage.DEFAULT_TTL, - timestamp = SnodeAPI.nowWithOffset + timestamp = clock.currentTimeMills() ) -} - +} \ No newline at end of file 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 230e23e52c..e0af1a0e30 100644 --- a/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt +++ b/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt @@ -26,6 +26,8 @@ interface MessageDataProvider { fun deleteMessages(messageIDs: List, threadId: Long, isSms: Boolean) fun markMessageAsDeleted(timestamp: Long, author: String, displayedMessage: String) fun markMessagesAsDeleted(messages: List, isSms: Boolean, displayedMessage: String) + fun markMessagesAsDeleted(threadId: Long, serverHashes: List, displayedMessage: String) + fun markUserMessagesAsDeleted(threadId: Long, until: Long, sender: String, displayedMessage: String) fun getServerHashForMessage(messageID: Long, mms: Boolean): String? fun getDatabaseAttachment(attachmentId: Long): DatabaseAttachment? fun getAttachmentStream(attachmentId: Long): SessionServiceAttachmentStream? diff --git a/libsession/src/main/java/org/session/libsession/database/ServerHashToMessageId.kt b/libsession/src/main/java/org/session/libsession/database/ServerHashToMessageId.kt index 9df00bedec..84fc643702 100644 --- a/libsession/src/main/java/org/session/libsession/database/ServerHashToMessageId.kt +++ b/libsession/src/main/java/org/session/libsession/database/ServerHashToMessageId.kt @@ -3,7 +3,9 @@ package org.session.libsession.database data class ServerHashToMessageId( val serverHash: String, /** - * This will only be the "sender" when the message is incoming. + * This will only be the "sender" when the message is incoming, when the message is outgoing, + * the value here could be the receiver of the message, it's better not to rely on opposite + * meaning of this field. */ val sender: String, val messageId: Long, diff --git a/libsession/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt b/libsession/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt index a593b0283f..e671699c62 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt @@ -93,6 +93,7 @@ interface GroupManagerV2 { suspend fun handleDeleteMemberContent( groupId: AccountId, deleteMemberContent: GroupUpdateDeleteMemberContentMessage, + timestamp: Long, sender: AccountId, senderIsVerifiedAdmin: Boolean, ) 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 739f67dfee..b8aa80b75f 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 @@ -651,6 +651,7 @@ private fun handleDeleteMemberContent(message: GroupUpdated, closedGroup: Accoun MessagingModuleConfiguration.shared.groupManagerV2.handleDeleteMemberContent( groupId = closedGroup, deleteMemberContent = deleteMemberContent, + timestamp = message.sentTimestamp!!, sender = AccountId(message.sender!!), senderIsVerifiedAdmin = hasValidAdminSignature )