Add Delete for Me sync support.
This commit is contained in:
parent
1c66da7873
commit
a81a675d59
40 changed files with 2274 additions and 198 deletions
|
@ -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"
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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));
|
||||
alert.setMessage(context.getResources().getQuantityString(R.plurals.ConversationListFragment_this_will_permanently_delete_all_n_selected_conversations,
|
||||
conversationsCount, conversationsCount));
|
||||
|
||||
if (TextSecurePreferences.isMultiDevice(context) && FeatureFlags.deleteSyncEnabled()) {
|
||||
alert.setMessage(context.getResources().getQuantityString(R.plurals.ConversationListFragment_this_will_permanently_delete_all_n_selected_conversations_linked_device,
|
||||
conversationsCount, conversationsCount));
|
||||
} else {
|
||||
alert.setMessage(context.getResources().getQuantityString(R.plurals.ConversationListFragment_this_will_permanently_delete_all_n_selected_conversations,
|
||||
conversationsCount, conversationsCount));
|
||||
}
|
||||
|
||||
alert.setCancelable(true);
|
||||
|
||||
alert.setPositiveButton(R.string.delete, (dialog, which) -> {
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,15 +13,23 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
|||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.MediaTable;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceDeleteSendSyncJob;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.util.AttachmentUtil;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
|
||||
import org.thoughtcrime.securesms.util.StorageUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
final class MediaActions {
|
||||
|
||||
|
@ -56,9 +64,17 @@ final class MediaActions {
|
|||
String confirmTitle = res.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_title,
|
||||
recordCount,
|
||||
recordCount);
|
||||
String confirmMessage = res.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_message,
|
||||
recordCount,
|
||||
recordCount);
|
||||
|
||||
String confirmMessage;
|
||||
if (TextSecurePreferences.isMultiDevice(context) && FeatureFlags.deleteSyncEnabled()) {
|
||||
confirmMessage = res.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_message_linked_device,
|
||||
recordCount,
|
||||
recordCount);
|
||||
} else {
|
||||
confirmMessage = res.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_message,
|
||||
recordCount,
|
||||
recordCount);
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(context).setTitle(confirmTitle)
|
||||
.setMessage(confirmMessage)
|
||||
|
@ -75,9 +91,18 @@ final class MediaActions {
|
|||
return null;
|
||||
}
|
||||
|
||||
Set<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;
|
||||
}
|
||||
|
||||
|
|
|
@ -30,9 +30,11 @@ import com.bumptech.glide.Glide;
|
|||
import com.codewaves.stickyheadergrid.StickyHeaderGridLayoutManager;
|
||||
|
||||
import org.signal.core.util.DimensionUnit;
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
||||
import org.thoughtcrime.securesms.components.DeleteSyncEducationDialog;
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem;
|
||||
import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
|
||||
|
@ -63,9 +65,9 @@ public final class MediaOverviewPageFragment extends Fragment
|
|||
private static final String MEDIA_TYPE_EXTRA = "media_type";
|
||||
private static final String GRID_MODE = "grid_mode";
|
||||
|
||||
private final ActionModeCallback actionModeCallback = new ActionModeCallback();
|
||||
private MediaTable.Sorting sorting = MediaTable.Sorting.Newest;
|
||||
private MediaLoader.MediaType mediaType = MediaLoader.MediaType.GALLERY;
|
||||
private final ActionModeCallback actionModeCallback = new ActionModeCallback();
|
||||
private MediaTable.Sorting sorting = MediaTable.Sorting.Newest;
|
||||
private MediaLoader.MediaType mediaType = MediaLoader.MediaType.GALLERY;
|
||||
private long threadId;
|
||||
private TextView noMedia;
|
||||
private RecyclerView recyclerView;
|
||||
|
@ -76,6 +78,7 @@ public final class MediaOverviewPageFragment extends Fragment
|
|||
private GridMode gridMode;
|
||||
private VoiceNoteMediaController voiceNoteMediaController;
|
||||
private SignalBottomActionBar bottomActionBar;
|
||||
private LifecycleDisposable lifecycleDisposable;
|
||||
|
||||
public static @NonNull Fragment newInstance(long threadId,
|
||||
@NonNull MediaLoader.MediaType mediaType,
|
||||
|
@ -115,6 +118,9 @@ public final class MediaOverviewPageFragment extends Fragment
|
|||
|
||||
@Override
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
||||
lifecycleDisposable = new LifecycleDisposable();
|
||||
lifecycleDisposable.bindTo(getViewLifecycleOwner());
|
||||
|
||||
Context context = requireContext();
|
||||
View view = inflater.inflate(R.layout.media_overview_page_fragment, container, false);
|
||||
int spans = getResources().getInteger(R.integer.media_overview_cols);
|
||||
|
@ -297,6 +303,19 @@ public final class MediaOverviewPageFragment extends Fragment
|
|||
handleMediaMultiSelectClick(mediaRecord);
|
||||
}
|
||||
|
||||
private void handleDeleteSelectedMedia() {
|
||||
if (DeleteSyncEducationDialog.shouldShow()) {
|
||||
lifecycleDisposable.add(
|
||||
DeleteSyncEducationDialog.show(getChildFragmentManager())
|
||||
.subscribe(this::handleDeleteSelectedMedia)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
MediaActions.handleDeleteMedia(requireContext(), getListAdapter().getSelectedMedia());
|
||||
exitMultiSelect();
|
||||
}
|
||||
|
||||
private void handleSelectAllMedia() {
|
||||
getListAdapter().selectAllMedia();
|
||||
updateMultiSelect();
|
||||
|
@ -344,10 +363,7 @@ public final class MediaOverviewPageFragment extends Fragment
|
|||
this::exitMultiSelect);
|
||||
}),
|
||||
new ActionItem(R.drawable.symbol_check_circle_24, getString(R.string.MediaOverviewActivity_select_all), this::handleSelectAllMedia),
|
||||
new ActionItem(R.drawable.symbol_trash_24, getResources().getQuantityString(R.plurals.MediaOverviewActivity_delete_plural, selectionCount), () -> {
|
||||
MediaActions.handleDeleteMedia(requireContext(), getListAdapter().getSelectedMedia());
|
||||
exitMultiSelect();
|
||||
})
|
||||
new ActionItem(R.drawable.symbol_trash_24, getResources().getQuantityString(R.plurals.MediaOverviewActivity_delete_plural, selectionCount), this::handleDeleteSelectedMedia)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
@ -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) { _, _ ->
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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?) {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
38
app/src/main/res/drawable-night/delete_sync.xml
Normal file
38
app/src/main/res/drawable-night/delete_sync.xml
Normal 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>
|
38
app/src/main/res/drawable/delete_sync.xml
Normal file
38
app/src/main/res/drawable/delete_sync.xml
Normal 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>
|
|
@ -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 they’re 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>
|
||||
|
|
|
@ -100,6 +100,7 @@ object Rows {
|
|||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = enabled) { onCheckChanged(!checked) }
|
||||
.padding(defaultPadding()),
|
||||
verticalAlignment = CenterVertically
|
||||
) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Add table
Reference in a new issue