Add Delete for Me sync support.

This commit is contained in:
Cody Henthorne 2024-05-21 15:11:06 -04:00
parent 1c66da7873
commit a81a675d59
40 changed files with 2274 additions and 198 deletions

View file

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

View file

@ -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<Content>
@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<Content>()
}
@After
fun tearDown() {
messageHelper.tearDown()
unmockkStatic(TextSecurePreferences::class)
}
@Test
fun messageDeletes() {
// GIVEN
val messages = mutableListOf<MessageHelper.MessageData>()
messages += messageHelper.incomingText()
messages += messageHelper.incomingText()
messages += messageHelper.outgoingText()
val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.alice)!!
val records: Set<MessageRecord> = 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<MessageHelper.MessageData>()
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<MessageRecord> = 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<MessageHelper.MessageData>) {
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()
}
}
}

View file

@ -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<Long>()
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<RecipientId, Long>): 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
)
}

View file

@ -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<Long>()
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<RecipientId, Long>): 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)
}

View file

@ -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<MessageTable.SyncMessageId>()
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<MessageTable.SyncMessageId>()
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<MessageTable.SyncMessageId>()
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<MessageTable.SyncMessageId>()
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<MessageTable.SyncMessageId>()
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<RecipientId, MutableList<MessageTable.SyncMessageId>>(
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<MessageTable.SyncMessageId>()
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
}
}

View file

@ -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<DeleteForMeSync>): 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<DeleteForMeSync>): 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<RecipientId>): 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<Pair<RecipientId, Long>>,
val isFullDelete: Boolean = true
) {
constructor(conversationId: RecipientId, vararg messages: Pair<RecipientId, Long>) : this(conversationId, messages.toList())
}
}

View file

@ -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 <E, T : Collection<E>> T.assertIsSize(expected: Int) {
assertThat(this, hasSize(expected))
}
infix fun <T : Any> T.assert(matcher: Matcher<T>) {
assertThat(this, matcher)
}
fun CountDownLatch.awaitFor(duration: Duration) {
if (!await(duration.inWholeMilliseconds, TimeUnit.MILLISECONDS)) {
throw TimeoutException("Latch await took longer than ${duration.inWholeMilliseconds}ms")

View file

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

View file

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

View file

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

View file

@ -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<MultiselectPart>) {
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

View file

@ -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<Long> 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));
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) -> {

View file

@ -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<SyncMessageId>): Long? {
if (messages.isEmpty()) {
return null
}
val args: List<Array<String>> = 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<MessageTable.SyncMessageId>): List<SyncMessageId> {
val threads = mutableSetOf<Long>()
val unhandled = mutableListOf<SyncMessageId>()
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<MessageRecord> {
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<MessageRecord> {
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<MessageRecord> {
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),

View file

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

View file

@ -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<Pair<Long, Set<MessageRecord>>>()
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<Long, Set<MessageRecord>>? = 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<Long, Set<MessageRecord>>? {
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<MessageRecord> = 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<MarkedMessageInfo> {
@ -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<MessageRecord> = 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<Long>) {
val recipientIds = getRecipientIdsForThreadIds(selectedConversations)
val addressableMessages = mutableListOf<Pair<Long, Set<MessageRecord>>>()
val queries: List<SqlUtil.Query> = 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)

View file

@ -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() {

View file

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

View file

@ -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<MessageRecord>) {
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<Pair<Long, Set<MessageRecord>>>, 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<MessageRecord>): 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<Pair<Long, Set<MessageRecord>>>, isFullDelete: Boolean): MultiDeviceDeleteSendSyncJob {
val threadDeletes: List<ThreadDelete> = 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<AddressableMessage> = emptyList(),
threads: List<ThreadDelete> = emptyList(),
localOnlyThreads: List<ThreadDelete> = 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<MultiDeviceDeleteSendSyncJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): MultiDeviceDeleteSendSyncJob {
return MultiDeviceDeleteSendSyncJob(DeleteSyncJobData.ADAPTER.decode(serializedData!!), parameters)
}
}
}

View file

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

View file

@ -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<String> 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());

View file

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

View file

@ -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,
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<MessageRecord> 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;
}

View file

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

View file

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

View file

@ -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) { _, _ ->

View file

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

View file

@ -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 <reified MessageType : Message<MessageType, BuilderType>, BuilderType : Message.Builder<MessageType, BuilderType>> Message.Builder<MessageType, BuilderType>.buildWith(block: BuilderType.() -> Unit): MessageType {
block(this as BuilderType)

View file

@ -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<SyncMessage.DeleteForMe.MessageDeletes>, envelopeTimestamp: Long, earlyMessageCacheEntry: EarlyMessageCacheEntry?) {
val messagesToDelete: List<MessageTable.SyncMessageId> = messageDeletes
.asSequence()
.map { it.messages }
.flatten()
.mapNotNull { it.toSyncMessageId(envelopeTimestamp) }
.toList()
val unhandled: List<MessageTable.SyncMessageId> = 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<SyncMessage.DeleteForMe.ConversationDelete>, 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<MessageTable.SyncMessageId> = 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<SyncMessage.DeleteForMe.LocalOnlyConversationDelete>, 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
}
}
}

View file

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

View file

@ -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?) {

View file

@ -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<String, Object> getMemoryValues() {
return new TreeMap<>(REMOTE_VALUES);

View file

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

View file

@ -0,0 +1,38 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="140dp"
android:height="92dp"
android:viewportWidth="140"
android:viewportHeight="92">
<path
android:pathData="M125.47,2C131.54,2 136.47,6.92 136.47,13L136.47,72.4C136.47,78.48 131.54,83.4 125.47,83.4L38.93,83.4C32.86,83.4 27.93,78.48 27.93,72.4L27.93,13C27.93,6.92 32.86,2 38.93,2L125.47,2Z"
android:fillColor="#373D52"/>
<path
android:pathData="M125.47,0.17C132.55,0.17 138.3,5.91 138.3,13L138.3,72.4C138.3,79.49 132.55,85.24 125.47,85.24L38.93,85.24C31.84,85.24 26.1,79.49 26.1,72.4L26.1,13C26.1,5.91 31.84,0.17 38.93,0.17L125.47,0.17ZM134.63,13C134.63,7.94 130.53,3.84 125.47,3.84L38.93,3.84C33.87,3.84 29.77,7.94 29.77,13L29.77,72.4C29.77,77.46 33.87,81.57 38.93,81.57L125.47,81.57C130.53,81.57 134.63,77.46 134.63,72.4L134.63,13Z"
android:fillColor="#B6C5FA"
android:fillType="evenOdd"/>
<path
android:pathData="M96,58C96,54.69 98.69,52 102,52H125C128.31,52 131,54.69 131,58V66C131,69.31 128.31,72 125,72H102C98.69,72 96,69.31 96,66V58Z"
android:fillColor="#6A77DD"/>
<path
android:pathData="M40,36C40,32.69 42.69,30 46,30H62C65.31,30 68,32.69 68,36V41C68,44.31 65.31,47 62,47H46C42.69,47 40,44.31 40,41V36Z"
android:fillColor="#D5D9FD"/>
<path
android:pathData="M35,17C35,13.69 37.69,11 41,11H77C80.31,11 83,13.69 83,17V22C83,25.31 80.31,28 77,28H41C37.69,28 35,25.31 35,22V17Z"
android:fillColor="#D5D9FD"/>
<path
android:pathData="M3,35.47C3,29.94 7.48,25.47 13,25.47H33.33C38.86,25.47 43.33,29.94 43.33,35.47V80C43.33,85.52 38.86,90 33.33,90H13C7.48,90 3,85.52 3,80L3,35.47Z"
android:fillColor="#373D52"/>
<path
android:pathData="M1.17,35.47C1.17,28.93 6.46,23.63 13,23.63L33.33,23.63C39.87,23.63 45.17,28.93 45.17,35.47V80C45.17,86.54 39.87,91.84 33.33,91.84H13C6.46,91.84 1.17,86.54 1.17,80L1.17,35.47ZM13,27.3C8.49,27.3 4.84,30.96 4.84,35.47L4.84,80C4.84,84.51 8.49,88.17 13,88.17H33.33C37.84,88.17 41.5,84.51 41.5,80V35.47C41.5,30.96 37.84,27.3 33.33,27.3H13Z"
android:fillColor="#B6C5FA"
android:fillType="evenOdd"/>
<path
android:pathData="M19.13,70.07C19.13,67.31 21.37,65.07 24.13,65.07H33.93C36.69,65.07 38.93,67.31 38.93,70.07V74.73C38.93,77.49 36.69,79.73 33.93,79.73H24.13C21.37,79.73 19.13,77.49 19.13,74.73V70.07Z"
android:fillColor="#6A77DD"/>
<path
android:pathData="M6.67,56.73C6.67,53.9 8.96,51.6 11.8,51.6H21.33C24.17,51.6 26.47,53.9 26.47,56.73V56.73C26.47,59.57 24.17,61.87 21.33,61.87H11.8C8.96,61.87 6.67,59.57 6.67,56.73V56.73Z"
android:fillColor="#D5D9FD"/>
<path
android:pathData="M6.67,45.27C6.67,42.43 8.96,40.13 11.8,40.13H27.2C30.03,40.13 32.33,42.43 32.33,45.27V45.27C32.33,48.1 30.03,50.4 27.2,50.4H11.8C8.96,50.4 6.67,48.1 6.67,45.27V45.27Z"
android:fillColor="#D5D9FD"/>
</vector>

View file

@ -0,0 +1,38 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="140dp"
android:height="92dp"
android:viewportWidth="140"
android:viewportHeight="92">
<path
android:pathData="M125.47,2C131.54,2 136.47,6.92 136.47,13L136.47,72.4C136.47,78.48 131.54,83.4 125.47,83.4L38.93,83.4C32.86,83.4 27.93,78.48 27.93,72.4L27.93,13C27.93,6.92 32.86,2 38.93,2L125.47,2Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M125.47,0.17C132.55,0.17 138.3,5.91 138.3,13L138.3,72.4C138.3,79.49 132.55,85.24 125.47,85.24L38.93,85.24C31.84,85.24 26.1,79.49 26.1,72.4L26.1,13C26.1,5.91 31.84,0.17 38.93,0.17L125.47,0.17ZM134.63,13C134.63,7.94 130.53,3.84 125.47,3.84L38.93,3.84C33.87,3.84 29.77,7.94 29.77,13L29.77,72.4C29.77,77.46 33.87,81.57 38.93,81.57L125.47,81.57C130.53,81.57 134.63,77.46 134.63,72.4L134.63,13Z"
android:fillColor="#617092"
android:fillType="evenOdd"/>
<path
android:pathData="M96,58C96,54.69 98.69,52 102,52H125C128.31,52 131,54.69 131,58V66C131,69.31 128.31,72 125,72H102C98.69,72 96,69.31 96,66V58Z"
android:fillColor="#B4BCFF"/>
<path
android:pathData="M40,36C40,32.69 42.69,30 46,30H62C65.31,30 68,32.69 68,36V41C68,44.31 65.31,47 62,47H46C42.69,47 40,44.31 40,41V36Z"
android:fillColor="#E4E7FF"/>
<path
android:pathData="M35,17C35,13.69 37.69,11 41,11H77C80.31,11 83,13.69 83,17V22C83,25.31 80.31,28 77,28H41C37.69,28 35,25.31 35,22V17Z"
android:fillColor="#E4E7FF"/>
<path
android:pathData="M3,35.47C3,29.94 7.48,25.47 13,25.47H33.33C38.86,25.47 43.33,29.94 43.33,35.47V80C43.33,85.52 38.86,90 33.33,90H13C7.48,90 3,85.52 3,80L3,35.47Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M1.17,35.47C1.17,28.93 6.46,23.63 13,23.63L33.33,23.63C39.87,23.63 45.17,28.93 45.17,35.47V80C45.17,86.54 39.87,91.84 33.33,91.84H13C6.46,91.84 1.17,86.54 1.17,80L1.17,35.47ZM13,27.3C8.49,27.3 4.84,30.96 4.84,35.47L4.84,80C4.84,84.51 8.49,88.17 13,88.17H33.33C37.84,88.17 41.5,84.51 41.5,80V35.47C41.5,30.96 37.84,27.3 33.33,27.3H13Z"
android:fillColor="#617092"
android:fillType="evenOdd"/>
<path
android:pathData="M19.13,70.07C19.13,67.31 21.37,65.07 24.13,65.07H33.93C36.69,65.07 38.93,67.31 38.93,70.07V74.73C38.93,77.49 36.69,79.73 33.93,79.73H24.13C21.37,79.73 19.13,77.49 19.13,74.73V70.07Z"
android:fillColor="#B4BCFF"/>
<path
android:pathData="M6.67,56.73C6.67,53.9 8.96,51.6 11.8,51.6H21.33C24.17,51.6 26.47,53.9 26.47,56.73V56.73C26.47,59.57 24.17,61.87 21.33,61.87H11.8C8.96,61.87 6.67,59.57 6.67,56.73V56.73Z"
android:fillColor="#E4E7FF"/>
<path
android:pathData="M6.67,45.27C6.67,42.43 8.96,40.13 11.8,40.13H27.2C30.03,40.13 32.33,42.43 32.33,45.27V45.27C32.33,48.1 30.03,50.4 27.2,50.4H11.8C8.96,50.4 6.67,48.1 6.67,45.27V45.27Z"
android:fillColor="#E4E7FF"/>
</vector>

View file

@ -547,6 +547,11 @@
<string name="ConversationFragment_delete_on_this_device">Delete on this device</string>
<!-- Dialog button for deleting one or more note-to-self messages on all linked devices. -->
<string name="ConversationFragment_delete_everywhere">Delete everywhere</string>
<!-- Dialog message when deleting individual messages and you have a linked device that the delete will sync to -->
<plurals name="ConversationFragment_delete_on_linked_warning">
<item quantity="one">This message will be deleted from all your devices.</item>
<item quantity="other">These messages will be deleted from all your devices.</item>
</plurals>
<string name="ConversationFragment_this_message_will_be_deleted_for_everyone_in_the_conversation">This message will be deleted for everyone in the chat if theyre on a recent version of Signal. They will be able to see that you deleted a message.</string>
<string name="ConversationFragment_quoted_message_not_found">Original message not found</string>
<string name="ConversationFragment_quoted_message_no_longer_available">Original message no longer available</string>
@ -647,6 +652,11 @@
<item quantity="one">This will permanently delete the selected chat.</item>
<item quantity="other">This will permanently delete all %1$d selected chats.</item>
</plurals>
<!-- Dialog message shown when deleting one to many conversations from the chat list and the user has a linked device -->
<plurals name="ConversationListFragment_this_will_permanently_delete_all_n_selected_conversations_linked_device">
<item quantity="one">This will permanently delete the selected chat from all your devices.</item>
<item quantity="other">This will permanently delete all %1$d selected chats from all your devices.</item>
</plurals>
<string name="ConversationListFragment_deleting">Deleting</string>
<plurals name="ConversationListFragment_deleting_selected_conversations">
<item quantity="one">Deleting selected chat…</item>
@ -1319,6 +1329,11 @@
<item quantity="one">This will permanently delete the selected file. Any message text associated with this item will also be deleted.</item>
<item quantity="other">This will permanently delete all %1$d selected files. Any message text associated with these items will also be deleted.</item>
</plurals>
<!-- Dialog message shown when deleting one to many attachments from the media overview screen -->
<plurals name="MediaOverviewActivity_Media_delete_confirm_message_linked_device">
<item quantity="one">This will permanently delete the selected file from all your devices. Any message text associated with this item will also be deleted.</item>
<item quantity="other">This will permanently delete all %1$d selected files from all your devices. Any message text associated with these items will also be deleted.</item>
</plurals>
<string name="MediaOverviewActivity_Media_delete_progress_title">Deleting</string>
<string name="MediaOverviewActivity_Media_delete_progress_message">Deleting messages…</string>
<string name="MediaOverviewActivity_collecting_attachments">Collecting attachments…</string>
@ -2501,6 +2516,8 @@
<string name="MediaPreviewActivity_unable_to_write_to_external_storage_without_permission">Unable to save to external storage without permissions</string>
<string name="MediaPreviewActivity_media_delete_confirmation_title">Delete message?</string>
<string name="MediaPreviewActivity_media_delete_confirmation_message">This will permanently delete this message.</string>
<!-- Dialog message shown when deleting an attachment and message -->
<string name="MediaPreviewActivity_media_delete_confirmation_message_linked_device">This will permanently delete this message from all your devices.</string>
<string name="MediaPreviewActivity_s_to_s">%1$s to %2$s</string>
<!-- All media preview title when viewing media send by you to another recipient (allows changing of \'You\' based on context) -->
<string name="MediaPreviewActivity_you_to_s">You to %1$s</string>
@ -3360,8 +3377,12 @@
<item quantity="other">This will permanently trim all chats to the %1$s most recent messages.</item>
</plurals>
<string name="preferences_storage__this_will_delete_all_message_history_and_media_from_your_device">This will permanently delete all message history and media from your device.</string>
<!-- Dialog message warning about delete chat message history will delete everything everywhere including linked devices -->
<string name="preferences_storage__this_will_delete_all_message_history_and_media_from_your_device_linked_device">This will permanently delete all message history and media from this device and any linked devices.</string>
<string name="preferences_storage__are_you_sure_you_want_to_delete_all_message_history">Are you sure you want to delete all message history?</string>
<string name="preferences_storage__all_message_history_will_be_permanently_removed_this_action_cannot_be_undone">All message history will be permanently removed. This action cannot be undone.</string>
<!-- Secondary warning dialog message about deleting all chat message history from everything everywhere including linked devices -->
<string name="preferences_storage__all_message_history_will_be_permanently_removed_this_action_cannot_be_undone_linked_device">All message history will be permanently removed from all devices. This action cannot be undone.</string>
<string name="preferences_storage__delete_all_now">Delete all now</string>
<string name="preferences_storage__forever">Forever</string>
<string name="preferences_storage__one_year">1 year</string>
@ -6907,6 +6928,17 @@
<string name="ManageStorageSettingsFragment_keep_messages_duration_warning">Messages older than the selected time will be permanently deleted.</string>
<!-- Warning message at the bottom of a settings screen indicating how messages will be deleted based on user\'s selection (limit is a number like 500 or 5,000) -->
<string name="ManageStorageSettingsFragment_chat_length_limit_warning">Messages exceeding the selected length will be permanently deleted.</string>
<!-- Setting title for syncing automated chat limit trimming (deleting messages automatically by length or date) to linked devices -->
<string name="ManageStorageSettingsFragment_apply_limits_title">Apply limits to linked devices</string>
<!-- Setting description for syncing automated chat limit trimming (deleting messages automatically by length or date) to linked devices -->
<string name="ManageStorageSettingsFragment_apply_limits_description">When enabled, chat limits will also delete messages from your linked devices.</string>
<!-- Educational bottom sheet dialog title shown to notify about delete syncs causing deletes to happen across all devices -->
<string name="DeleteSyncEducation_title">Deleting is now synced across all of your devices</string>
<!-- Educational bottom sheet dialog message shown to notify about delete syncs causing deletes to happen across all devices -->
<string name="DeleteSyncEducation_message">When you delete messages, media or chats, they will be deleted from your phone and all linked devices.</string>
<!-- Educational bottom sheet confirm/dismiss button text shown to notify about delete syncs causing deletes to happen across all devices -->
<string name="DeleteSyncEducation_acknowledge_button">OK</string>
<!-- EOF -->
</resources>

View file

@ -100,6 +100,7 @@ object Rows {
Row(
modifier = modifier
.fillMaxWidth()
.clickable(enabled = enabled) { onCheckChanged(!checked) }
.padding(defaultPadding()),
verticalAlignment = CenterVertically
) {

View file

@ -79,11 +79,15 @@ fun <T> Cursor.requireObject(column: String, serializer: IntSerializer<T>): 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
}
}
}

View file

@ -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<Long> timestamp = message.getSent().map(SentTranscriptMessage::getTimestamp);
return sendSyncMessage(content, urgent, timestamp);
}
public @Nonnull SendMessageResult sendSyncMessage(Content content, boolean urgent, Optional<Long> 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));

View file

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

View file

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