Handle message deletion

This commit is contained in:
SessionHero01
2024-10-30 11:40:19 +11:00
parent b06aee7a20
commit 22b4479019
11 changed files with 157 additions and 68 deletions

View File

@@ -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;

View File

@@ -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<String>,
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<MarkAsDeletedMessage>()
val smsMessages = mutableListOf<MarkAsDeletedMessage>()
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)

View File

@@ -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<Long> getAllMessageIdsFromSenderInThread(long threadId, String serializedAuthor) {
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + MmsSmsColumns.ADDRESS + " = \"" + serializedAuthor + "\"";
Set<Long> identifiedMessages = new HashSet<Long>();
// 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;

View File

@@ -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

View File

@@ -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
))
}
}

View File

@@ -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,

View File

@@ -0,0 +1,274 @@
package org.thoughtcrime.securesms.groups.handler
import android.content.Context
import android.os.SystemClock
import com.google.protobuf.ByteString
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
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.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
/**
* This handler is responsible for processing pending group member removals.
*
* 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
fun start() {
require(job == null) { "Already started" }
job = GlobalScope.launch {
while (true) {
// Make sure we have a local number before we start processing
textSecurePreferences.watchLocalNumber().first { it != null }
val processStartedAt = SystemClock.uptimeMillis()
try {
processPendingMemberRemoval()
} catch (e: Exception) {
Log.e(TAG, "Error processing pending member removal", e)
}
configFactory.configUpdateNotifications.firstOrNull()
// Make sure we don't process too often. As some of the config changes don't apply
// to us, but we have no way to tell if it does or not. The safest way is to process
// everytime any config changes, with a minimum interval.
val delayMills =
MIN_PROCESS_INTERVAL_MILLS - (SystemClock.uptimeMillis() - processStartedAt)
if (delayMills > 0) {
delay(delayMills)
}
}
}
}
private suspend fun processPendingMemberRemoval() {
configFactory.withUserConfigs { it.userGroups.allClosedGroupInfo() }
.asSequence()
.filter { it.hasAdminKey() }
.forEach { group ->
processPendingRemovalsForGroup(group.groupAccountId, group.adminKey!!)
}
}
private suspend fun processPendingRemovalsForGroup(
groupAccountId: AccountId,
adminKey: ByteArray
) {
val groupAuth = OwnedSwarmAuth.ofClosedGroup(groupAccountId, adminKey)
val (pendingRemovals, batchCalls) = configFactory.withGroupConfigs(groupAccountId) { configs ->
val pendingRemovals = configs.groupMembers.all().filter { it.removed }
if (pendingRemovals.isEmpty()) {
// Skip if there are no pending removals
return@withGroupConfigs pendingRemovals to emptyList()
}
Log.d(TAG, "Processing ${pendingRemovals.size} pending removals for group")
// Perform a sequential call to group snode to:
// 1. Revoke the member's sub key (by adding the key to a "revoked list" under the hood)
// 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<SnodeAPI.SnodeBatchRequestInfo>(3)
// 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(
groupAdminAuth = groupAuth,
subAccountTokens = pendingRemovals.map {
configs.groupKeys.getSubAccountToken(AccountId(it.sessionId))
}
)
) { "Fail to create a revoke request" }
// 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
),
auth = groupAuth,
)
// Call No 3. Conditionally send the `GroupUpdateDeleteMemberContent`
if (pendingRemovals.any { it.shouldRemoveMessages }) {
calls += SnodeAPI.buildAuthenticatedStoreBatchInfo(
namespace = Namespace.CLOSED_GROUP_MESSAGES(),
message = buildDeleteGroupMemberContentMessage(
adminKey = adminKey,
groupAccountId = groupAccountId.hexString,
memberSessionIDs = pendingRemovals
.asSequence()
.filter { it.shouldRemoveMessages }
.map { it.sessionId },
),
auth = groupAuth,
)
}
pendingRemovals to (calls as List<SnodeAPI.SnodeBatchRequestInfo>)
}
if (pendingRemovals.isEmpty() || batchCalls.isEmpty()) {
return
}
val node = SnodeAPI.getSingleTargetSnode(groupAccountId.hexString).await()
val response =
SnodeAPI.getBatchResponse(node, groupAccountId.hexString, batchCalls, sequence = true)
val firstError = response.results.firstOrNull { !it.isSuccessful }
check(firstError == null) {
"Error processing pending removals for group: code = ${firstError?.code}, body = ${firstError?.body}"
}
Log.d(TAG, "Essential steps for group removal are done")
// The essential part of the operation has been successful once we get to this point,
// now we can go ahead and update the configs
configFactory.withMutableGroupConfigs(groupAccountId) { configs ->
pendingRemovals.forEach(configs.groupMembers::erase)
configs.rekey()
}
configFactory.waitUntilGroupConfigsPushed(groupAccountId)
Log.d(TAG, "Group configs updated")
// Try to delete members' message. It's ok to fail as they will be re-tried in different
// cases (a.k.a the GroupUpdateDeleteMemberContent message handling) and could be by different admins.
val deletingMessagesForMembers = pendingRemovals.filter { it.shouldRemoveMessages }
if (deletingMessagesForMembers.isNotEmpty()) {
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<String>
): SnodeMessage {
val timestamp = clock.currentTimeMills()
return MessageSender.buildWrappedMessageToSnode(
destination = Destination.ClosedGroup(groupAccountId),
message = GroupUpdated(
SignalServiceProtos.DataMessage.GroupUpdateMessage.newBuilder()
.setDeleteMemberContent(
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
)
}
private fun buildGroupKickMessage(
groupAccountId: String,
pendingRemovals: List<GroupMember>,
keys: ReadableGroupKeysConfig,
adminKey: ByteArray
) = SnodeMessage(
recipient = groupAccountId,
data = Base64.encodeBytes(
Sodium.encryptForMultipleSimple(
messages = Array(pendingRemovals.size) {
AccountId(pendingRemovals[it].sessionId).pubKeyBytes
.plus(keys.currentGeneration().toString().toByteArray())
},
recipients = Array(pendingRemovals.size) {
AccountId(pendingRemovals[it].sessionId).pubKeyBytes
},
ed25519SecretKey = adminKey,
domain = Sodium.KICKED_DOMAIN
)
),
ttl = SnodeMessage.DEFAULT_TTL,
timestamp = clock.currentTimeMills()
)
}