diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/dependencies/InstrumentationApplicationDependencyProvider.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/dependencies/InstrumentationApplicationDependencyProvider.kt index ade9ad37b1..75e873b942 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/dependencies/InstrumentationApplicationDependencyProvider.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/dependencies/InstrumentationApplicationDependencyProvider.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.dependencies import android.app.Application +import io.mockk.spyk import okhttp3.ConnectionSpec import okhttp3.Response import okhttp3.WebSocket @@ -23,6 +24,9 @@ import org.thoughtcrime.securesms.testing.Get import org.thoughtcrime.securesms.testing.Verb import org.thoughtcrime.securesms.testing.runSync import org.thoughtcrime.securesms.testing.success +import org.whispersystems.signalservice.api.SignalServiceDataStore +import org.whispersystems.signalservice.api.SignalServiceMessageSender +import org.whispersystems.signalservice.api.SignalWebSocket import org.whispersystems.signalservice.api.push.TrustStore import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl import org.whispersystems.signalservice.internal.configuration.SignalCdsiUrl @@ -43,6 +47,7 @@ class InstrumentationApplicationDependencyProvider(val application: Application, private val uncensoredConfiguration: SignalServiceConfiguration private val serviceNetworkAccessMock: SignalServiceNetworkAccess private val recipientCache: LiveRecipientCache + private var signalServiceMessageSender: SignalServiceMessageSender? = null init { runSync { @@ -101,6 +106,17 @@ class InstrumentationApplicationDependencyProvider(val application: Application, return recipientCache } + override fun provideSignalServiceMessageSender( + signalWebSocket: SignalWebSocket, + protocolStore: SignalServiceDataStore, + signalServiceConfiguration: SignalServiceConfiguration + ): SignalServiceMessageSender { + if (signalServiceMessageSender == null) { + signalServiceMessageSender = spyk(objToCopy = default.provideSignalServiceMessageSender(signalWebSocket, protocolStore, signalServiceConfiguration)) + } + return signalServiceMessageSender!! + } + class MockWebSocket : WebSocketListener() { private val TAG = "MockWebSocket" diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/MultiDeviceDeleteSendSyncJobTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/MultiDeviceDeleteSendSyncJobTest.kt new file mode 100644 index 0000000000..8db24c269c --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/MultiDeviceDeleteSendSyncJobTest.kt @@ -0,0 +1,168 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.jobs + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.mockk.CapturingSlot +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkStatic +import okio.ByteString.Companion.toByteString +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.jobs.protos.DeleteSyncJobData +import org.thoughtcrime.securesms.messages.MessageHelper +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.testing.SignalActivityRule +import org.thoughtcrime.securesms.testing.assertIs +import org.thoughtcrime.securesms.testing.assertIsNotNull +import org.thoughtcrime.securesms.testing.assertIsSize +import org.thoughtcrime.securesms.util.MessageTableTestUtils +import org.thoughtcrime.securesms.util.TextSecurePreferences +import org.whispersystems.signalservice.api.messages.SendMessageResult +import org.whispersystems.signalservice.api.push.SignalServiceAddress +import org.whispersystems.signalservice.internal.push.Content +import java.util.Optional + +@RunWith(AndroidJUnit4::class) +class MultiDeviceDeleteSendSyncJobTest { + + @get:Rule + val harness = SignalActivityRule(createGroup = true) + + private lateinit var messageHelper: MessageHelper + + private lateinit var success: SendMessageResult + private lateinit var failure: SendMessageResult + private lateinit var content: CapturingSlot + + @Before + fun setUp() { + messageHelper = MessageHelper(harness) + + mockkStatic(TextSecurePreferences::class) + every { TextSecurePreferences.isMultiDevice(any()) } answers { + true + } + + success = SendMessageResult.success(SignalServiceAddress(Recipient.self().requireServiceId()), listOf(2), true, false, 0, Optional.empty()) + failure = SendMessageResult.networkFailure(SignalServiceAddress(Recipient.self().requireServiceId())) + content = slot() + } + + @After + fun tearDown() { + messageHelper.tearDown() + + unmockkStatic(TextSecurePreferences::class) + } + + @Test + fun messageDeletes() { + // GIVEN + val messages = mutableListOf() + messages += messageHelper.incomingText() + messages += messageHelper.incomingText() + messages += messageHelper.outgoingText() + + val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.alice)!! + val records: Set = MessageTableTestUtils.getMessages(threadId).toSet() + + // WHEN + every { ApplicationDependencies.getSignalServiceMessageSender().sendSyncMessage(capture(content), any(), any()) } returns success + + val job = MultiDeviceDeleteSendSyncJob.createMessageDeletes(records) + val result = job.run() + + // THEN + result.isSuccess assertIs true + assertDeleteSync(messageHelper.alice, messages) + } + + @Test + fun groupMessageDeletes() { + // GIVEN + val messages = mutableListOf() + messages += messageHelper.incomingText(destination = messageHelper.group.recipientId) + messages += messageHelper.incomingText(destination = messageHelper.group.recipientId) + messages += messageHelper.outgoingText(conversationId = messageHelper.group.recipientId) + + val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.group.recipientId)!! + val records: Set = MessageTableTestUtils.getMessages(threadId).toSet() + + // WHEN + every { ApplicationDependencies.getSignalServiceMessageSender().sendSyncMessage(capture(content), any(), any()) } returns success + + val job = MultiDeviceDeleteSendSyncJob.createMessageDeletes(records) + val result = job.run() + + // THEN + result.isSuccess assertIs true + assertDeleteSync(messageHelper.group.recipientId, messages) + } + + @Test + fun retryOfDeletes() { + // GIVEN + val alice = messageHelper.alice.toLong() + + // WHEN + every { ApplicationDependencies.getSignalServiceMessageSender().sendSyncMessage(capture(content), any(), any()) } returns failure + + val job = MultiDeviceDeleteSendSyncJob( + messages = listOf(DeleteSyncJobData.AddressableMessage(alice, 1, alice)), + threads = listOf(DeleteSyncJobData.ThreadDelete(alice, listOf(DeleteSyncJobData.AddressableMessage(alice, 1, alice)))), + localOnlyThreads = listOf(DeleteSyncJobData.ThreadDelete(alice)) + ) + + val result = job.run() + val data = DeleteSyncJobData.ADAPTER.decode(job.serialize()) + + // THEN + result.isRetry assertIs true + data.messageDeletes.assertIsSize(1) + data.threadDeletes.assertIsSize(1) + data.localOnlyThreadDeletes.assertIsSize(1) + } + + private fun assertDeleteSync(conversation: RecipientId, inputMessages: List) { + val messagesMap = inputMessages.associateBy { it.timestamp } + + val content = this.content.captured + + content.syncMessage?.padding.assertIsNotNull() + content.syncMessage?.deleteForMe.assertIsNotNull() + + val deleteForMe = content.syncMessage!!.deleteForMe!! + deleteForMe.messageDeletes.assertIsSize(1) + deleteForMe.conversationDeletes.assertIsSize(0) + deleteForMe.localOnlyConversationDeletes.assertIsSize(0) + + val messageDeletes = deleteForMe.messageDeletes[0] + val conversationRecipient = Recipient.resolved(conversation) + if (conversationRecipient.isGroup) { + messageDeletes.conversation!!.threadGroupId assertIs conversationRecipient.requireGroupId().decodedId.toByteString() + } else { + messageDeletes.conversation!!.threadAci assertIs conversationRecipient.requireAci().toString() + } + + messageDeletes + .messages + .forEach { delete -> + val messageData = messagesMap[delete.sentTimestamp] + delete.sentTimestamp assertIs messageData!!.timestamp + delete.authorAci assertIs Recipient.resolved(messageData.author).requireAci().toString() + } + } +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/messages/MessageHelper.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/messages/MessageHelper.kt new file mode 100644 index 0000000000..6c7d771588 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/messages/MessageHelper.kt @@ -0,0 +1,254 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.messages + +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkStatic +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.GroupsV2UpdateMessageConverter +import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context +import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription +import org.thoughtcrime.securesms.jobs.ThreadUpdateJob +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.mms.OutgoingMessage +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.testing.GroupTestingUtils +import org.thoughtcrime.securesms.testing.MessageContentFuzzer +import org.thoughtcrime.securesms.testing.SignalActivityRule +import java.util.UUID + +/** + * Makes inserting messages through the "normal" code paths simpler. Mostly focused on incoming messages. + */ +class MessageHelper(private val harness: SignalActivityRule, var startTime: Long = System.currentTimeMillis()) { + + val alice: RecipientId = harness.others[0] + val bob: RecipientId = harness.others[1] + val group: GroupTestingUtils.TestGroupInfo = harness.group!! + val processor: MessageContentProcessor = MessageContentProcessor(harness.context) + + init { + val threadIdSlot = slot() + mockkStatic(ThreadUpdateJob::class) + every { ThreadUpdateJob.enqueue(capture(threadIdSlot)) } answers { + SignalDatabase.threads.update(threadIdSlot.captured, false) + } + } + + fun tearDown() { + unmockkStatic(ThreadUpdateJob::class) + } + + fun incomingText(sender: RecipientId = alice, destination: RecipientId = harness.self.id): MessageData { + startTime = nextStartTime() + + val messageData = MessageData(author = sender, timestamp = startTime) + + processor.process( + envelope = MessageContentFuzzer.envelope(messageData.timestamp, serverGuid = messageData.serverGuid), + content = MessageContentFuzzer.fuzzTextMessage( + sentTimestamp = messageData.timestamp, + groupContextV2 = if (destination == group.recipientId) group.groupV2Context else null, + allowExpireTimeChanges = false + ), + metadata = MessageContentFuzzer.envelopeMetadata( + source = sender, + destination = harness.self.id, + groupId = if (destination == group.recipientId) group.groupId else null + ), + serverDeliveredTimestamp = messageData.timestamp + 10 + ) + + return messageData + } + + fun outgoingText(conversationId: RecipientId = alice, successfulSend: Boolean = true, updateMessage: (OutgoingMessage.() -> OutgoingMessage)? = null): MessageData { + startTime = nextStartTime() + + val messageData = MessageData(author = harness.self.id, timestamp = startTime) + val threadRecipient = Recipient.resolved(conversationId) + + val message = OutgoingMessage( + threadRecipient = threadRecipient, + body = MessageContentFuzzer.string(), + sentTimeMillis = messageData.timestamp, + isUrgent = true, + isSecure = true + ).apply { updateMessage?.invoke(this) } + + val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(threadRecipient) + val messageId = SignalDatabase.messages.insertMessageOutbox(message, threadId, false, null) + + if (successfulSend) { + SignalDatabase.messages.markAsSent(messageId, true) + } + + return messageData.copy(messageId = messageId) + } + + fun outgoingMessage(conversationId: RecipientId = alice, updateMessage: OutgoingMessage.() -> OutgoingMessage): MessageData { + startTime = nextStartTime() + + val messageData = MessageData(author = harness.self.id, timestamp = startTime) + val threadRecipient = Recipient.resolved(conversationId) + + val message = OutgoingMessage( + threadRecipient = threadRecipient, + sentTimeMillis = messageData.timestamp, + isUrgent = true, + isSecure = true + ).apply { updateMessage() } + + val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(threadRecipient) + val messageId = SignalDatabase.messages.insertMessageOutbox(message, threadId, false, null) + + return messageData.copy(messageId = messageId) + } + + fun outgoingGroupChange(): MessageData { + startTime = nextStartTime() + + val messageData = MessageData(author = harness.self.id, timestamp = startTime) + val groupRecipient = Recipient.resolved(group.recipientId) + val decryptedGroupV2Context = DecryptedGroupV2Context( + context = group.groupV2Context, + groupState = SignalDatabase.groups.getGroup(group.groupId).get().requireV2GroupProperties().decryptedGroup + ) + + val updateDescription = GV2UpdateDescription.Builder() + .gv2ChangeDescription(decryptedGroupV2Context) + .groupChangeUpdate(GroupsV2UpdateMessageConverter.translateDecryptedChange(SignalStore.account().getServiceIds(), decryptedGroupV2Context)) + .build() + + val outgoingMessage = OutgoingMessage.groupUpdateMessage(groupRecipient, updateDescription, startTime) + + val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient) + val messageId = SignalDatabase.messages.insertMessageOutbox(outgoingMessage, threadId, false, null) + SignalDatabase.messages.markAsSent(messageId, true) + + return messageData.copy(messageId = messageId) + } + + fun incomingMedia(sender: RecipientId = alice, destination: RecipientId = harness.self.id): MessageData { + startTime = nextStartTime() + + val messageData = MessageData(author = sender, timestamp = startTime) + + processor.process( + envelope = MessageContentFuzzer.envelope(messageData.timestamp, serverGuid = messageData.serverGuid), + content = MessageContentFuzzer.fuzzStickerMediaMessage( + sentTimestamp = messageData.timestamp, + groupContextV2 = if (destination == group.recipientId) group.groupV2Context else null + ), + metadata = MessageContentFuzzer.envelopeMetadata( + source = sender, + destination = harness.self.id, + groupId = if (destination == group.recipientId) group.groupId else null + ), + serverDeliveredTimestamp = messageData.timestamp + 10 + ) + + return messageData + } + + fun incomingEditText(targetTimestamp: Long = System.currentTimeMillis(), sender: RecipientId = alice, destination: RecipientId = harness.self.id): MessageData { + startTime = nextStartTime() + + val messageData = MessageData(author = sender, timestamp = startTime) + + processor.process( + envelope = MessageContentFuzzer.envelope(messageData.timestamp, serverGuid = messageData.serverGuid), + content = MessageContentFuzzer.editTextMessage( + targetTimestamp = targetTimestamp, + editedDataMessage = MessageContentFuzzer.fuzzTextMessage( + sentTimestamp = messageData.timestamp, + groupContextV2 = if (destination == group.recipientId) group.groupV2Context else null + ).dataMessage!! + ), + metadata = MessageContentFuzzer.envelopeMetadata( + source = sender, + destination = harness.self.id, + groupId = if (destination == group.recipientId) group.groupId else null + ), + serverDeliveredTimestamp = messageData.timestamp + 10 + ) + + return messageData + } + + fun syncReadMessage(vararg reads: Pair): MessageData { + startTime = nextStartTime() + val messageData = MessageData(timestamp = startTime) + + processor.process( + envelope = MessageContentFuzzer.envelope(messageData.timestamp, serverGuid = messageData.serverGuid), + content = MessageContentFuzzer.syncReadsMessage(reads.toList()), + metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, sourceDeviceId = 2), + serverDeliveredTimestamp = messageData.timestamp + 10 + ) + + return messageData + } + + fun syncDeleteForMeMessage(vararg deletes: MessageContentFuzzer.DeleteForMeSync): MessageData { + startTime = nextStartTime() + val messageData = MessageData(timestamp = startTime) + + processor.process( + envelope = MessageContentFuzzer.envelope(messageData.timestamp, serverGuid = messageData.serverGuid), + content = MessageContentFuzzer.syncDeleteForMeMessage(deletes.toList()), + metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, sourceDeviceId = 2), + serverDeliveredTimestamp = messageData.timestamp + 10 + ) + + return messageData + } + + fun syncDeleteForMeConversation(vararg deletes: MessageContentFuzzer.DeleteForMeSync): MessageData { + startTime = nextStartTime() + val messageData = MessageData(timestamp = startTime) + + processor.process( + envelope = MessageContentFuzzer.envelope(messageData.timestamp, serverGuid = messageData.serverGuid), + content = MessageContentFuzzer.syncDeleteForMeConversation(deletes.toList()), + metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, sourceDeviceId = 2), + serverDeliveredTimestamp = messageData.timestamp + 10 + ) + + return messageData + } + + fun syncDeleteForMeLocalOnlyConversation(vararg conversations: RecipientId): MessageData { + startTime = nextStartTime() + val messageData = MessageData(timestamp = startTime) + + processor.process( + envelope = MessageContentFuzzer.envelope(messageData.timestamp, serverGuid = messageData.serverGuid), + content = MessageContentFuzzer.syncDeleteForMeLocalOnlyConversation(conversations.toList()), + metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, sourceDeviceId = 2), + serverDeliveredTimestamp = messageData.timestamp + 10 + ) + + return messageData + } + + /** + * Get the next "sentTimestamp" for current + [nextMessageOffset]th message. Useful for early message processing and future message timestamps. + */ + fun nextStartTime(nextMessageOffset: Int = 1): Long { + return startTime + 1000 * nextMessageOffset + } + + data class MessageData( + val author: RecipientId = RecipientId.UNKNOWN, + val serverGuid: UUID = UUID.randomUUID(), + val timestamp: Long, + val messageId: Long = -1L + ) +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/messages/SyncMessageProcessorTest_readSyncs.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/messages/SyncMessageProcessorTest_readSyncs.kt index 36a752235c..67f0f2dfa4 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/messages/SyncMessageProcessorTest_readSyncs.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/messages/SyncMessageProcessorTest_readSyncs.kt @@ -6,23 +6,14 @@ package org.thoughtcrime.securesms.messages import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.mockk.every -import io.mockk.mockkStatic -import io.mockk.slot -import io.mockk.unmockkStatic import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.thoughtcrime.securesms.database.SignalDatabase -import org.thoughtcrime.securesms.jobs.ThreadUpdateJob -import org.thoughtcrime.securesms.recipients.RecipientId -import org.thoughtcrime.securesms.testing.GroupTestingUtils -import org.thoughtcrime.securesms.testing.MessageContentFuzzer import org.thoughtcrime.securesms.testing.SignalActivityRule import org.thoughtcrime.securesms.testing.assertIs -import java.util.UUID @Suppress("ClassName") @RunWith(AndroidJUnit4::class) @@ -31,43 +22,28 @@ class SyncMessageProcessorTest_readSyncs { @get:Rule val harness = SignalActivityRule(createGroup = true) - private lateinit var alice: RecipientId - private lateinit var bob: RecipientId - private lateinit var group: GroupTestingUtils.TestGroupInfo - private lateinit var processor: MessageContentProcessor + private lateinit var messageHelper: MessageHelper @Before fun setUp() { - alice = harness.others[0] - bob = harness.others[1] - group = harness.group!! - - processor = MessageContentProcessor(harness.context) - - val threadIdSlot = slot() - mockkStatic(ThreadUpdateJob::class) - every { ThreadUpdateJob.enqueue(capture(threadIdSlot)) } answers { - SignalDatabase.threads.update(threadIdSlot.captured, false) - } + messageHelper = MessageHelper(harness) } @After fun tearDown() { - unmockkStatic(ThreadUpdateJob::class) + messageHelper.tearDown() } @Test fun handleSynchronizeReadMessage() { - val messageHelper = MessageHelper() - val message1Timestamp = messageHelper.incomingText().timestamp val message2Timestamp = messageHelper.incomingText().timestamp - val threadId = SignalDatabase.threads.getThreadIdFor(alice)!! + val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.alice)!! var threadRecord = SignalDatabase.threads.getThreadRecord(threadId)!! threadRecord.unreadCount assertIs 2 - messageHelper.syncReadMessage(alice to message1Timestamp, alice to message2Timestamp) + messageHelper.syncReadMessage(messageHelper.alice to message1Timestamp, messageHelper.alice to message2Timestamp) threadRecord = SignalDatabase.threads.getThreadRecord(threadId)!! threadRecord.unreadCount assertIs 0 @@ -75,16 +51,14 @@ class SyncMessageProcessorTest_readSyncs { @Test fun handleSynchronizeReadMessageMissingTimestamp() { - val messageHelper = MessageHelper() - messageHelper.incomingText().timestamp val message2Timestamp = messageHelper.incomingText().timestamp - val threadId = SignalDatabase.threads.getThreadIdFor(alice)!! + val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.alice)!! var threadRecord = SignalDatabase.threads.getThreadRecord(threadId)!! threadRecord.unreadCount assertIs 2 - messageHelper.syncReadMessage(alice to message2Timestamp) + messageHelper.syncReadMessage(messageHelper.alice to message2Timestamp) threadRecord = SignalDatabase.threads.getThreadRecord(threadId)!! threadRecord.unreadCount assertIs 0 @@ -92,21 +66,19 @@ class SyncMessageProcessorTest_readSyncs { @Test fun handleSynchronizeReadWithEdits() { - val messageHelper = MessageHelper() - val message1Timestamp = messageHelper.incomingText().timestamp - messageHelper.syncReadMessage(alice to message1Timestamp) + messageHelper.syncReadMessage(messageHelper.alice to message1Timestamp) val editMessage1Timestamp1 = messageHelper.incomingEditText(message1Timestamp).timestamp val editMessage1Timestamp2 = messageHelper.incomingEditText(editMessage1Timestamp1).timestamp val message2Timestamp = messageHelper.incomingMedia().timestamp - val threadId = SignalDatabase.threads.getThreadIdFor(alice)!! + val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.alice)!! var threadRecord = SignalDatabase.threads.getThreadRecord(threadId)!! threadRecord.unreadCount assertIs 2 - messageHelper.syncReadMessage(alice to message2Timestamp, alice to editMessage1Timestamp1, alice to editMessage1Timestamp2) + messageHelper.syncReadMessage(messageHelper.alice to message2Timestamp, messageHelper.alice to editMessage1Timestamp1, messageHelper.alice to editMessage1Timestamp2) threadRecord = SignalDatabase.threads.getThreadRecord(threadId)!! threadRecord.unreadCount assertIs 0 @@ -114,112 +86,22 @@ class SyncMessageProcessorTest_readSyncs { @Test fun handleSynchronizeReadWithEditsInGroup() { - val messageHelper = MessageHelper() + val message1Timestamp = messageHelper.incomingText(sender = messageHelper.alice, destination = messageHelper.group.recipientId).timestamp - val message1Timestamp = messageHelper.incomingText(sender = alice, destination = group.recipientId).timestamp + messageHelper.syncReadMessage(messageHelper.alice to message1Timestamp) - messageHelper.syncReadMessage(alice to message1Timestamp) + val editMessage1Timestamp1 = messageHelper.incomingEditText(targetTimestamp = message1Timestamp, sender = messageHelper.alice, destination = messageHelper.group.recipientId).timestamp + val editMessage1Timestamp2 = messageHelper.incomingEditText(targetTimestamp = editMessage1Timestamp1, sender = messageHelper.alice, destination = messageHelper.group.recipientId).timestamp - val editMessage1Timestamp1 = messageHelper.incomingEditText(targetTimestamp = message1Timestamp, sender = alice, destination = group.recipientId).timestamp - val editMessage1Timestamp2 = messageHelper.incomingEditText(targetTimestamp = editMessage1Timestamp1, sender = alice, destination = group.recipientId).timestamp + val message2Timestamp = messageHelper.incomingMedia(sender = messageHelper.bob, destination = messageHelper.group.recipientId).timestamp - val message2Timestamp = messageHelper.incomingMedia(sender = bob, destination = group.recipientId).timestamp - - val threadId = SignalDatabase.threads.getThreadIdFor(group.recipientId)!! + val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.group.recipientId)!! var threadRecord = SignalDatabase.threads.getThreadRecord(threadId)!! threadRecord.unreadCount assertIs 2 - messageHelper.syncReadMessage(bob to message2Timestamp, alice to editMessage1Timestamp1, alice to editMessage1Timestamp2) + messageHelper.syncReadMessage(messageHelper.bob to message2Timestamp, messageHelper.alice to editMessage1Timestamp1, messageHelper.alice to editMessage1Timestamp2) threadRecord = SignalDatabase.threads.getThreadRecord(threadId)!! threadRecord.unreadCount assertIs 0 } - - private inner class MessageHelper(var startTime: Long = System.currentTimeMillis()) { - - fun incomingText(sender: RecipientId = alice, destination: RecipientId = harness.self.id): MessageData { - startTime += 1000 - - val messageData = MessageData(timestamp = startTime) - - processor.process( - envelope = MessageContentFuzzer.envelope(messageData.timestamp, serverGuid = messageData.serverGuid), - content = MessageContentFuzzer.fuzzTextMessage( - sentTimestamp = messageData.timestamp, - groupContextV2 = if (destination == group.recipientId) group.groupV2Context else null - ), - metadata = MessageContentFuzzer.envelopeMetadata( - source = sender, - destination = harness.self.id, - groupId = if (destination == group.recipientId) group.groupId else null - ), - serverDeliveredTimestamp = messageData.timestamp + 10 - ) - - return messageData - } - - fun incomingMedia(sender: RecipientId = alice, destination: RecipientId = harness.self.id): MessageData { - startTime += 1000 - - val messageData = MessageData(timestamp = startTime) - - processor.process( - envelope = MessageContentFuzzer.envelope(messageData.timestamp, serverGuid = messageData.serverGuid), - content = MessageContentFuzzer.fuzzStickerMediaMessage( - sentTimestamp = messageData.timestamp, - groupContextV2 = if (destination == group.recipientId) group.groupV2Context else null - ), - metadata = MessageContentFuzzer.envelopeMetadata( - source = sender, - destination = harness.self.id, - groupId = if (destination == group.recipientId) group.groupId else null - ), - serverDeliveredTimestamp = messageData.timestamp + 10 - ) - - return messageData - } - - fun incomingEditText(targetTimestamp: Long = System.currentTimeMillis(), sender: RecipientId = alice, destination: RecipientId = harness.self.id): MessageData { - startTime += 1000 - - val messageData = MessageData(timestamp = startTime) - - processor.process( - envelope = MessageContentFuzzer.envelope(messageData.timestamp, serverGuid = messageData.serverGuid), - content = MessageContentFuzzer.editTextMessage( - targetTimestamp = targetTimestamp, - editedDataMessage = MessageContentFuzzer.fuzzTextMessage( - sentTimestamp = messageData.timestamp, - groupContextV2 = if (destination == group.recipientId) group.groupV2Context else null - ).dataMessage!! - ), - metadata = MessageContentFuzzer.envelopeMetadata( - source = sender, - destination = harness.self.id, - groupId = if (destination == group.recipientId) group.groupId else null - ), - serverDeliveredTimestamp = messageData.timestamp + 10 - ) - - return messageData - } - - fun syncReadMessage(vararg reads: Pair): MessageData { - startTime += 1000 - val messageData = MessageData(timestamp = startTime) - - processor.process( - envelope = MessageContentFuzzer.envelope(messageData.timestamp, serverGuid = messageData.serverGuid), - content = MessageContentFuzzer.syncReadsMessage(reads.toList()), - metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, sourceDeviceId = 2), - serverDeliveredTimestamp = messageData.timestamp + 10 - ) - - return messageData - } - } - - private data class MessageData(val serverGuid: UUID = UUID.randomUUID(), val timestamp: Long) } diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/messages/SyncMessageProcessorTest_synchronizeDeleteForMe.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/messages/SyncMessageProcessorTest_synchronizeDeleteForMe.kt new file mode 100644 index 0000000000..1c10fb0e47 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/messages/SyncMessageProcessorTest_synchronizeDeleteForMe.kt @@ -0,0 +1,508 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.messages + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.hamcrest.Matchers.greaterThan +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.thoughtcrime.securesms.database.CallTable +import org.thoughtcrime.securesms.database.MessageTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchoverEvent +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.testing.MessageContentFuzzer.DeleteForMeSync +import org.thoughtcrime.securesms.testing.SignalActivityRule +import org.thoughtcrime.securesms.testing.assert +import org.thoughtcrime.securesms.testing.assertIs +import org.thoughtcrime.securesms.testing.assertIsNotNull +import org.thoughtcrime.securesms.util.FeatureFlags +import org.thoughtcrime.securesms.util.IdentityUtil + +@Suppress("ClassName") +@RunWith(AndroidJUnit4::class) +class SyncMessageProcessorTest_synchronizeDeleteForMe { + + @get:Rule + val harness = SignalActivityRule(createGroup = true) + + private lateinit var messageHelper: MessageHelper + + @Before + fun setUp() { + messageHelper = MessageHelper(harness) + + mockkStatic(FeatureFlags::class) + every { FeatureFlags.deleteSyncEnabled() } returns true + } + + @After + fun tearDown() { + messageHelper.tearDown() + + unmockkStatic(FeatureFlags::class) + } + + @Test + fun singleMessageDelete() { + // GIVEN + val message1Timestamp = messageHelper.incomingText().timestamp + messageHelper.incomingText() + + val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.alice)!! + var messageCount = SignalDatabase.messages.getMessageCountForThread(threadId) + messageCount assertIs 2 + + // WHEN + messageHelper.syncDeleteForMeMessage( + DeleteForMeSync(conversationId = messageHelper.alice, messageHelper.alice to message1Timestamp) + ) + + // THEN + messageCount = SignalDatabase.messages.getMessageCountForThread(threadId) + messageCount assertIs 1 + } + + @Test + fun singleOutgoingMessageDelete() { + // GIVEN + val message1Timestamp = messageHelper.outgoingText().timestamp + messageHelper.incomingText() + + val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.alice)!! + var messageCount = SignalDatabase.messages.getMessageCountForThread(threadId) + messageCount assertIs 2 + + // WHEN + messageHelper.syncDeleteForMeMessage( + DeleteForMeSync(conversationId = messageHelper.alice, harness.self.id to message1Timestamp) + ) + + // THEN + messageCount = SignalDatabase.messages.getMessageCountForThread(threadId) + messageCount assertIs 1 + } + + @Test + fun singleGroupMessageDelete() { + // GIVEN + val message1Timestamp = messageHelper.incomingText(sender = messageHelper.alice, destination = messageHelper.group.recipientId).timestamp + messageHelper.incomingText(sender = messageHelper.alice, destination = messageHelper.group.recipientId) + messageHelper.incomingText(sender = messageHelper.bob, destination = messageHelper.group.recipientId) + + val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.group.recipientId)!! + var messageCount = SignalDatabase.messages.getMessageCountForThread(threadId) + messageCount assertIs 3 + + // WHEN + messageHelper.syncDeleteForMeMessage( + DeleteForMeSync(conversationId = messageHelper.group.recipientId, messageHelper.alice to message1Timestamp) + ) + + // THEN + messageCount = SignalDatabase.messages.getMessageCountForThread(threadId) + messageCount assertIs 2 + } + + @Test + fun multipleGroupMessageDelete() { + // GIVEN + val message1Timestamp = messageHelper.incomingText(sender = messageHelper.alice, destination = messageHelper.group.recipientId).timestamp + messageHelper.incomingText(sender = messageHelper.alice, destination = messageHelper.group.recipientId) + val message3Timestamp = messageHelper.incomingText(sender = messageHelper.bob, destination = messageHelper.group.recipientId).timestamp + + val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.group.recipientId)!! + var messageCount = SignalDatabase.messages.getMessageCountForThread(threadId) + messageCount assertIs 3 + + // WHEN + messageHelper.syncDeleteForMeMessage( + DeleteForMeSync(conversationId = messageHelper.group.recipientId, messageHelper.alice to message1Timestamp, messageHelper.bob to message3Timestamp) + ) + + // THEN + messageCount = SignalDatabase.messages.getMessageCountForThread(threadId) + messageCount assertIs 1 + } + + @Test + fun allMessagesDelete() { + // GIVEN + val message1Timestamp = messageHelper.incomingText().timestamp + val message2Timestamp = messageHelper.incomingText().timestamp + + val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.alice)!! + var messageCount = SignalDatabase.messages.getMessageCountForThread(threadId) + messageCount assertIs 2 + + // WHEN + messageHelper.syncDeleteForMeMessage( + DeleteForMeSync(conversationId = messageHelper.alice, messageHelper.alice to message1Timestamp, messageHelper.alice to message2Timestamp) + ) + + // THEN + messageCount = SignalDatabase.messages.getMessageCountForThread(threadId) + messageCount assertIs 0 + + val threadRecord = SignalDatabase.threads.getThreadRecord(threadId) + threadRecord assertIs null + } + + @Test + fun earlyMessagesDelete() { + // GIVEN + messageHelper.incomingText().timestamp + + val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.alice)!! + var messageCount = SignalDatabase.messages.getMessageCountForThread(threadId) + messageCount assertIs 1 + + // WHEN + val nextTextMessageTimestamp = messageHelper.nextStartTime(2) + messageHelper.syncDeleteForMeMessage( + DeleteForMeSync(conversationId = messageHelper.alice, messageHelper.alice to nextTextMessageTimestamp) + ) + messageHelper.incomingText() + + // THEN + messageCount = SignalDatabase.messages.getMessageCountForThread(threadId) + messageCount assertIs 1 + } + + @Test + fun multipleConversationMessagesDelete() { + // GIVEN + messageHelper.incomingText(sender = messageHelper.alice) + val aliceMessage2 = messageHelper.incomingText(sender = messageHelper.alice).timestamp + + messageHelper.incomingText(sender = messageHelper.bob) + val bobMessage2 = messageHelper.incomingText(sender = messageHelper.bob).timestamp + + val aliceThreadId = SignalDatabase.threads.getThreadIdFor(messageHelper.alice)!! + var aliceMessageCount = SignalDatabase.messages.getMessageCountForThread(aliceThreadId) + aliceMessageCount assertIs 2 + + val bobThreadId = SignalDatabase.threads.getThreadIdFor(messageHelper.bob)!! + var bobMessageCount = SignalDatabase.messages.getMessageCountForThread(bobThreadId) + bobMessageCount assertIs 2 + + // WHEN + messageHelper.syncDeleteForMeMessage( + DeleteForMeSync(conversationId = messageHelper.alice, messageHelper.alice to aliceMessage2), + DeleteForMeSync(conversationId = messageHelper.bob, messageHelper.bob to bobMessage2) + ) + + // THEN + aliceMessageCount = SignalDatabase.messages.getMessageCountForThread(aliceThreadId) + aliceMessageCount assertIs 1 + + bobMessageCount = SignalDatabase.messages.getMessageCountForThread(bobThreadId) + bobMessageCount assertIs 1 + } + + @Test + fun singleConversationDelete() { + // GIVEN + val messages = mutableListOf() + + for (i in 0 until 10) { + messages += MessageTable.SyncMessageId(messageHelper.alice, messageHelper.incomingText().timestamp) + messages += MessageTable.SyncMessageId(harness.self.id, messageHelper.outgoingText().timestamp) + } + + val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.alice)!! + SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 20 + + // WHEN + messageHelper.syncDeleteForMeConversation( + DeleteForMeSync( + conversationId = messageHelper.alice, + messages = messages.takeLast(5).map { it.recipientId to it.timetamp }, + isFullDelete = true + ) + ) + + // THEN + SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 0 + SignalDatabase.threads.getThreadRecord(threadId) assertIs null + } + + @Test + fun singleConversationNoRecentsFoundDelete() { + // GIVEN + val messages = mutableListOf() + + for (i in 0 until 10) { + messages += MessageTable.SyncMessageId(messageHelper.alice, messageHelper.incomingText().timestamp) + messages += MessageTable.SyncMessageId(harness.self.id, messageHelper.outgoingText().timestamp) + } + + val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.alice)!! + SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 20 + + // WHEN + val randomFutureMessages = (1..5).map { + messageHelper.alice to messageHelper.nextStartTime(it) + } + + messageHelper.syncDeleteForMeConversation( + DeleteForMeSync(conversationId = messageHelper.alice, randomFutureMessages, true) + ) + + // THEN + SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 20 + SignalDatabase.threads.getThreadRecord(threadId).assertIsNotNull() + + harness.inMemoryLogger.flush() + harness.inMemoryLogger.entries().filter { it.message?.contains("Unable to find most recent received at timestamp") == true }.size assertIs 1 + } + + @Test + fun localOnlyRemainingAfterConversationDeleteWithFullDelete() { + // GIVEN + val messages = mutableListOf() + + for (i in 0 until 10) { + messages += MessageTable.SyncMessageId(messageHelper.alice, messageHelper.incomingText().timestamp) + messages += MessageTable.SyncMessageId(harness.self.id, messageHelper.outgoingText().timestamp) + } + + val alice = Recipient.resolved(messageHelper.alice) + IdentityUtil.markIdentityVerified(harness.context, alice, true, true) + SignalDatabase.messages.insertProfileNameChangeMessages(alice, "new name", "previous name") + SignalDatabase.calls.insertOneToOneCall(1, System.currentTimeMillis(), alice.id, CallTable.Type.AUDIO_CALL, CallTable.Direction.OUTGOING, CallTable.Event.ACCEPTED) + + val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.alice)!! + SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 23 + + // WHEN + messageHelper.syncDeleteForMeConversation( + DeleteForMeSync( + conversationId = messageHelper.alice, + messages = messages.takeLast(5).map { it.recipientId to it.timetamp }, + isFullDelete = true + ) + ) + + // THEN + SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 0 + SignalDatabase.threads.getThreadRecord(threadId) assertIs null + } + + @Test + fun localOnlyRemainingAfterConversationDeleteWithoutFullDelete() { + // GIVEN + val messages = mutableListOf() + + for (i in 0 until 10) { + messages += MessageTable.SyncMessageId(messageHelper.alice, messageHelper.incomingText().timestamp) + messages += MessageTable.SyncMessageId(harness.self.id, messageHelper.outgoingText().timestamp) + } + + val alice = Recipient.resolved(messageHelper.alice) + IdentityUtil.markIdentityVerified(harness.context, alice, true, true) + SignalDatabase.messages.insertProfileNameChangeMessages(alice, "new name", "previous name") + SignalDatabase.calls.insertOneToOneCall(1, System.currentTimeMillis(), alice.id, CallTable.Type.AUDIO_CALL, CallTable.Direction.OUTGOING, CallTable.Event.ACCEPTED) + + val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.alice)!! + SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 23 + + // WHEN + messageHelper.syncDeleteForMeConversation( + DeleteForMeSync( + conversationId = messageHelper.alice, + messages = messages.takeLast(5).map { it.recipientId to it.timetamp }, + isFullDelete = false + ) + ) + + // THEN + SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 3 + SignalDatabase.threads.getThreadRecord(threadId).assertIsNotNull() + } + + @Test + fun groupConversationDelete() { + // GIVEN + val messages = mutableListOf() + + for (i in 0 until 50) { + messages += when (i % 3) { + 1 -> MessageTable.SyncMessageId(messageHelper.alice, messageHelper.incomingText(sender = messageHelper.alice, destination = messageHelper.group.recipientId).timestamp) + 2 -> MessageTable.SyncMessageId(messageHelper.bob, messageHelper.incomingText(sender = messageHelper.bob, destination = messageHelper.group.recipientId).timestamp) + else -> MessageTable.SyncMessageId(harness.self.id, messageHelper.outgoingText(messageHelper.group.recipientId).timestamp) + } + } + + val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.group.recipientId)!! + + // WHEN + messageHelper.syncDeleteForMeConversation( + DeleteForMeSync( + conversationId = messageHelper.group.recipientId, + messages = messages.takeLast(5).map { it.recipientId to it.timetamp }, + isFullDelete = true + ) + ) + + // THEN + SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 0 + SignalDatabase.threads.getThreadRecord(threadId) assertIs null + } + + @Test + fun multipleConversationDelete() { + // GIVEN + val allMessages = mapOf>( + messageHelper.alice to mutableListOf(), + messageHelper.bob to mutableListOf() + ) + + allMessages.forEach { (conversation, messages) -> + for (i in 0 until 10) { + messages += MessageTable.SyncMessageId(conversation, messageHelper.incomingText(sender = conversation).timestamp) + messages += MessageTable.SyncMessageId(harness.self.id, messageHelper.outgoingText(conversationId = conversation).timestamp) + } + } + + val threadIds = allMessages.keys.map { SignalDatabase.threads.getThreadIdFor(it)!! } + threadIds.forEach { SignalDatabase.messages.getMessageCountForThread(it) assertIs 20 } + + // WHEN + messageHelper.syncDeleteForMeConversation( + DeleteForMeSync(conversationId = messageHelper.alice, allMessages[messageHelper.alice]!!.takeLast(5).map { it.recipientId to it.timetamp }, true), + DeleteForMeSync(conversationId = messageHelper.bob, allMessages[messageHelper.bob]!!.takeLast(5).map { it.recipientId to it.timetamp }, true) + ) + + // THEN + threadIds.forEach { + SignalDatabase.messages.getMessageCountForThread(it) assertIs 0 + SignalDatabase.threads.getThreadRecord(it) assertIs null + } + } + + @Test + fun singleLocalOnlyConversation() { + // GIVEN + val alice = Recipient.resolved(messageHelper.alice) + + // Insert placeholder message to prevent early thread update deletes + val oneToOnePlaceHolderMessage = messageHelper.outgoingText().messageId + + val aliceThreadId = SignalDatabase.threads.getOrCreateThreadIdFor(messageHelper.alice, isGroup = false) + + IdentityUtil.markIdentityVerified(harness.context, alice, true, false) + SignalDatabase.calls.insertOneToOneCall(1, System.currentTimeMillis(), alice.id, CallTable.Type.AUDIO_CALL, CallTable.Direction.OUTGOING, CallTable.Event.ACCEPTED) + SignalDatabase.messages.insertProfileNameChangeMessages(alice, "new name", "previous name") + SignalDatabase.messages.markAsSentFailed(messageHelper.outgoingText().messageId) + + // Cleanup and confirm setup + SignalDatabase.messages.deleteMessage(messageId = oneToOnePlaceHolderMessage, threadId = aliceThreadId, notify = false, updateThread = false) + SignalDatabase.messages.getMessageCountForThread(aliceThreadId) assert greaterThan(0) + + // WHEN + messageHelper.syncDeleteForMeLocalOnlyConversation(messageHelper.alice) + + // THEN + SignalDatabase.messages.getMessageCountForThread(aliceThreadId) assertIs 0 + SignalDatabase.threads.getThreadRecord(aliceThreadId) assertIs null + } + + @Test + fun multipleLocalOnlyConversation() { + // GIVEN + val alice = Recipient.resolved(messageHelper.alice) + + // Insert placeholder messages in group and alice thread to prevent early thread update deletes + val groupPlaceholderMessage = messageHelper.outgoingText(conversationId = messageHelper.group.recipientId).messageId + val oneToOnePlaceHolderMessage = messageHelper.outgoingText().messageId + + val aliceThreadId = SignalDatabase.threads.getOrCreateThreadIdFor(messageHelper.alice, isGroup = false) + val groupThreadId = SignalDatabase.threads.getOrCreateThreadIdFor(messageHelper.group.recipientId, isGroup = true) + + // Identity changes + IdentityUtil.markIdentityVerified(harness.context, alice, true, true) + IdentityUtil.markIdentityVerified(harness.context, alice, false, true) + IdentityUtil.markIdentityVerified(harness.context, alice, true, false) + IdentityUtil.markIdentityVerified(harness.context, alice, false, false) + + IdentityUtil.markIdentityUpdate(harness.context, alice.id) + + // Calls + SignalDatabase.calls.insertOneToOneCall(1, System.currentTimeMillis(), alice.id, CallTable.Type.AUDIO_CALL, CallTable.Direction.OUTGOING, CallTable.Event.ACCEPTED) + SignalDatabase.calls.insertOneToOneCall(2, System.currentTimeMillis(), alice.id, CallTable.Type.VIDEO_CALL, CallTable.Direction.INCOMING, CallTable.Event.MISSED) + SignalDatabase.calls.insertOneToOneCall(3, System.currentTimeMillis(), alice.id, CallTable.Type.AUDIO_CALL, CallTable.Direction.INCOMING, CallTable.Event.MISSED_NOTIFICATION_PROFILE) + + SignalDatabase.calls.insertAcceptedGroupCall(4, messageHelper.group.recipientId, CallTable.Direction.INCOMING, System.currentTimeMillis()) + SignalDatabase.calls.insertDeclinedGroupCall(5, messageHelper.group.recipientId, System.currentTimeMillis()) + + // Detected changes + SignalDatabase.messages.insertProfileNameChangeMessages(alice, "new name", "previous name") + SignalDatabase.messages.insertLearnedProfileNameChangeMessage(alice, "previous display name") + SignalDatabase.messages.insertNumberChangeMessages(alice.id) + SignalDatabase.messages.insertSmsExportMessage(alice.id, SignalDatabase.threads.getThreadIdFor(messageHelper.alice)!!) + SignalDatabase.messages.insertSessionSwitchoverEvent(alice.id, aliceThreadId, SessionSwitchoverEvent()) + + // Sent failed + SignalDatabase.messages.markAsSending(messageHelper.outgoingText().messageId) + SignalDatabase.messages.markAsSentFailed(messageHelper.outgoingText().messageId) + messageHelper.outgoingText().let { + SignalDatabase.messages.markAsSending(it.messageId) + SignalDatabase.messages.markAsRateLimited(it.messageId) + } + + // Group change + messageHelper.outgoingGroupChange() + + // Cleanup and confirm setup + SignalDatabase.messages.deleteMessage(messageId = oneToOnePlaceHolderMessage, threadId = aliceThreadId, notify = false, updateThread = false) + SignalDatabase.messages.deleteMessage(messageId = groupPlaceholderMessage, threadId = aliceThreadId, notify = false, updateThread = false) + + SignalDatabase.messages.getMessageCountForThread(aliceThreadId) assertIs 16 + SignalDatabase.messages.getMessageCountForThread(groupThreadId) assertIs 10 + + // WHEN + messageHelper.syncDeleteForMeLocalOnlyConversation(messageHelper.alice, messageHelper.group.recipientId) + + // THEN + SignalDatabase.messages.getMessageCountForThread(aliceThreadId) assertIs 0 + SignalDatabase.threads.getThreadRecord(aliceThreadId) assertIs null + + SignalDatabase.messages.getMessageCountForThread(groupThreadId) assertIs 0 + SignalDatabase.threads.getThreadRecord(groupThreadId) assertIs null + } + + @Test + fun singleLocalOnlyConversationHasAddressable() { + // GIVEN + val messages = mutableListOf() + + for (i in 0 until 10) { + messages += MessageTable.SyncMessageId(messageHelper.alice, messageHelper.incomingText().timestamp) + messages += MessageTable.SyncMessageId(harness.self.id, messageHelper.outgoingText().timestamp) + } + + val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.alice)!! + SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 20 + + // WHEN + messageHelper.syncDeleteForMeLocalOnlyConversation(messageHelper.alice) + + // THEN + SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 20 + SignalDatabase.threads.getThreadRecord(threadId).assertIsNotNull() + + harness.inMemoryLogger.flush() + harness.inMemoryLogger.entries().filter { it.message?.contains("Thread is not local only") == true }.size assertIs 1 + } +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/MessageContentFuzzer.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/MessageContentFuzzer.kt index 38a366315b..d72f4edc5a 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/MessageContentFuzzer.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/MessageContentFuzzer.kt @@ -61,13 +61,13 @@ object MessageContentFuzzer { * - An expire timer value * - Bold style body ranges */ - fun fuzzTextMessage(sentTimestamp: Long? = null, groupContextV2: GroupContextV2? = null): Content { + fun fuzzTextMessage(sentTimestamp: Long? = null, groupContextV2: GroupContextV2? = null, allowExpireTimeChanges: Boolean = true): Content { return Content.Builder() .dataMessage( DataMessage.Builder().buildWith { timestamp = sentTimestamp body = string() - if (random.nextBoolean()) { + if (allowExpireTimeChanges && random.nextBoolean()) { expireTimer = random.nextInt(0..28.days.inWholeSeconds.toInt()) } if (random.nextBoolean()) { @@ -150,6 +150,85 @@ object MessageContentFuzzer { ).build() } + fun syncDeleteForMeMessage(allDeletes: List): Content { + return Content + .Builder() + .syncMessage( + SyncMessage( + deleteForMe = SyncMessage.DeleteForMe( + messageDeletes = allDeletes.map { (conversationId, conversationDeletes) -> + val conversation = Recipient.resolved(conversationId) + SyncMessage.DeleteForMe.MessageDeletes( + conversation = if (conversation.isGroup) { + SyncMessage.DeleteForMe.ConversationIdentifier(threadGroupId = conversation.requireGroupId().decodedId.toByteString()) + } else { + SyncMessage.DeleteForMe.ConversationIdentifier(threadAci = conversation.requireAci().toString()) + }, + + messages = conversationDeletes.map { (author, timestamp) -> + SyncMessage.DeleteForMe.AddressableMessage( + authorAci = Recipient.resolved(author).requireAci().toString(), + sentTimestamp = timestamp + ) + } + ) + } + ) + ) + ).build() + } + + fun syncDeleteForMeConversation(allDeletes: List): Content { + return Content + .Builder() + .syncMessage( + SyncMessage( + deleteForMe = SyncMessage.DeleteForMe( + conversationDeletes = allDeletes.map { (conversationId, conversationDeletes, isFullDelete) -> + val conversation = Recipient.resolved(conversationId) + SyncMessage.DeleteForMe.ConversationDelete( + conversation = if (conversation.isGroup) { + SyncMessage.DeleteForMe.ConversationIdentifier(threadGroupId = conversation.requireGroupId().decodedId.toByteString()) + } else { + SyncMessage.DeleteForMe.ConversationIdentifier(threadAci = conversation.requireAci().toString()) + }, + + mostRecentMessages = conversationDeletes.map { (author, timestamp) -> + SyncMessage.DeleteForMe.AddressableMessage( + authorAci = Recipient.resolved(author).requireAci().toString(), + sentTimestamp = timestamp + ) + }, + + isFullDelete = isFullDelete + ) + } + ) + ) + ).build() + } + + fun syncDeleteForMeLocalOnlyConversation(conversations: List): Content { + return Content + .Builder() + .syncMessage( + SyncMessage( + deleteForMe = SyncMessage.DeleteForMe( + localOnlyConversationDeletes = conversations.map { conversationId -> + val conversation = Recipient.resolved(conversationId) + SyncMessage.DeleteForMe.LocalOnlyConversationDelete( + conversation = if (conversation.isGroup) { + SyncMessage.DeleteForMe.ConversationIdentifier(threadGroupId = conversation.requireGroupId().decodedId.toByteString()) + } else { + SyncMessage.DeleteForMe.ConversationIdentifier(threadAci = conversation.requireAci().toString()) + } + ) + } + ) + ) + ).build() + } + /** * Create a random media message that may be: * - A text body @@ -290,4 +369,12 @@ object MessageContentFuzzer { fun fuzzServerDeliveredTimestamp(envelopeTimestamp: Long): Long { return envelopeTimestamp + 10 } + + data class DeleteForMeSync( + val conversationId: RecipientId, + val messages: List>, + val isFullDelete: Boolean = true + ) { + constructor(conversationId: RecipientId, vararg messages: Pair) : this(conversationId, messages.toList()) + } } diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/TestUtils.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/TestUtils.kt index 542672cc17..fc35f53e8f 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/TestUtils.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/TestUtils.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.testing import android.database.Cursor import android.util.Base64 +import org.hamcrest.Matcher import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.hasSize import org.hamcrest.Matchers.`is` @@ -56,6 +57,10 @@ infix fun > T.assertIsSize(expected: Int) { assertThat(this, hasSize(expected)) } +infix fun T.assert(matcher: Matcher) { + assertThat(this, matcher) +} + fun CountDownLatch.awaitFor(duration: Duration) { if (!await(duration.inWholeMilliseconds, TimeUnit.MILLISECONDS)) { throw TimeoutException("Latch await took longer than ${duration.inWholeMilliseconds}ms") diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/DeleteSyncEducationDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/components/DeleteSyncEducationDialog.kt new file mode 100644 index 0000000000..707d7a1cfd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/DeleteSyncEducationDialog.kt @@ -0,0 +1,140 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components + +import android.content.DialogInterface +import android.os.Bundle +import android.view.View +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentManager +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.subjects.CompletableSubject +import org.signal.core.ui.Buttons +import org.signal.core.ui.Previews +import org.signal.core.ui.SignalPreview +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.util.FeatureFlags +import org.thoughtcrime.securesms.util.TextSecurePreferences + +/** + * Show educational info about delete syncing to linked devices. This dialog uses a subject to convey when + * it completes and will dismiss itself if that subject is null aka dialog is recreated by OS instead of being + * shown by our code. + */ +class DeleteSyncEducationDialog : ComposeBottomSheetDialogFragment() { + + companion object { + + @JvmStatic + fun shouldShow(): Boolean { + return TextSecurePreferences.isMultiDevice(ApplicationDependencies.getApplication()) && + !SignalStore.uiHints().hasSeenDeleteSyncEducationSheet && + FeatureFlags.deleteSyncEnabled() + } + + @JvmStatic + fun show(fragmentManager: FragmentManager): Completable { + val dialog = DeleteSyncEducationDialog() + + dialog.show(fragmentManager, null) + SignalStore.uiHints().hasSeenDeleteSyncEducationSheet = true + + val subject = CompletableSubject.create() + dialog.subject = subject + + return subject + .onErrorComplete() + .observeOn(AndroidSchedulers.mainThread()) + } + } + + override val peekHeightPercentage: Float = 1f + + private var subject: CompletableSubject? = null + + @Composable + override fun SheetContent() { + Sheet(dismiss = this::dismissAllowingStateLoss) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + if (subject == null || savedInstanceState != null) { + dismissAllowingStateLoss() + } + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + subject?.onComplete() + } +} + +@Composable +private fun Sheet( + dismiss: () -> Unit = {} +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(24.dp) + ) { + Image( + painter = painterResource(id = R.drawable.delete_sync), + contentDescription = null, + modifier = Modifier + .padding(top = 48.dp) + ) + + Text( + text = stringResource(id = R.string.DeleteSyncEducation_title), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier + .padding(top = 24.dp, bottom = 12.dp) + ) + + Text( + text = stringResource(id = R.string.DeleteSyncEducation_message), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.secondary + ) + + Buttons.LargeTonal( + onClick = dismiss, + modifier = Modifier + .padding(top = 64.dp) + .defaultMinSize(minWidth = 132.dp) + ) { + Text(text = stringResource(id = R.string.DeleteSyncEducation_acknowledge_button)) + } + } +} + +@SignalPreview +@Composable +private fun SheetPreview() { + Previews.Preview { + Sheet() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/storage/ManageStorageSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/storage/ManageStorageSettingsFragment.kt index 87552a8f2c..daff34acd9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/storage/ManageStorageSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/storage/ManageStorageSettingsFragment.kt @@ -31,6 +31,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.integerArrayResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource @@ -65,6 +66,8 @@ import org.thoughtcrime.securesms.database.MediaTable import org.thoughtcrime.securesms.keyvalue.KeepMessagesDuration import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity import org.thoughtcrime.securesms.preferences.widgets.StorageGraphView +import org.thoughtcrime.securesms.util.FeatureFlags +import org.thoughtcrime.securesms.util.TextSecurePreferences import org.thoughtcrime.securesms.util.Util import org.thoughtcrime.securesms.util.viewModel import java.text.NumberFormat @@ -98,6 +101,7 @@ class ManageStorageSettingsFragment : ComposeFragment() { onReviewStorage = { startActivity(MediaOverviewActivity.forAll(requireContext())) }, onSetKeepMessages = { navController.navigate("set-keep-messages") }, onSetChatLengthLimit = { navController.navigate("set-chat-length-limit") }, + onSyncTrimThreadDeletes = { viewModel.setSyncTrimDeletes(it) }, onDeleteChatHistory = { navController.navigate("confirm-delete-chat-history") } ) } @@ -134,7 +138,11 @@ class ManageStorageSettingsFragment : ComposeFragment() { dialog("confirm-delete-chat-history") { Dialogs.SimpleAlertDialog( title = stringResource(id = R.string.preferences_storage__delete_message_history), - body = stringResource(id = R.string.preferences_storage__this_will_delete_all_message_history_and_media_from_your_device), + body = if (TextSecurePreferences.isMultiDevice(LocalContext.current) && FeatureFlags.deleteSyncEnabled()) { + stringResource(id = R.string.preferences_storage__this_will_delete_all_message_history_and_media_from_your_device_linked_device) + } else { + stringResource(id = R.string.preferences_storage__this_will_delete_all_message_history_and_media_from_your_device) + }, confirm = stringResource(id = R.string.delete), confirmColor = MaterialTheme.colorScheme.error, dismiss = stringResource(id = android.R.string.cancel), @@ -146,7 +154,11 @@ class ManageStorageSettingsFragment : ComposeFragment() { dialog("double-confirm-delete-chat-history", dialogProperties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true)) { Dialogs.SimpleAlertDialog( title = stringResource(id = R.string.preferences_storage__are_you_sure_you_want_to_delete_all_message_history), - body = stringResource(id = R.string.preferences_storage__all_message_history_will_be_permanently_removed_this_action_cannot_be_undone), + body = if (TextSecurePreferences.isMultiDevice(LocalContext.current) && FeatureFlags.deleteSyncEnabled()) { + stringResource(id = R.string.preferences_storage__all_message_history_will_be_permanently_removed_this_action_cannot_be_undone_linked_device) + } else { + stringResource(id = R.string.preferences_storage__all_message_history_will_be_permanently_removed_this_action_cannot_be_undone) + }, confirm = stringResource(id = R.string.preferences_storage__delete_all_now), confirmColor = MaterialTheme.colorScheme.error, dismiss = stringResource(id = android.R.string.cancel), @@ -223,6 +235,7 @@ private fun ManageStorageSettingsScreen( onReviewStorage: () -> Unit = {}, onSetKeepMessages: () -> Unit = {}, onSetChatLengthLimit: () -> Unit = {}, + onSyncTrimThreadDeletes: (Boolean) -> Unit = {}, onDeleteChatHistory: () -> Unit = {} ) { Scaffolds.Settings( @@ -263,6 +276,13 @@ private fun ManageStorageSettingsScreen( onClick = onSetChatLengthLimit ) + Rows.ToggleRow( + text = stringResource(id = R.string.ManageStorageSettingsFragment_apply_limits_title), + label = stringResource(id = R.string.ManageStorageSettingsFragment_apply_limits_description), + checked = state.syncTrimDeletes, + onCheckChanged = onSyncTrimThreadDeletes + ) + Dividers.Default() Rows.TextRow( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/storage/ManageStorageSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/storage/ManageStorageSettingsViewModel.kt index e5f16f296b..a5df9d68cd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/storage/ManageStorageSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/storage/ManageStorageSettingsViewModel.kt @@ -26,7 +26,8 @@ class ManageStorageSettingsViewModel : ViewModel() { private val store = MutableStateFlow( ManageStorageState( keepMessagesDuration = SignalStore.settings().keepMessagesDuration, - lengthLimit = if (SignalStore.settings().isTrimByLengthEnabled) SignalStore.settings().threadTrimLength else ManageStorageState.NO_LIMIT + lengthLimit = if (SignalStore.settings().isTrimByLengthEnabled) SignalStore.settings().threadTrimLength else ManageStorageState.NO_LIMIT, + syncTrimDeletes = SignalStore.settings().shouldSyncThreadTrimDeletes() ) ) val state = store.asStateFlow() @@ -82,6 +83,11 @@ class ManageStorageSettingsViewModel : ViewModel() { return isRestrictingLengthLimitChange(newLimit) } + fun setSyncTrimDeletes(syncTrimDeletes: Boolean) { + SignalStore.settings().setSyncThreadTrimDeletes(syncTrimDeletes) + store.update { it.copy(syncTrimDeletes = syncTrimDeletes) } + } + private fun isRestrictingLengthLimitChange(newLimit: Int): Boolean { return state.value.lengthLimit == ManageStorageState.NO_LIMIT || (newLimit != ManageStorageState.NO_LIMIT && newLimit < state.value.lengthLimit) } @@ -90,6 +96,7 @@ class ManageStorageSettingsViewModel : ViewModel() { data class ManageStorageState( val keepMessagesDuration: KeepMessagesDuration = KeepMessagesDuration.FOREVER, val lengthLimit: Int = NO_LIMIT, + val syncTrimDeletes: Boolean = true, val breakdown: MediaTable.StorageBreakdown? = null ) { companion object { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index 0b2bcd99c3..3cdddcc1ca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -108,6 +108,7 @@ import org.thoughtcrime.securesms.badges.gifts.viewgift.sent.ViewSentGiftBottomS import org.thoughtcrime.securesms.components.AnimatingToggle import org.thoughtcrime.securesms.components.ComposeText import org.thoughtcrime.securesms.components.ConversationSearchBottomBar +import org.thoughtcrime.securesms.components.DeleteSyncEducationDialog import org.thoughtcrime.securesms.components.HidingLinearLayout import org.thoughtcrime.securesms.components.InputAwareConstraintLayout import org.thoughtcrime.securesms.components.InputPanel @@ -2375,10 +2376,26 @@ class ConversationFragment : } private fun handleDeleteMessages(messageParts: Set) { + if (DeleteSyncEducationDialog.shouldShow()) { + DeleteSyncEducationDialog + .show(childFragmentManager) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { handleDeleteMessages(messageParts) } + .addTo(disposables) + + return + } + val records = messageParts.map(MultiselectPart::getMessageRecord).toSet() + disposables += DeleteDialog.show( context = requireContext(), - messageRecords = records + messageRecords = records, + message = if (TextSecurePreferences.isMultiDevice(requireContext()) && FeatureFlags.deleteSyncEnabled()) { + resources.getQuantityString(R.plurals.ConversationFragment_delete_on_linked_warning, records.size) + } else { + null + } ).observeOn(AndroidSchedulers.mainThread()) .subscribe { (deleted: Boolean, _: Boolean) -> if (!deleted) return@subscribe diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index 67a1af9a09..59bb10a832 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -93,6 +93,7 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.badges.models.Badge; import org.thoughtcrime.securesms.badges.self.expired.ExpiredOneTimeBadgeBottomSheetDialogFragment; import org.thoughtcrime.securesms.badges.self.expired.MonthlyDonationCanceledBottomSheetDialogFragment; +import org.thoughtcrime.securesms.components.DeleteSyncEducationDialog; import org.thoughtcrime.securesms.components.Material3SearchToolbar; import org.thoughtcrime.securesms.components.RatingManager; import org.thoughtcrime.securesms.components.SignalProgressDialog; @@ -168,6 +169,7 @@ import org.thoughtcrime.securesms.util.AppForegroundObserver; import org.thoughtcrime.securesms.util.AppStartup; import org.thoughtcrime.securesms.util.CachedInflater; import org.thoughtcrime.securesms.util.ConversationUtil; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.PlayStoreUtil; import org.thoughtcrime.securesms.util.ServiceUtil; import org.thoughtcrime.securesms.util.SignalLocalMetrics; @@ -202,7 +204,8 @@ import static android.app.Activity.RESULT_OK; public class ConversationListFragment extends MainFragment implements ActionMode.Callback, ConversationListAdapter.OnConversationClickListener, - MegaphoneActionController, ClearFilterViewHolder.OnClearFilterClickListener + MegaphoneActionController, + ClearFilterViewHolder.OnClearFilterClickListener { public static final short MESSAGE_REQUESTS_REQUEST_CODE_CREATE_NAME = 32562; public static final short SMS_ROLE_REQUEST_CODE = 32563; @@ -1184,14 +1187,30 @@ public class ConversationListFragment extends MainFragment implements ActionMode @SuppressLint("StaticFieldLeak") private void handleDelete(@NonNull Collection ids) { + if (DeleteSyncEducationDialog.shouldShow()) { + lifecycleDisposable.add( + DeleteSyncEducationDialog.show(getChildFragmentManager()) + .subscribe(() -> handleDelete(ids)) + ); + + return; + } + int conversationsCount = ids.size(); MaterialAlertDialogBuilder alert = new MaterialAlertDialogBuilder(requireActivity()); Context context = requireContext(); alert.setTitle(context.getResources().getQuantityString(R.plurals.ConversationListFragment_delete_selected_conversations, conversationsCount, conversationsCount)); - alert.setMessage(context.getResources().getQuantityString(R.plurals.ConversationListFragment_this_will_permanently_delete_all_n_selected_conversations, - conversationsCount, conversationsCount)); + + if (TextSecurePreferences.isMultiDevice(context) && FeatureFlags.deleteSyncEnabled()) { + alert.setMessage(context.getResources().getQuantityString(R.plurals.ConversationListFragment_this_will_permanently_delete_all_n_selected_conversations_linked_device, + conversationsCount, conversationsCount)); + } else { + alert.setMessage(context.getResources().getQuantityString(R.plurals.ConversationListFragment_this_will_permanently_delete_all_n_selected_conversations, + conversationsCount, conversationsCount)); + } + alert.setCancelable(true); alert.setPositiveButton(R.string.delete, (dialog, which) -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index f223b7e8ce..8a980008a1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -46,6 +46,7 @@ import org.signal.core.util.readToList import org.signal.core.util.readToSet import org.signal.core.util.readToSingleInt import org.signal.core.util.readToSingleLong +import org.signal.core.util.readToSingleLongOrNull import org.signal.core.util.readToSingleObject import org.signal.core.util.requireBlob import org.signal.core.util.requireBoolean @@ -449,6 +450,32 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat .joinToString(" OR ") } + /** + * A message that can be correctly identified with an author/sent timestamp across devices. + * + * Must be: + * - Incoming or sent outgoing + * - Secure or push + * - Not a group update + * - Not a key exchange message + * - Not an encryption message + * - Not a report spam message + * - Not a message rqeuest accepted message + * - Have a valid sent timestamp + * - Be a normal message or direct (1:1) story reply + */ + private const val IS_ADDRESSABLE_CLAUSE = """ + (($TYPE & ${MessageTypes.BASE_TYPE_MASK}) = ${MessageTypes.BASE_SENT_TYPE} OR ($TYPE & ${MessageTypes.BASE_TYPE_MASK}) = ${MessageTypes.BASE_INBOX_TYPE}) AND + ($TYPE & (${MessageTypes.SECURE_MESSAGE_BIT} | ${MessageTypes.PUSH_MESSAGE_BIT})) != 0 AND + ($TYPE & ${MessageTypes.GROUP_MASK}) = 0 AND + ($TYPE & ${MessageTypes.KEY_EXCHANGE_MASK}) = 0 AND + ($TYPE & ${MessageTypes.ENCRYPTION_MASK}) = 0 AND + ($TYPE & ${MessageTypes.SPECIAL_TYPES_MASK}) != ${MessageTypes.SPECIAL_TYPE_REPORTED_SPAM} AND + ($TYPE & ${MessageTypes.SPECIAL_TYPES_MASK}) != ${MessageTypes.SPECIAL_TYPE_MESSAGE_REQUEST_ACCEPTED} AND + $DATE_SENT > 0 AND + $PARENT_STORY_ID <= 0 + """ + @JvmStatic fun mmsReaderFor(cursor: Cursor): MmsReader { return MmsReader(cursor) @@ -1722,6 +1749,33 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat .readToSingleLong(-1) } + fun getLatestReceivedAt(threadId: Long, messages: List): Long? { + if (messages.isEmpty()) { + return null + } + + val args: List> = messages.map { arrayOf(it.timetamp.toString(), it.recipientId.serialize(), threadId.toString()) } + val queries = SqlUtil.buildCustomCollectionQuery("$DATE_SENT = ? AND $FROM_RECIPIENT_ID = ? AND $THREAD_ID = ?", args) + + var overallLatestReceivedAt: Long? = null + for (query in queries) { + val latestReceivedAt: Long? = readableDatabase + .select("MAX($DATE_RECEIVED)") + .from(TABLE_NAME) + .where(query.where, query.whereArgs) + .run() + .readToSingleLongOrNull() + + if (overallLatestReceivedAt == null) { + overallLatestReceivedAt = latestReceivedAt + } else if (latestReceivedAt != null) { + overallLatestReceivedAt = max(overallLatestReceivedAt, latestReceivedAt) + } + } + + return overallLatestReceivedAt + } + fun getScheduledMessageCountForThread(threadId: Long): Int { return readableDatabase .select("COUNT(*)") @@ -3200,7 +3254,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat return deleteMessage(messageId, threadId) } - private fun deleteMessage(messageId: Long, threadId: Long = getThreadIdForMessage(messageId), notify: Boolean = true, updateThread: Boolean = true): Boolean { + @VisibleForTesting + fun deleteMessage(messageId: Long, threadId: Long, notify: Boolean = true, updateThread: Boolean = true): Boolean { Log.d(TAG, "deleteMessage($messageId)") attachments.deleteAttachmentsForMessage(messageId) @@ -3378,12 +3433,12 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat writableDatabase.withinTransaction { db -> SqlUtil.buildCollectionQuery(THREAD_ID, threadIds).forEach { query -> - db.select(ID) + db.select(ID, THREAD_ID) .from(TABLE_NAME) .where(query.where, query.whereArgs) .run() .forEach { cursor -> - deleteMessage(cursor.requireLong(ID), notify = false, updateThread = false) + deleteMessage(cursor.requireLong(ID), cursor.requireLong(THREAD_ID), notify = false, updateThread = false) } } } @@ -3394,10 +3449,12 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat OptimizeMessageSearchIndexJob.enqueue() } - fun deleteMessagesInThreadBeforeDate(threadId: Long, date: Long): Int { + fun deleteMessagesInThreadBeforeDate(threadId: Long, date: Long, inclusive: Boolean): Int { + val condition = if (inclusive) "<=" else "<" + return writableDatabase .delete(TABLE_NAME) - .where("$THREAD_ID = ? AND $DATE_RECEIVED < $date", threadId) + .where("$THREAD_ID = ? AND $DATE_RECEIVED $condition $date", threadId) .run() } @@ -3423,6 +3480,48 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat } } + fun deleteMessages(messagesToDelete: List): List { + val threads = mutableSetOf() + val unhandled = mutableListOf() + + for (message in messagesToDelete) { + readableDatabase + .select(ID, THREAD_ID) + .from(TABLE_NAME) + .where("$DATE_SENT = ? AND $FROM_RECIPIENT_ID = ?", message.timetamp, message.recipientId) + .run() + .use { + if (it.moveToFirst()) { + val messageId = it.requireLong(ID) + val threadId = it.requireLong(THREAD_ID) + + deleteMessage( + messageId = messageId, + threadId = threadId, + notify = false, + updateThread = false + ) + threads += threadId + } else { + unhandled += message + } + } + } + + threads + .forEach { threadId -> + SignalDatabase.threads.update(threadId, unarchive = false) + notifyConversationListeners(threadId) + } + + notifyConversationListListeners() + notifyStickerListeners() + notifyStickerPackListeners() + OptimizeMessageSearchIndexJob.enqueue() + + return unhandled + } + private fun getMessagesInThreadAfterInclusive(threadId: Long, timestamp: Long, limit: Long): List { val where = "$TABLE_NAME.$THREAD_ID = ? AND $TABLE_NAME.$DATE_RECEIVED >= ? AND $TABLE_NAME.$SCHEDULED_DATE = -1 AND $TABLE_NAME.$LATEST_REVISION_ID IS NULL" val args = buildArgs(threadId, timestamp) @@ -4863,6 +4962,48 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat .run() } + fun threadContainsAddressableMessages(threadId: Long): Boolean { + return readableDatabase + .exists(TABLE_NAME) + .where("$IS_ADDRESSABLE_CLAUSE AND $THREAD_ID = ?", threadId) + .run() + } + + fun threadIsEmpty(threadId: Long): Boolean { + val hasMessages = readableDatabase + .exists(TABLE_NAME) + .where("$THREAD_ID = ?", threadId) + .run() + + return !hasMessages + } + + fun getMostRecentAddressableMessages(threadId: Long): Set { + return readableDatabase + .select(*MMS_PROJECTION) + .from(TABLE_NAME) + .where("$IS_ADDRESSABLE_CLAUSE AND $THREAD_ID = ?", threadId) + .orderBy("$DATE_RECEIVED DESC") + .limit(5) + .run() + .use { + MmsReader(it).toSet() + } + } + + fun getAddressableMessagesBefore(threadId: Long, beforeTimestamp: Long): Set { + return readableDatabase + .select(*MMS_PROJECTION) + .from(TABLE_NAME) + .where("$IS_ADDRESSABLE_CLAUSE AND $THREAD_ID = ? AND $DATE_RECEIVED < ?", threadId, beforeTimestamp) + .orderBy("$DATE_RECEIVED DESC") + .limit(5) + .run() + .use { + MmsReader(it).toSet() + } + } + protected enum class ReceiptType(val columnName: String, val groupStatus: Int) { READ(HAS_READ_RECEIPT, GroupReceiptTable.STATUS_READ), DELIVERY(HAS_DELIVERY_RECEIPT, GroupReceiptTable.STATUS_DELIVERED), diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTypes.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTypes.java index d4a184cbfb..c79481a215 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTypes.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTypes.java @@ -90,6 +90,7 @@ public interface MessageTypes { long PUSH_MESSAGE_BIT = 0x200000; // Group Message Information + long GROUP_MASK = 0xF0000; long GROUP_UPDATE_BIT = 0x10000; // Note: Leave bit was previous QUIT bit for GV1, now also general member leave for GV2 long GROUP_LEAVE_BIT = 0x20000; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt index 0943282054..8234d37a6f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt @@ -50,6 +50,7 @@ import org.thoughtcrime.securesms.database.model.serialize import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.groups.BadGroupIdException import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.jobs.MultiDeviceDeleteSendSyncJob import org.thoughtcrime.securesms.jobs.OptimizeMessageSearchIndexJob import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.mms.SlideDeck @@ -61,6 +62,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientUtil import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.thoughtcrime.securesms.util.ConversationUtil +import org.thoughtcrime.securesms.util.FeatureFlags import org.thoughtcrime.securesms.util.JsonUtils import org.thoughtcrime.securesms.util.JsonUtils.SaneJSONObject import org.thoughtcrime.securesms.util.LRUCache @@ -324,13 +326,23 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa return } + val syncThreadTrimDeletes = SignalStore.settings().shouldSyncThreadTrimDeletes() && FeatureFlags.deleteSyncEnabled() + val threadTrimsToSync = mutableListOf>>() + readableDatabase .select(ID) .from(TABLE_NAME) .run() .use { cursor -> while (cursor.moveToNext()) { - trimThreadInternal(cursor.requireLong(ID), length, trimBeforeDate) + trimThreadInternal( + threadId = cursor.requireLong(ID), + syncThreadTrimDeletes = syncThreadTrimDeletes, + length = length, + trimBeforeDate = trimBeforeDate + )?.also { + threadTrimsToSync += it + } } } @@ -346,18 +358,29 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa Log.i(TAG, "Trim all threads caused $deletes attachments to be deleted.") } + if (syncThreadTrimDeletes && threadTrimsToSync.isNotEmpty()) { + MultiDeviceDeleteSendSyncJob.enqueueThreadDeletes(threadTrimsToSync, isFullDelete = false) + } + notifyAttachmentListeners() notifyStickerPackListeners() OptimizeMessageSearchIndexJob.enqueue() } - fun trimThread(threadId: Long, length: Int, trimBeforeDate: Long) { + fun trimThread( + threadId: Long, + syncThreadTrimDeletes: Boolean, + length: Int = NO_TRIM_MESSAGE_COUNT_SET, + trimBeforeDate: Long = NO_TRIM_BEFORE_DATE_SET, + inclusive: Boolean = false + ) { if (length == NO_TRIM_MESSAGE_COUNT_SET && trimBeforeDate == NO_TRIM_BEFORE_DATE_SET) { return } + var threadTrimToSync: Pair>? = null val deletes = writableDatabase.withinTransaction { - trimThreadInternal(threadId, length, trimBeforeDate) + threadTrimToSync = trimThreadInternal(threadId, syncThreadTrimDeletes, length, trimBeforeDate, inclusive) messages.deleteAbandonedMessages() attachments.trimAllAbandonedAttachments() groupReceipts.deleteAbandonedRows() @@ -369,14 +392,24 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa Log.i(TAG, "Trim thread $threadId caused $deletes attachments to be deleted.") } + if (syncThreadTrimDeletes && threadTrimToSync != null) { + MultiDeviceDeleteSendSyncJob.enqueueThreadDeletes(listOf(threadTrimToSync!!), isFullDelete = false) + } + notifyAttachmentListeners() notifyStickerPackListeners() OptimizeMessageSearchIndexJob.enqueue() } - private fun trimThreadInternal(threadId: Long, length: Int, trimBeforeDate: Long) { + private fun trimThreadInternal( + threadId: Long, + syncThreadTrimDeletes: Boolean, + length: Int, + trimBeforeDate: Long, + inclusive: Boolean = false + ): Pair>? { if (length == NO_TRIM_MESSAGE_COUNT_SET && trimBeforeDate == NO_TRIM_BEFORE_DATE_SET) { - return + return null } val finalTrimBeforeDate = if (length != NO_TRIM_MESSAGE_COUNT_SET && length > 0) { @@ -393,19 +426,29 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa } if (finalTrimBeforeDate != NO_TRIM_BEFORE_DATE_SET) { - Log.i(TAG, "Trimming thread: $threadId before: $finalTrimBeforeDate") + Log.i(TAG, "Trimming thread: $threadId before: $finalTrimBeforeDate inclusive: $inclusive") + + val addressableMessages: Set = if (syncThreadTrimDeletes) messages.getAddressableMessagesBefore(threadId, finalTrimBeforeDate) else emptySet() + val deletes = messages.deleteMessagesInThreadBeforeDate(threadId, finalTrimBeforeDate, inclusive) - val deletes = messages.deleteMessagesInThreadBeforeDate(threadId, finalTrimBeforeDate) if (deletes > 0) { Log.i(TAG, "Trimming deleted $deletes messages thread: $threadId") setLastScrolled(threadId, 0) - update(threadId, false) + val threadDeleted = update(threadId, false) notifyConversationListeners(threadId) SignalDatabase.calls.updateCallEventDeletionTimestamps() + + return if (syncThreadTrimDeletes && (threadDeleted || addressableMessages.isNotEmpty())) { + threadId to addressableMessages + } else { + null + } } else { Log.i(TAG, "Trimming deleted no messages thread: $threadId") } } + + return null } fun setAllThreadsRead(): List { @@ -1068,10 +1111,30 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa } } - fun deleteConversation(threadId: Long) { + fun deleteConversationIfContainsOnlyLocal(threadId: Long): Boolean { + return writableDatabase.withinTransaction { + val containsAddressable = messages.threadContainsAddressableMessages(threadId) + val isEmpty = messages.threadIsEmpty(threadId) + + if (containsAddressable || isEmpty) { + false + } else { + deleteConversation(threadId, syncThreadDeletes = false) + true + } + } + } + + @JvmOverloads + fun deleteConversation(threadId: Long, syncThreadDeletes: Boolean = true) { val recipientIdForThreadId = getRecipientIdForThreadId(threadId) + var addressableMessages: Set = emptySet() writableDatabase.withinTransaction { db -> + if (syncThreadDeletes && FeatureFlags.deleteSyncEnabled()) { + addressableMessages = messages.getMostRecentAddressableMessages(threadId) + } + messages.deleteThread(threadId) drafts.clearDrafts(threadId) db.deactivateThread(threadId) @@ -1080,6 +1143,10 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa } } + if (syncThreadDeletes) { + MultiDeviceDeleteSendSyncJob.enqueueThreadDeletes(listOf(threadId to addressableMessages), isFullDelete = true) + } + notifyConversationListListeners() notifyConversationListeners(threadId) ApplicationDependencies.getDatabaseObserver().notifyConversationDeleteListeners(threadId) @@ -1089,12 +1156,20 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa fun deleteConversations(selectedConversations: Set) { val recipientIds = getRecipientIdsForThreadIds(selectedConversations) + val addressableMessages = mutableListOf>>() + val queries: List = SqlUtil.buildCollectionQuery(ID, selectedConversations) writableDatabase.withinTransaction { db -> for (query in queries) { db.deactivateThread(query) } + if (FeatureFlags.deleteSyncEnabled()) { + for (threadId in selectedConversations) { + addressableMessages += threadId to messages.getMostRecentAddressableMessages(threadId) + } + } + messages.deleteAbandonedMessages() attachments.trimAllAbandonedAttachments() groupReceipts.deleteAbandonedRows() @@ -1108,6 +1183,8 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa } } + MultiDeviceDeleteSendSyncJob.enqueueThreadDeletes(addressableMessages, isFullDelete = true) + notifyConversationListListeners() notifyConversationListeners(selectedConversations) ApplicationDependencies.getDatabaseObserver().notifyConversationDeleteListeners(selectedConversations) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index 6f13a41c82..2ecb6eb3cb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -72,6 +72,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Locale; +import java.util.Objects; import java.util.Set; import java.util.function.Consumer; import java.util.function.Function; @@ -707,7 +708,7 @@ public abstract class MessageRecord extends DisplayRecord { } public int hashCode() { - return (int)getId(); + return Objects.hash(id, isMms()); } public int getSubscriptionId() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 8ae100d65f..899db0dd5d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -163,6 +163,7 @@ public final class JobManagerFactories { put(MultiDeviceConfigurationUpdateJob.KEY, new MultiDeviceConfigurationUpdateJob.Factory()); put(MultiDeviceContactSyncJob.KEY, new MultiDeviceContactSyncJob.Factory()); put(MultiDeviceContactUpdateJob.KEY, new MultiDeviceContactUpdateJob.Factory()); + put(MultiDeviceDeleteSendSyncJob.KEY, new MultiDeviceDeleteSendSyncJob.Factory()); put(MultiDeviceKeysUpdateJob.KEY, new MultiDeviceKeysUpdateJob.Factory()); put(MultiDeviceMessageRequestResponseJob.KEY, new MultiDeviceMessageRequestResponseJob.Factory()); put(MultiDeviceOutgoingPaymentSyncJob.KEY, new MultiDeviceOutgoingPaymentSyncJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceDeleteSendSyncJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceDeleteSendSyncJob.kt new file mode 100644 index 0000000000..9674605f91 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceDeleteSendSyncJob.kt @@ -0,0 +1,306 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.jobs + +import androidx.annotation.VisibleForTesting +import androidx.annotation.WorkerThread +import okio.ByteString.Companion.toByteString +import org.signal.core.util.logging.Log +import org.signal.core.util.orNull +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint +import org.thoughtcrime.securesms.jobs.protos.DeleteSyncJobData +import org.thoughtcrime.securesms.jobs.protos.DeleteSyncJobData.AddressableMessage +import org.thoughtcrime.securesms.jobs.protos.DeleteSyncJobData.ThreadDelete +import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.pad +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.Recipient.Companion.self +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.FeatureFlags +import org.thoughtcrime.securesms.util.TextSecurePreferences +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException +import org.whispersystems.signalservice.internal.push.Content +import org.whispersystems.signalservice.internal.push.SyncMessage +import org.whispersystems.signalservice.internal.push.SyncMessage.DeleteForMe +import java.io.IOException +import java.util.Optional +import kotlin.time.Duration.Companion.days + +/** + * Send delete for me sync messages for the various type of delete syncs. + */ +class MultiDeviceDeleteSendSyncJob private constructor( + private var data: DeleteSyncJobData, + parameters: Parameters = Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setMaxAttempts(Parameters.UNLIMITED) + .setLifespan(1.days.inWholeMilliseconds) + .build() +) : Job(parameters) { + + companion object { + const val KEY = "MultiDeviceDeleteSendSyncJob" + private val TAG = Log.tag(MultiDeviceDeleteSendSyncJob::class.java) + + private const val CHUNK_SIZE = 500 + private const val THREAD_CHUNK_SIZE = CHUNK_SIZE / 5 + + @WorkerThread + @JvmStatic + fun enqueueMessageDeletes(messageRecords: Set) { + if (!TextSecurePreferences.isMultiDevice(ApplicationDependencies.getApplication())) { + return + } + + if (!FeatureFlags.deleteSyncEnabled()) { + Log.i(TAG, "Delete sync support not enabled.") + return + } + + messageRecords.chunked(CHUNK_SIZE).forEach { chunk -> + ApplicationDependencies.getJobManager().add(createMessageDeletes(chunk)) + } + } + + @WorkerThread + fun enqueueThreadDeletes(threads: List>>, isFullDelete: Boolean) { + if (!TextSecurePreferences.isMultiDevice(ApplicationDependencies.getApplication())) { + return + } + + if (!FeatureFlags.deleteSyncEnabled()) { + Log.i(TAG, "Delete sync support not enabled.") + return + } + + threads.chunked(THREAD_CHUNK_SIZE).forEach { chunk -> + ApplicationDependencies.getJobManager().add(createThreadDeletes(chunk, isFullDelete)) + } + } + + @WorkerThread + @VisibleForTesting + fun createMessageDeletes(messageRecords: Collection): MultiDeviceDeleteSendSyncJob { + val deletes = messageRecords.mapNotNull { message -> + val threadRecipient = SignalDatabase.threads.getRecipientForThreadId(message.threadId) + if (threadRecipient == null) { + Log.w(TAG, "Unable to find thread recipient for message: ${message.id} thread: ${message.threadId}") + null + } else if (threadRecipient.isReleaseNotes) { + Log.w(TAG, "Syncing release channel deletes are not currently supported") + null + } else { + AddressableMessage( + threadRecipientId = threadRecipient.id.toLong(), + sentTimestamp = message.dateSent, + authorRecipientId = message.fromRecipient.id.toLong() + ) + } + } + + return MultiDeviceDeleteSendSyncJob(messages = deletes) + } + + @WorkerThread + @VisibleForTesting + fun createThreadDeletes(threads: List>>, isFullDelete: Boolean): MultiDeviceDeleteSendSyncJob { + val threadDeletes: List = threads.mapNotNull { (threadId, messages) -> + val threadRecipient = SignalDatabase.threads.getRecipientForThreadId(threadId) + if (threadRecipient == null) { + Log.w(TAG, "Unable to find thread recipient for thread: $threadId") + null + } else if (threadRecipient.isReleaseNotes) { + Log.w(TAG, "Syncing release channel delete is not currently supported") + null + } else { + ThreadDelete( + threadRecipientId = threadRecipient.id.toLong(), + isFullDelete = isFullDelete, + messages = messages.map { + AddressableMessage( + sentTimestamp = it.dateSent, + authorRecipientId = it.fromRecipient.id.toLong() + ) + } + ) + } + } + + return MultiDeviceDeleteSendSyncJob( + threads = threadDeletes.filter { it.messages.isNotEmpty() }, + localOnlyThreads = threadDeletes.filter { it.messages.isEmpty() } + ) + } + } + + @VisibleForTesting + constructor( + messages: List = emptyList(), + threads: List = emptyList(), + localOnlyThreads: List = emptyList() + ) : this( + DeleteSyncJobData( + messageDeletes = messages, + threadDeletes = threads, + localOnlyThreadDeletes = localOnlyThreads + ) + ) + + override fun serialize(): ByteArray = data.encode() + + override fun getFactoryKey(): String = KEY + + override fun run(): Result { + if (!self().isRegistered) { + Log.w(TAG, "Not registered") + return Result.failure() + } + + if (!TextSecurePreferences.isMultiDevice(context)) { + Log.w(TAG, "Not multi-device") + return Result.failure() + } + + if (data.messageDeletes.isNotEmpty()) { + val success = syncDelete( + DeleteForMe( + messageDeletes = data.messageDeletes.groupBy { it.threadRecipientId }.mapNotNull { (threadRecipientId, messages) -> + val conversation = Recipient.resolved(RecipientId.from(threadRecipientId)).toDeleteSyncConversationId() + if (conversation != null) { + DeleteForMe.MessageDeletes( + conversation = conversation, + messages = messages.mapNotNull { it.toDeleteSyncMessage() } + ) + } else { + Log.w(TAG, "Unable to resolve $threadRecipientId to conversation id") + null + } + } + ) + ) + + if (!success) { + return Result.retry(defaultBackoff()) + } + } + + if (data.threadDeletes.isNotEmpty()) { + val success = syncDelete( + DeleteForMe( + conversationDeletes = data.threadDeletes.mapNotNull { + val conversation = Recipient.resolved(RecipientId.from(it.threadRecipientId)).toDeleteSyncConversationId() + if (conversation != null) { + DeleteForMe.ConversationDelete( + conversation = conversation, + mostRecentMessages = it.messages.mapNotNull { m -> m.toDeleteSyncMessage() }, + isFullDelete = it.isFullDelete + ) + } else { + Log.w(TAG, "Unable to resolve ${it.threadRecipientId} to conversation id") + null + } + } + ) + ) + + if (!success) { + return Result.retry(defaultBackoff()) + } + } + + if (data.localOnlyThreadDeletes.isNotEmpty()) { + val success = syncDelete( + DeleteForMe( + localOnlyConversationDeletes = data.localOnlyThreadDeletes.mapNotNull { + val conversation = Recipient.resolved(RecipientId.from(it.threadRecipientId)).toDeleteSyncConversationId() + if (conversation != null) { + DeleteForMe.LocalOnlyConversationDelete( + conversation = conversation + ) + } else { + Log.w(TAG, "Unable to resolve ${it.threadRecipientId} to conversation id") + null + } + } + ) + ) + + if (!success) { + return Result.retry(defaultBackoff()) + } + } + + return Result.success() + } + + override fun onFailure() = Unit + + private fun syncDelete(deleteForMe: DeleteForMe): Boolean { + if (deleteForMe.conversationDeletes.isEmpty() && deleteForMe.messageDeletes.isEmpty() && deleteForMe.localOnlyConversationDeletes.isEmpty()) { + Log.i(TAG, "No valid deletes, nothing to send, skipping") + return true + } + + val syncMessageContent = deleteForMeContent(deleteForMe) + + return try { + ApplicationDependencies.getSignalServiceMessageSender().sendSyncMessage(syncMessageContent, true, Optional.empty()).isSuccess + } catch (e: IOException) { + Log.w(TAG, "Unable to send message delete sync", e) + false + } catch (e: UntrustedIdentityException) { + Log.w(TAG, "Unable to send message delete sync", e) + false + } + } + + private fun deleteForMeContent(deleteForMe: DeleteForMe): Content { + val syncMessage = SyncMessage.Builder() + .pad() + .deleteForMe(deleteForMe) + + return Content(syncMessage = syncMessage.build()) + } + + private fun Recipient.toDeleteSyncConversationId(): DeleteForMe.ConversationIdentifier? { + return when { + isGroup -> DeleteForMe.ConversationIdentifier(threadGroupId = requireGroupId().decodedId.toByteString()) + hasAci -> DeleteForMe.ConversationIdentifier(threadAci = requireAci().toString()) + hasE164 -> DeleteForMe.ConversationIdentifier(threadE164 = requireE164()) + else -> null + } + } + + private fun AddressableMessage.toDeleteSyncMessage(): DeleteForMe.AddressableMessage? { + val author: Recipient = Recipient.resolved(RecipientId.from(authorRecipientId)) + val authorAci: String? = author.aci.orNull()?.toString() + val authorE164: String? = if (authorAci == null) { + author.e164.orNull() + } else { + null + } + + return if (authorAci == null && authorE164 == null) { + Log.w(TAG, "Unable to send sync message without aci and e164 recipient: ${author.id}") + null + } else { + DeleteForMe.AddressableMessage( + authorAci = authorAci, + authorE164 = authorE164, + sentTimestamp = sentTimestamp + ) + } + } + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): MultiDeviceDeleteSendSyncJob { + return MultiDeviceDeleteSendSyncJob(DeleteSyncJobData.ADAPTER.decode(serializedData!!), parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/TrimThreadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/TrimThreadJob.java index 4d251be672..5b51f1f18e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/TrimThreadJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/TrimThreadJob.java @@ -28,6 +28,7 @@ import org.thoughtcrime.securesms.jobmanager.JsonJobData; import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.keyvalue.KeepMessagesDuration; import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.util.FeatureFlags; public class TrimThreadJob extends BaseJob { @@ -77,7 +78,7 @@ public class TrimThreadJob extends BaseJob { long trimBeforeDate = keepMessagesDuration != KeepMessagesDuration.FOREVER ? System.currentTimeMillis() - keepMessagesDuration.getDuration() : ThreadTable.NO_TRIM_BEFORE_DATE_SET; - SignalDatabase.threads().trimThread(threadId, trimLength, trimBeforeDate); + SignalDatabase.threads().trimThread(threadId, SignalStore.settings().shouldSyncThreadTrimDeletes() && FeatureFlags.deleteSyncEnabled(), trimLength, trimBeforeDate, false); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SettingsValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SettingsValues.java index 9173a80865..2220d03c21 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SettingsValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SettingsValues.java @@ -70,6 +70,7 @@ public final class SettingsValues extends SignalStoreValues { private static final String CENSORSHIP_CIRCUMVENTION_ENABLED = "settings.censorshipCircumventionEnabled"; private static final String KEEP_MUTED_CHATS_ARCHIVED = "settings.keepMutedChatsArchived"; private static final String USE_COMPACT_NAVIGATION_BAR = "settings.useCompactNavigationBar"; + private static final String THREAD_TRIM_SYNC_TO_LINKED_DEVICES = "settings.storage.syncThreadTrimDeletes"; public static final int BACKUP_DEFAULT_HOUR = 2; public static final int BACKUP_DEFAULT_MINUTE = 0; @@ -123,7 +124,8 @@ public final class SettingsValues extends SignalStoreValues { UNIVERSAL_EXPIRE_TIMER, SENT_MEDIA_QUALITY, KEEP_MUTED_CHATS_ARCHIVED, - USE_COMPACT_NAVIGATION_BAR); + USE_COMPACT_NAVIGATION_BAR, + THREAD_TRIM_SYNC_TO_LINKED_DEVICES); } public @NonNull LiveData getOnConfigurationSettingChanged() { @@ -162,6 +164,18 @@ public final class SettingsValues extends SignalStoreValues { putInteger(THREAD_TRIM_LENGTH, length); } + public boolean shouldSyncThreadTrimDeletes() { + if (!getStore().containsKey(THREAD_TRIM_SYNC_TO_LINKED_DEVICES)) { + setSyncThreadTrimDeletes(!isTrimByLengthEnabled() && getKeepMessagesDuration() == KeepMessagesDuration.FOREVER); + } + + return getBoolean(THREAD_TRIM_SYNC_TO_LINKED_DEVICES, true); + } + + public void setSyncThreadTrimDeletes(boolean syncDeletes) { + putBoolean(THREAD_TRIM_SYNC_TO_LINKED_DEVICES, syncDeletes); + } + public void setSignalBackupDirectory(@NonNull Uri uri) { putString(SIGNAL_BACKUP_DIRECTORY, uri.toString()); putString(SIGNAL_LATEST_BACKUP_DIRECTORY, uri.toString()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHints.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHints.java index cbcc878c97..72e9ad9771 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHints.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHints.java @@ -25,6 +25,7 @@ public class UiHints extends SignalStoreValues { private static final String HAS_COMPLETED_USERNAME_ONBOARDING = "uihints.has_completed_username_onboarding"; private static final String HAS_SEEN_DOUBLE_TAP_EDIT_EDUCATION_SHEET = "uihints.has_seen_double_tap_edit_education_sheet"; private static final String DISMISSED_CONTACTS_PERMISSION_BANNER = "uihints.dismissed_contacts_permission_banner"; + private static final String HAS_SEEN_DELETE_SYNC_EDUCATION_SHEET = "uihints.has_seen_delete_sync_education_sheet"; UiHints(@NonNull KeyValueStore store) { super(store); @@ -176,4 +177,12 @@ public class UiHints extends SignalStoreValues { public boolean getDismissedContactsPermissionBanner() { return getBoolean(DISMISSED_CONTACTS_PERMISSION_BANNER, false); } + + public void setHasSeenDeleteSyncEducationSheet(boolean seen) { + putBoolean(HAS_SEEN_DELETE_SYNC_EDUCATION_SHEET, seen); + } + + public boolean getHasSeenDeleteSyncEducationSheet() { + return getBoolean(HAS_SEEN_DELETE_SYNC_EDUCATION_SHEET, false); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaActions.java b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaActions.java index b24d95dcb2..754b58d005 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaActions.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaActions.java @@ -13,15 +13,23 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.database.MediaTable; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.jobs.MultiDeviceDeleteSendSyncJob; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.util.AttachmentUtil; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.SaveAttachmentTask; import org.thoughtcrime.securesms.util.StorageUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask; +import java.util.ArrayList; import java.util.Collection; +import java.util.HashSet; import java.util.LinkedList; import java.util.List; +import java.util.Set; final class MediaActions { @@ -56,9 +64,17 @@ final class MediaActions { String confirmTitle = res.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_title, recordCount, recordCount); - String confirmMessage = res.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_message, - recordCount, - recordCount); + + String confirmMessage; + if (TextSecurePreferences.isMultiDevice(context) && FeatureFlags.deleteSyncEnabled()) { + confirmMessage = res.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_message_linked_device, + recordCount, + recordCount); + } else { + confirmMessage = res.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_message, + recordCount, + recordCount); + } MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(context).setTitle(confirmTitle) .setMessage(confirmMessage) @@ -75,9 +91,18 @@ final class MediaActions { return null; } + Set deletedMessageRecords = new HashSet<>(records.length); for (MediaTable.MediaRecord record : records) { - AttachmentUtil.deleteAttachment(context, record.getAttachment()); + MessageRecord deleted = AttachmentUtil.deleteAttachment(record.getAttachment()); + if (deleted != null) { + deletedMessageRecords.add(deleted); + } } + + if (FeatureFlags.deleteSyncEnabled() && Util.hasItems(deletedMessageRecords)) { + MultiDeviceDeleteSendSyncJob.enqueueMessageDeletes(deletedMessageRecords); + } + return null; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewPageFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewPageFragment.java index 1dec181fba..d75b722d79 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewPageFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewPageFragment.java @@ -30,9 +30,11 @@ import com.bumptech.glide.Glide; import com.codewaves.stickyheadergrid.StickyHeaderGridLayoutManager; import org.signal.core.util.DimensionUnit; +import org.signal.core.util.concurrent.LifecycleDisposable; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.attachments.DatabaseAttachment; +import org.thoughtcrime.securesms.components.DeleteSyncEducationDialog; import org.thoughtcrime.securesms.components.menu.ActionItem; import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar; import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController; @@ -63,9 +65,9 @@ public final class MediaOverviewPageFragment extends Fragment private static final String MEDIA_TYPE_EXTRA = "media_type"; private static final String GRID_MODE = "grid_mode"; - private final ActionModeCallback actionModeCallback = new ActionModeCallback(); - private MediaTable.Sorting sorting = MediaTable.Sorting.Newest; - private MediaLoader.MediaType mediaType = MediaLoader.MediaType.GALLERY; + private final ActionModeCallback actionModeCallback = new ActionModeCallback(); + private MediaTable.Sorting sorting = MediaTable.Sorting.Newest; + private MediaLoader.MediaType mediaType = MediaLoader.MediaType.GALLERY; private long threadId; private TextView noMedia; private RecyclerView recyclerView; @@ -76,6 +78,7 @@ public final class MediaOverviewPageFragment extends Fragment private GridMode gridMode; private VoiceNoteMediaController voiceNoteMediaController; private SignalBottomActionBar bottomActionBar; + private LifecycleDisposable lifecycleDisposable; public static @NonNull Fragment newInstance(long threadId, @NonNull MediaLoader.MediaType mediaType, @@ -115,6 +118,9 @@ public final class MediaOverviewPageFragment extends Fragment @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + lifecycleDisposable = new LifecycleDisposable(); + lifecycleDisposable.bindTo(getViewLifecycleOwner()); + Context context = requireContext(); View view = inflater.inflate(R.layout.media_overview_page_fragment, container, false); int spans = getResources().getInteger(R.integer.media_overview_cols); @@ -297,6 +303,19 @@ public final class MediaOverviewPageFragment extends Fragment handleMediaMultiSelectClick(mediaRecord); } + private void handleDeleteSelectedMedia() { + if (DeleteSyncEducationDialog.shouldShow()) { + lifecycleDisposable.add( + DeleteSyncEducationDialog.show(getChildFragmentManager()) + .subscribe(this::handleDeleteSelectedMedia) + ); + return; + } + + MediaActions.handleDeleteMedia(requireContext(), getListAdapter().getSelectedMedia()); + exitMultiSelect(); + } + private void handleSelectAllMedia() { getListAdapter().selectAllMedia(); updateMultiSelect(); @@ -344,10 +363,7 @@ public final class MediaOverviewPageFragment extends Fragment this::exitMultiSelect); }), new ActionItem(R.drawable.symbol_check_circle_24, getString(R.string.MediaOverviewActivity_select_all), this::handleSelectAllMedia), - new ActionItem(R.drawable.symbol_trash_24, getResources().getQuantityString(R.plurals.MediaOverviewActivity_delete_plural, selectionCount), () -> { - MediaActions.handleDeleteMedia(requireContext(), getListAdapter().getSelectedMedia()); - exitMultiSelect(); - }) + new ActionItem(R.drawable.symbol_trash_24, getResources().getQuantityString(R.plurals.MediaOverviewActivity_delete_plural, selectionCount), this::handleDeleteSelectedMedia) )); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewRepository.kt index feb9741bdd..bba91f1e58 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewRepository.kt @@ -21,10 +21,12 @@ import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.SignalDatabase.Companion.media import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.jobs.MultiDeviceDeleteSendSyncJob import org.thoughtcrime.securesms.longmessage.resolveBody import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.sms.MessageSender import org.thoughtcrime.securesms.util.AttachmentUtil +import org.thoughtcrime.securesms.util.FeatureFlags /** * Repository for accessing the attachments in the encrypted database. @@ -80,9 +82,12 @@ class MediaPreviewRepository { }.subscribeOn(Schedulers.io()).toFlowable() } - fun localDelete(context: Context, attachment: DatabaseAttachment): Completable { + fun localDelete(attachment: DatabaseAttachment): Completable { return Completable.fromRunnable { - AttachmentUtil.deleteAttachment(context.applicationContext, attachment) + val deletedMessageRecord = AttachmentUtil.deleteAttachment(attachment) + if (deletedMessageRecord != null && FeatureFlags.deleteSyncEnabled()) { + MultiDeviceDeleteSendSyncJob.enqueueMessageDeletes(setOf(deletedMessageRecord)) + } }.subscribeOn(Schedulers.io()) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2Fragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2Fragment.kt index 7399c9d8b2..62bf917430 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2Fragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2Fragment.kt @@ -40,10 +40,12 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.kotlin.subscribeBy import org.signal.core.util.concurrent.LifecycleDisposable import org.signal.core.util.concurrent.SignalExecutors +import org.signal.core.util.concurrent.addTo import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.LoggingFragment import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.attachments.DatabaseAttachment +import org.thoughtcrime.securesms.components.DeleteSyncEducationDialog import org.thoughtcrime.securesms.components.ViewBinderDelegate import org.thoughtcrime.securesms.components.mention.MentionAnnotation import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment @@ -65,12 +67,14 @@ import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.util.ContextUtil import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.Debouncer +import org.thoughtcrime.securesms.util.FeatureFlags import org.thoughtcrime.securesms.util.FullscreenHelper import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.MessageConstraintsUtil import org.thoughtcrime.securesms.util.SaveAttachmentTask import org.thoughtcrime.securesms.util.SpanUtil import org.thoughtcrime.securesms.util.StorageUtil +import org.thoughtcrime.securesms.util.TextSecurePreferences import org.thoughtcrime.securesms.util.ViewUtil import org.thoughtcrime.securesms.util.visible import java.util.Locale @@ -585,10 +589,19 @@ class MediaPreviewV2Fragment : LoggingFragment(R.layout.fragment_media_preview_v private fun deleteMedia(mediaItem: MediaTable.MediaRecord) { val attachment: DatabaseAttachment = mediaItem.attachment ?: return + if (DeleteSyncEducationDialog.shouldShow()) { + DeleteSyncEducationDialog + .show(childFragmentManager) + .subscribe { deleteMedia(mediaItem) } + .addTo(lifecycleDisposable) + + return + } + MaterialAlertDialogBuilder(requireContext()).apply { setIcon(R.drawable.symbol_error_triangle_fill_24) setTitle(R.string.MediaPreviewActivity_media_delete_confirmation_title) - setMessage(R.string.MediaPreviewActivity_media_delete_confirmation_message) + setMessage(if (TextSecurePreferences.isMultiDevice(requireContext()) && FeatureFlags.deleteSyncEnabled()) R.string.MediaPreviewActivity_media_delete_confirmation_message_linked_device else R.string.MediaPreviewActivity_media_delete_confirmation_message) setCancelable(true) setNegativeButton(android.R.string.cancel, null) setPositiveButton(R.string.ConversationFragment_delete_for_me) { _, _ -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2ViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2ViewModel.kt index e41f7a08a8..90e39e84e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2ViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2ViewModel.kt @@ -93,7 +93,7 @@ class MediaPreviewV2ViewModel : ViewModel() { } fun localDelete(context: Context, attachment: DatabaseAttachment): Completable { - return repository.localDelete(context, attachment).subscribeOn(Schedulers.io()) + return repository.localDelete(attachment).subscribeOn(Schedulers.io()) } fun jumpToFragment(context: Context, messageId: Long): Single { diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/SignalServiceProtoUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/SignalServiceProtoUtil.kt index c23507156a..bfd176d6fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/SignalServiceProtoUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/SignalServiceProtoUtil.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.messages import ProtoUtil.isNotEmpty import com.squareup.wire.Message import okio.ByteString +import okio.ByteString.Companion.toByteString import org.signal.core.util.orNull import org.signal.libsignal.protocol.message.DecryptionErrorMessage import org.signal.libsignal.zkgroup.groups.GroupMasterKey @@ -25,8 +26,10 @@ import org.whispersystems.signalservice.internal.push.DataMessage import org.whispersystems.signalservice.internal.push.DataMessage.Payment import org.whispersystems.signalservice.internal.push.GroupContextV2 import org.whispersystems.signalservice.internal.push.StoryMessage +import org.whispersystems.signalservice.internal.push.SyncMessage import org.whispersystems.signalservice.internal.push.SyncMessage.Sent import org.whispersystems.signalservice.internal.push.TypingMessage +import org.whispersystems.signalservice.internal.util.Util import java.util.Optional import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -186,6 +189,11 @@ object SignalServiceProtoUtil { return Money.picoMobileCoin(this) } + fun SyncMessage.Builder.pad(length: Int = 512): SyncMessage.Builder { + padding(Util.getRandomLengthSecretBytes(length).toByteString()) + return this + } + @Suppress("UNCHECKED_CAST") inline fun , BuilderType : Message.Builder> Message.Builder.buildWith(block: BuilderType.() -> Unit): MessageType { block(this as BuilderType) diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt index 5846da2949..5189323ec8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt @@ -158,6 +158,7 @@ object SyncMessageProcessor { syncMessage.callEvent != null -> handleSynchronizeCallEvent(syncMessage.callEvent!!, envelope.timestamp!!) syncMessage.callLinkUpdate != null -> handleSynchronizeCallLink(syncMessage.callLinkUpdate!!, envelope.timestamp!!) syncMessage.callLogEvent != null -> handleSynchronizeCallLogEvent(syncMessage.callLogEvent!!, envelope.timestamp!!) + syncMessage.deleteForMe != null -> handleSynchronizeDeleteForMe(context, syncMessage.deleteForMe!!, envelope.timestamp!!, earlyMessageCacheEntry) else -> warn(envelope.timestamp!!, "Contains no known sync types...") } } @@ -1451,4 +1452,146 @@ object SyncMessageProcessor { } } } + + private fun handleSynchronizeDeleteForMe(context: Context, deleteForMe: SyncMessage.DeleteForMe, envelopeTimestamp: Long, earlyMessageCacheEntry: EarlyMessageCacheEntry?) { + if (!FeatureFlags.deleteSyncEnabled()) { + warn(envelopeTimestamp, "Delete for me sync message dropped as support not enabled") + return + } + + log(envelopeTimestamp, "Synchronize delete message messageDeletes=${deleteForMe.messageDeletes.size} conversationDeletes=${deleteForMe.conversationDeletes.size} localOnlyConversationDeletes=${deleteForMe.localOnlyConversationDeletes.size}") + + if (deleteForMe.messageDeletes.isNotEmpty()) { + handleSynchronizeMessageDeletes(deleteForMe.messageDeletes, envelopeTimestamp, earlyMessageCacheEntry) + } + + if (deleteForMe.conversationDeletes.isNotEmpty()) { + handleSynchronizeConversationDeletes(deleteForMe.conversationDeletes, envelopeTimestamp) + } + + if (deleteForMe.localOnlyConversationDeletes.isNotEmpty()) { + handleSynchronizeLocalOnlyConversationDeletes(deleteForMe.localOnlyConversationDeletes, envelopeTimestamp) + } + + ApplicationDependencies.getMessageNotifier().updateNotification(context) + } + + private fun handleSynchronizeMessageDeletes(messageDeletes: List, envelopeTimestamp: Long, earlyMessageCacheEntry: EarlyMessageCacheEntry?) { + val messagesToDelete: List = messageDeletes + .asSequence() + .map { it.messages } + .flatten() + .mapNotNull { it.toSyncMessageId(envelopeTimestamp) } + .toList() + + val unhandled: List = SignalDatabase.messages.deleteMessages(messagesToDelete) + + for (syncMessage in unhandled) { + warn(envelopeTimestamp, "[handleSynchronizeDeleteForMe] Could not find matching message! timestamp: ${syncMessage.timetamp} author: ${syncMessage.recipientId}") + if (earlyMessageCacheEntry != null) { + ApplicationDependencies.getEarlyMessageCache().store(syncMessage.recipientId, syncMessage.timetamp, earlyMessageCacheEntry) + } + } + + if (unhandled.isNotEmpty() && earlyMessageCacheEntry != null) { + PushProcessEarlyMessagesJob.enqueue() + } + } + + private fun handleSynchronizeConversationDeletes(conversationDeletes: List, envelopeTimestamp: Long) { + for (delete in conversationDeletes) { + val threadRecipientId: RecipientId? = delete.conversation?.toRecipientId() + + if (threadRecipientId == null) { + warn(envelopeTimestamp, "[handleSynchronizeDeleteForMe] Could not find matching conversation recipient") + continue + } + + val threadId = SignalDatabase.threads.getThreadIdFor(threadRecipientId) + if (threadId == null) { + log(envelopeTimestamp, "[handleSynchronizeDeleteForMe] No thread for matching conversation for recipient: $threadRecipientId") + continue + } + + val mostRecentMessagesToDelete: List = delete.mostRecentMessages.mapNotNull { it.toSyncMessageId(envelopeTimestamp) } + val latestReceivedAt = SignalDatabase.messages.getLatestReceivedAt(threadId, mostRecentMessagesToDelete) + + if (latestReceivedAt != null) { + SignalDatabase.threads.trimThread(threadId = threadId, syncThreadTrimDeletes = false, trimBeforeDate = latestReceivedAt, inclusive = true) + + if (delete.isFullDelete == true) { + val deleted = SignalDatabase.threads.deleteConversationIfContainsOnlyLocal(threadId) + + if (deleted) { + log(envelopeTimestamp, "[handleSynchronizeDeleteForMe] Deleted thread with only local remaining") + } + } + } else { + warn(envelopeTimestamp, "[handleSynchronizeDeleteForMe] Unable to find most recent received at timestamp for recipient: $threadRecipientId thread: $threadId") + } + } + } + + private fun handleSynchronizeLocalOnlyConversationDeletes(conversationDeletes: List, envelopeTimestamp: Long) { + for (delete in conversationDeletes) { + val threadRecipientId: RecipientId? = delete.conversation?.toRecipientId() + + if (threadRecipientId == null) { + warn(envelopeTimestamp, "[handleSynchronizeDeleteForMe] Could not find matching conversation recipient") + continue + } + + val threadId = SignalDatabase.threads.getThreadIdFor(threadRecipientId) + if (threadId == null) { + log(envelopeTimestamp, "[handleSynchronizeDeleteForMe] No thread for matching conversation for recipient: $threadRecipientId") + continue + } + + val deleted = SignalDatabase.threads.deleteConversationIfContainsOnlyLocal(threadId) + if (!deleted) { + log(envelopeTimestamp, "[handleSynchronizeDeleteForMe] Thread is not local only or already empty recipient: $threadRecipientId thread: $threadId") + } + } + } + + private fun SyncMessage.DeleteForMe.ConversationIdentifier.toRecipientId(): RecipientId? { + return when { + threadGroupId != null -> { + try { + val groupId: GroupId = GroupId.push(threadGroupId!!) + Recipient.externalPossiblyMigratedGroup(groupId).id + } catch (e: BadGroupIdException) { + null + } + } + + threadAci != null -> { + ServiceId.parseOrNull(threadAci)?.let { + SignalDatabase.recipients.getOrInsertFromServiceId(it) + } + } + + threadE164 != null -> { + SignalDatabase.recipients.getOrInsertFromE164(threadE164!!) + } + + else -> null + } + } + + private fun SyncMessage.DeleteForMe.AddressableMessage.toSyncMessageId(envelopeTimestamp: Long): MessageTable.SyncMessageId? { + return if (this.sentTimestamp != null && (this.authorAci != null || this.authorE164 != null)) { + val serviceId = ServiceId.parseOrNull(this.authorAci) + val id = if (serviceId != null) { + SignalDatabase.recipients.getOrInsertFromServiceId(serviceId) + } else { + SignalDatabase.recipients.getOrInsertFromE164(this.authorE164!!) + } + + MessageTable.SyncMessageId(id, this.sentTimestamp!!) + } else { + warn(envelopeTimestamp, "[handleSynchronizeDeleteForMe] Invalid delete sync missing timestamp or author") + null + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.java index 564594dee6..896c77a98e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.java @@ -69,22 +69,26 @@ public class AttachmentUtil { /** * Deletes the specified attachment. If its the only attachment for its linked message, the entire * message is deleted. + * + * @return message record of deleted message if a message is deleted */ @WorkerThread - public static void deleteAttachment(@NonNull Context context, - @NonNull DatabaseAttachment attachment) - { + public static @Nullable MessageRecord deleteAttachment(@NonNull DatabaseAttachment attachment) { AttachmentId attachmentId = attachment.attachmentId; long mmsId = attachment.mmsId; int attachmentCount = SignalDatabase.attachments() .getAttachmentsForMessage(mmsId) .size(); + MessageRecord deletedMessageRecord = null; if (attachmentCount <= 1) { + deletedMessageRecord = SignalDatabase.messages().getMessageRecordOrNull(mmsId); SignalDatabase.messages().deleteMessage(mmsId); } else { SignalDatabase.attachments().deleteAttachment(attachmentId); } + + return deletedMessageRecord; } private static boolean isNonDocumentType(String contentType) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DeleteDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/util/DeleteDialog.kt index 44d0731438..21e713ab56 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/DeleteDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DeleteDialog.kt @@ -8,6 +8,7 @@ import org.signal.core.util.concurrent.SignalExecutors import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.jobs.MultiDeviceDeleteSendSyncJob import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.sms.MessageSender import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask @@ -100,13 +101,19 @@ object DeleteDialog { R.string.ConversationFragment_deleting_messages ) { override fun doInBackground(vararg params: Void?): Boolean { - return messageRecords.map { record -> - if (record.isMms) { - SignalDatabase.messages.deleteMessage(record.id) - } else { - SignalDatabase.messages.deleteMessage(record.id) + var threadDeleted = false + + messageRecords.forEach { record -> + if (SignalDatabase.messages.deleteMessage(record.id)) { + threadDeleted = true } - }.any { it } + } + + if (FeatureFlags.deleteSyncEnabled()) { + MultiDeviceDeleteSendSyncJob.enqueueMessageDeletes(messageRecords) + } + + return threadDeleted } override fun onPostExecute(result: Boolean?) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 841904b061..03d684be61 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -131,6 +131,7 @@ public final class FeatureFlags { private static final String LIBSIGNAL_WEB_SOCKET_ENABLED = "android.libsignalWebSocketEnabled"; private static final String RESTORE_POST_REGISTRATION = "android.registration.restorePostRegistration"; private static final String LIBSIGNAL_WEB_SOCKET_SHADOW_PCT = "android.libsignalWebSocketShadowingPercentage"; + private static final String DELETE_SYNC_SEND_RECEIVE = "android.deleteSyncSendReceive"; /** * We will only store remote values for flags in this set. If you want a flag to be controllable @@ -211,7 +212,8 @@ public final class FeatureFlags { LINKED_DEVICE_LIFESPAN_SECONDS, CAMERAX_CUSTOM_CONTROLLER, LIBSIGNAL_WEB_SOCKET_ENABLED, - LIBSIGNAL_WEB_SOCKET_SHADOW_PCT + LIBSIGNAL_WEB_SOCKET_SHADOW_PCT, + DELETE_SYNC_SEND_RECEIVE ); @VisibleForTesting @@ -287,7 +289,8 @@ public final class FeatureFlags { CDSI_LIBSIGNAL_NET, RX_MESSAGE_SEND, LINKED_DEVICE_LIFESPAN_SECONDS, - CAMERAX_CUSTOM_CONTROLLER + CAMERAX_CUSTOM_CONTROLLER, + DELETE_SYNC_SEND_RECEIVE ); /** @@ -766,6 +769,11 @@ public final class FeatureFlags { return Math.max(0, Math.min(value, 100)); } + /** Whether or not to delete syncing is enabled. */ + public static boolean deleteSyncEnabled() { + return getBoolean(DELETE_SYNC_SEND_RECEIVE, false); + } + /** Only for rendering debug info. */ public static synchronized @NonNull Map getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); diff --git a/app/src/main/protowire/JobData.proto b/app/src/main/protowire/JobData.proto index 85724869c8..f59121e30f 100644 --- a/app/src/main/protowire/JobData.proto +++ b/app/src/main/protowire/JobData.proto @@ -71,3 +71,21 @@ message InAppPaymentRedemptionJobData { bool makePrimary = 3; } + +message DeleteSyncJobData { + message AddressableMessage { + uint64 threadRecipientId = 1; + uint64 sentTimestamp = 2; + uint64 authorRecipientId = 3; + } + + message ThreadDelete { + uint64 threadRecipientId = 1; + repeated AddressableMessage messages = 2; + bool isFullDelete = 3; + } + + repeated AddressableMessage messageDeletes = 1; + repeated ThreadDelete threadDeletes = 2; + repeated ThreadDelete localOnlyThreadDeletes = 3; +} diff --git a/app/src/main/res/drawable-night/delete_sync.xml b/app/src/main/res/drawable-night/delete_sync.xml new file mode 100644 index 0000000000..3edb3b2368 --- /dev/null +++ b/app/src/main/res/drawable-night/delete_sync.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/delete_sync.xml b/app/src/main/res/drawable/delete_sync.xml new file mode 100644 index 0000000000..6d304d2489 --- /dev/null +++ b/app/src/main/res/drawable/delete_sync.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e03b2091e0..cc19dbcd0b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -547,6 +547,11 @@ Delete on this device Delete everywhere + + + This message will be deleted from all your devices. + These messages will be deleted from all your devices. + This message will be deleted for everyone in the chat if they’re on a recent version of Signal. They will be able to see that you deleted a message. Original message not found Original message no longer available @@ -647,6 +652,11 @@ This will permanently delete the selected chat. This will permanently delete all %1$d selected chats. + + + This will permanently delete the selected chat from all your devices. + This will permanently delete all %1$d selected chats from all your devices. + Deleting Deleting selected chat… @@ -1319,6 +1329,11 @@ This will permanently delete the selected file. Any message text associated with this item will also be deleted. This will permanently delete all %1$d selected files. Any message text associated with these items will also be deleted. + + + This will permanently delete the selected file from all your devices. Any message text associated with this item will also be deleted. + This will permanently delete all %1$d selected files from all your devices. Any message text associated with these items will also be deleted. + Deleting Deleting messages… Collecting attachments… @@ -2501,6 +2516,8 @@ Unable to save to external storage without permissions Delete message? This will permanently delete this message. + + This will permanently delete this message from all your devices. %1$s to %2$s You to %1$s @@ -3360,8 +3377,12 @@ This will permanently trim all chats to the %1$s most recent messages. This will permanently delete all message history and media from your device. + + This will permanently delete all message history and media from this device and any linked devices. Are you sure you want to delete all message history? All message history will be permanently removed. This action cannot be undone. + + All message history will be permanently removed from all devices. This action cannot be undone. Delete all now Forever 1 year @@ -6907,6 +6928,17 @@ Messages older than the selected time will be permanently deleted. Messages exceeding the selected length will be permanently deleted. + + Apply limits to linked devices + + When enabled, chat limits will also delete messages from your linked devices. + + + Deleting is now synced across all of your devices + + When you delete messages, media or chats, they will be deleted from your phone and all linked devices. + + OK diff --git a/core-ui/src/main/java/org/signal/core/ui/Rows.kt b/core-ui/src/main/java/org/signal/core/ui/Rows.kt index aea17b788f..942d409b95 100644 --- a/core-ui/src/main/java/org/signal/core/ui/Rows.kt +++ b/core-ui/src/main/java/org/signal/core/ui/Rows.kt @@ -100,6 +100,7 @@ object Rows { Row( modifier = modifier .fillMaxWidth() + .clickable(enabled = enabled) { onCheckChanged(!checked) } .padding(defaultPadding()), verticalAlignment = CenterVertically ) { diff --git a/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt b/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt index 8e20a72322..8ce810a62c 100644 --- a/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt +++ b/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt @@ -79,11 +79,15 @@ fun Cursor.requireObject(column: String, serializer: IntSerializer): T { @JvmOverloads fun Cursor.readToSingleLong(defaultValue: Long = 0): Long { + return readToSingleLongOrNull() ?: defaultValue +} + +fun Cursor.readToSingleLongOrNull(): Long? { return use { if (it.moveToFirst()) { - it.getLong(0) + it.getLongOrNull(0) } else { - defaultValue + null } } } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java index 5dda9c6596..84fefec8af 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java @@ -135,7 +135,6 @@ import org.whispersystems.util.ByteArrayUtil; import java.io.IOException; import java.io.InputStream; -import java.security.SecureRandom; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -766,8 +765,15 @@ public class SignalServiceMessageSender { throw new IOException("Unsupported sync message!"); } - long timestamp = message.getSent().isPresent() ? message.getSent().get().getTimestamp() - : System.currentTimeMillis(); + Optional timestamp = message.getSent().map(SentTranscriptMessage::getTimestamp); + + return sendSyncMessage(content, urgent, timestamp); + } + + public @Nonnull SendMessageResult sendSyncMessage(Content content, boolean urgent, Optional sent) + throws IOException, UntrustedIdentityException + { + long timestamp = sent.orElseGet(System::currentTimeMillis); EnvelopeContent envelopeContent = EnvelopeContent.encrypted(content, ContentHint.IMPLICIT, Optional.empty()); @@ -908,7 +914,7 @@ public class SignalServiceMessageSender { throws IOException, UntrustedIdentityException { byte[] nullMessageBody = new DataMessage.Builder() - .body(Base64.encodeWithPadding(Util.getRandomLengthBytes(140))) + .body(Base64.encodeWithPadding(Util.getRandomLengthSecretBytes(140))) .build() .encode(); @@ -938,7 +944,7 @@ public class SignalServiceMessageSender { throws UntrustedIdentityException, IOException { byte[] nullMessageBody = new DataMessage.Builder() - .body(Base64.encodeWithPadding(Util.getRandomLengthBytes(140))) + .body(Base64.encodeWithPadding(Util.getRandomLengthSecretBytes(140))) .build() .encode(); @@ -1763,9 +1769,7 @@ public class SignalServiceMessageSender { } private SyncMessage.Builder createSyncMessageBuilder() { - SecureRandom random = new SecureRandom(); - byte[] padding = Util.getRandomLengthBytes(512); - random.nextBytes(padding); + byte[] padding = Util.getRandomLengthSecretBytes(512); SyncMessage.Builder builder = new SyncMessage.Builder(); builder.padding(ByteString.of(padding)); diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/util/Util.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/util/Util.java index 513d4ae905..b14eb2e014 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/util/Util.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/util/Util.java @@ -75,7 +75,7 @@ public class Util { return secret; } - public static byte[] getRandomLengthBytes(int maxSize) { + public static byte[] getRandomLengthSecretBytes(int maxSize) { SecureRandom secureRandom = new SecureRandom(); byte[] result = new byte[secureRandom.nextInt(maxSize) + 1]; secureRandom.nextBytes(result); diff --git a/libsignal-service/src/main/protowire/SignalService.proto b/libsignal-service/src/main/protowire/SignalService.proto index c6d482d649..19ed0f99a1 100644 --- a/libsignal-service/src/main/protowire/SignalService.proto +++ b/libsignal-service/src/main/protowire/SignalService.proto @@ -653,6 +653,43 @@ message SyncMessage { optional uint64 callId = 4; } + message DeleteForMe { + message ConversationIdentifier { + oneof identifier { + string threadAci = 1; + bytes threadGroupId = 2; + string threadE164 = 3; + } + } + + message AddressableMessage { + oneof author { + string authorAci = 1; + string authorE164 = 2; + } + optional uint64 sentTimestamp = 3; + } + + message MessageDeletes { + optional ConversationIdentifier conversation = 1; + repeated AddressableMessage messages = 2; + } + + message ConversationDelete { + optional ConversationIdentifier conversation = 1; + repeated AddressableMessage mostRecentMessages = 2; + optional bool isFullDelete = 3; + } + + message LocalOnlyConversationDelete { + optional ConversationIdentifier conversation = 1; + } + + repeated MessageDeletes messageDeletes = 1; + repeated ConversationDelete conversationDeletes = 2; + repeated LocalOnlyConversationDelete localOnlyConversationDeletes = 3; + } + optional Sent sent = 1; optional Contacts contacts = 2; reserved /*groups*/ 3; @@ -674,6 +711,7 @@ message SyncMessage { optional CallEvent callEvent = 19; optional CallLinkUpdate callLinkUpdate = 20; optional CallLogEvent callLogEvent = 21; + optional DeleteForMe deleteForMe = 22; } message AttachmentPointer {