Add Delete for Me sync support.

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

View File

@@ -0,0 +1,140 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentManager
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.subjects.CompletableSubject
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.TextSecurePreferences
/**
* Show educational info about delete syncing to linked devices. This dialog uses a subject to convey when
* it completes and will dismiss itself if that subject is null aka dialog is recreated by OS instead of being
* shown by our code.
*/
class DeleteSyncEducationDialog : ComposeBottomSheetDialogFragment() {
companion object {
@JvmStatic
fun shouldShow(): Boolean {
return TextSecurePreferences.isMultiDevice(ApplicationDependencies.getApplication()) &&
!SignalStore.uiHints().hasSeenDeleteSyncEducationSheet &&
FeatureFlags.deleteSyncEnabled()
}
@JvmStatic
fun show(fragmentManager: FragmentManager): Completable {
val dialog = DeleteSyncEducationDialog()
dialog.show(fragmentManager, null)
SignalStore.uiHints().hasSeenDeleteSyncEducationSheet = true
val subject = CompletableSubject.create()
dialog.subject = subject
return subject
.onErrorComplete()
.observeOn(AndroidSchedulers.mainThread())
}
}
override val peekHeightPercentage: Float = 1f
private var subject: CompletableSubject? = null
@Composable
override fun SheetContent() {
Sheet(dismiss = this::dismissAllowingStateLoss)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
if (subject == null || savedInstanceState != null) {
dismissAllowingStateLoss()
}
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
subject?.onComplete()
}
}
@Composable
private fun Sheet(
dismiss: () -> Unit = {}
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(24.dp)
) {
Image(
painter = painterResource(id = R.drawable.delete_sync),
contentDescription = null,
modifier = Modifier
.padding(top = 48.dp)
)
Text(
text = stringResource(id = R.string.DeleteSyncEducation_title),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(top = 24.dp, bottom = 12.dp)
)
Text(
text = stringResource(id = R.string.DeleteSyncEducation_message),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.secondary
)
Buttons.LargeTonal(
onClick = dismiss,
modifier = Modifier
.padding(top = 64.dp)
.defaultMinSize(minWidth = 132.dp)
) {
Text(text = stringResource(id = R.string.DeleteSyncEducation_acknowledge_button))
}
}
}
@SignalPreview
@Composable
private fun SheetPreview() {
Previews.Preview {
Sheet()
}
}

View File

@@ -31,6 +31,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.integerArrayResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
@@ -65,6 +66,8 @@ import org.thoughtcrime.securesms.database.MediaTable
import org.thoughtcrime.securesms.keyvalue.KeepMessagesDuration
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
import org.thoughtcrime.securesms.preferences.widgets.StorageGraphView
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.viewModel
import java.text.NumberFormat
@@ -98,6 +101,7 @@ class ManageStorageSettingsFragment : ComposeFragment() {
onReviewStorage = { startActivity(MediaOverviewActivity.forAll(requireContext())) },
onSetKeepMessages = { navController.navigate("set-keep-messages") },
onSetChatLengthLimit = { navController.navigate("set-chat-length-limit") },
onSyncTrimThreadDeletes = { viewModel.setSyncTrimDeletes(it) },
onDeleteChatHistory = { navController.navigate("confirm-delete-chat-history") }
)
}
@@ -134,7 +138,11 @@ class ManageStorageSettingsFragment : ComposeFragment() {
dialog("confirm-delete-chat-history") {
Dialogs.SimpleAlertDialog(
title = stringResource(id = R.string.preferences_storage__delete_message_history),
body = stringResource(id = R.string.preferences_storage__this_will_delete_all_message_history_and_media_from_your_device),
body = if (TextSecurePreferences.isMultiDevice(LocalContext.current) && FeatureFlags.deleteSyncEnabled()) {
stringResource(id = R.string.preferences_storage__this_will_delete_all_message_history_and_media_from_your_device_linked_device)
} else {
stringResource(id = R.string.preferences_storage__this_will_delete_all_message_history_and_media_from_your_device)
},
confirm = stringResource(id = R.string.delete),
confirmColor = MaterialTheme.colorScheme.error,
dismiss = stringResource(id = android.R.string.cancel),
@@ -146,7 +154,11 @@ class ManageStorageSettingsFragment : ComposeFragment() {
dialog("double-confirm-delete-chat-history", dialogProperties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true)) {
Dialogs.SimpleAlertDialog(
title = stringResource(id = R.string.preferences_storage__are_you_sure_you_want_to_delete_all_message_history),
body = stringResource(id = R.string.preferences_storage__all_message_history_will_be_permanently_removed_this_action_cannot_be_undone),
body = if (TextSecurePreferences.isMultiDevice(LocalContext.current) && FeatureFlags.deleteSyncEnabled()) {
stringResource(id = R.string.preferences_storage__all_message_history_will_be_permanently_removed_this_action_cannot_be_undone_linked_device)
} else {
stringResource(id = R.string.preferences_storage__all_message_history_will_be_permanently_removed_this_action_cannot_be_undone)
},
confirm = stringResource(id = R.string.preferences_storage__delete_all_now),
confirmColor = MaterialTheme.colorScheme.error,
dismiss = stringResource(id = android.R.string.cancel),
@@ -223,6 +235,7 @@ private fun ManageStorageSettingsScreen(
onReviewStorage: () -> Unit = {},
onSetKeepMessages: () -> Unit = {},
onSetChatLengthLimit: () -> Unit = {},
onSyncTrimThreadDeletes: (Boolean) -> Unit = {},
onDeleteChatHistory: () -> Unit = {}
) {
Scaffolds.Settings(
@@ -263,6 +276,13 @@ private fun ManageStorageSettingsScreen(
onClick = onSetChatLengthLimit
)
Rows.ToggleRow(
text = stringResource(id = R.string.ManageStorageSettingsFragment_apply_limits_title),
label = stringResource(id = R.string.ManageStorageSettingsFragment_apply_limits_description),
checked = state.syncTrimDeletes,
onCheckChanged = onSyncTrimThreadDeletes
)
Dividers.Default()
Rows.TextRow(

View File

@@ -26,7 +26,8 @@ class ManageStorageSettingsViewModel : ViewModel() {
private val store = MutableStateFlow(
ManageStorageState(
keepMessagesDuration = SignalStore.settings().keepMessagesDuration,
lengthLimit = if (SignalStore.settings().isTrimByLengthEnabled) SignalStore.settings().threadTrimLength else ManageStorageState.NO_LIMIT
lengthLimit = if (SignalStore.settings().isTrimByLengthEnabled) SignalStore.settings().threadTrimLength else ManageStorageState.NO_LIMIT,
syncTrimDeletes = SignalStore.settings().shouldSyncThreadTrimDeletes()
)
)
val state = store.asStateFlow()
@@ -82,6 +83,11 @@ class ManageStorageSettingsViewModel : ViewModel() {
return isRestrictingLengthLimitChange(newLimit)
}
fun setSyncTrimDeletes(syncTrimDeletes: Boolean) {
SignalStore.settings().setSyncThreadTrimDeletes(syncTrimDeletes)
store.update { it.copy(syncTrimDeletes = syncTrimDeletes) }
}
private fun isRestrictingLengthLimitChange(newLimit: Int): Boolean {
return state.value.lengthLimit == ManageStorageState.NO_LIMIT || (newLimit != ManageStorageState.NO_LIMIT && newLimit < state.value.lengthLimit)
}
@@ -90,6 +96,7 @@ class ManageStorageSettingsViewModel : ViewModel() {
data class ManageStorageState(
val keepMessagesDuration: KeepMessagesDuration = KeepMessagesDuration.FOREVER,
val lengthLimit: Int = NO_LIMIT,
val syncTrimDeletes: Boolean = true,
val breakdown: MediaTable.StorageBreakdown? = null
) {
companion object {

View File

@@ -108,6 +108,7 @@ import org.thoughtcrime.securesms.badges.gifts.viewgift.sent.ViewSentGiftBottomS
import org.thoughtcrime.securesms.components.AnimatingToggle
import org.thoughtcrime.securesms.components.ComposeText
import org.thoughtcrime.securesms.components.ConversationSearchBottomBar
import org.thoughtcrime.securesms.components.DeleteSyncEducationDialog
import org.thoughtcrime.securesms.components.HidingLinearLayout
import org.thoughtcrime.securesms.components.InputAwareConstraintLayout
import org.thoughtcrime.securesms.components.InputPanel
@@ -2375,10 +2376,26 @@ class ConversationFragment :
}
private fun handleDeleteMessages(messageParts: Set<MultiselectPart>) {
if (DeleteSyncEducationDialog.shouldShow()) {
DeleteSyncEducationDialog
.show(childFragmentManager)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { handleDeleteMessages(messageParts) }
.addTo(disposables)
return
}
val records = messageParts.map(MultiselectPart::getMessageRecord).toSet()
disposables += DeleteDialog.show(
context = requireContext(),
messageRecords = records
messageRecords = records,
message = if (TextSecurePreferences.isMultiDevice(requireContext()) && FeatureFlags.deleteSyncEnabled()) {
resources.getQuantityString(R.plurals.ConversationFragment_delete_on_linked_warning, records.size)
} else {
null
}
).observeOn(AndroidSchedulers.mainThread())
.subscribe { (deleted: Boolean, _: Boolean) ->
if (!deleted) return@subscribe

View File

@@ -93,6 +93,7 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.badges.models.Badge;
import org.thoughtcrime.securesms.badges.self.expired.ExpiredOneTimeBadgeBottomSheetDialogFragment;
import org.thoughtcrime.securesms.badges.self.expired.MonthlyDonationCanceledBottomSheetDialogFragment;
import org.thoughtcrime.securesms.components.DeleteSyncEducationDialog;
import org.thoughtcrime.securesms.components.Material3SearchToolbar;
import org.thoughtcrime.securesms.components.RatingManager;
import org.thoughtcrime.securesms.components.SignalProgressDialog;
@@ -168,6 +169,7 @@ import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.CachedInflater;
import org.thoughtcrime.securesms.util.ConversationUtil;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.PlayStoreUtil;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
@@ -202,7 +204,8 @@ import static android.app.Activity.RESULT_OK;
public class ConversationListFragment extends MainFragment implements ActionMode.Callback,
ConversationListAdapter.OnConversationClickListener,
MegaphoneActionController, ClearFilterViewHolder.OnClearFilterClickListener
MegaphoneActionController,
ClearFilterViewHolder.OnClearFilterClickListener
{
public static final short MESSAGE_REQUESTS_REQUEST_CODE_CREATE_NAME = 32562;
public static final short SMS_ROLE_REQUEST_CODE = 32563;
@@ -1184,14 +1187,30 @@ public class ConversationListFragment extends MainFragment implements ActionMode
@SuppressLint("StaticFieldLeak")
private void handleDelete(@NonNull Collection<Long> ids) {
if (DeleteSyncEducationDialog.shouldShow()) {
lifecycleDisposable.add(
DeleteSyncEducationDialog.show(getChildFragmentManager())
.subscribe(() -> handleDelete(ids))
);
return;
}
int conversationsCount = ids.size();
MaterialAlertDialogBuilder alert = new MaterialAlertDialogBuilder(requireActivity());
Context context = requireContext();
alert.setTitle(context.getResources().getQuantityString(R.plurals.ConversationListFragment_delete_selected_conversations,
conversationsCount, conversationsCount));
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) -> {

View File

@@ -46,6 +46,7 @@ import org.signal.core.util.readToList
import org.signal.core.util.readToSet
import org.signal.core.util.readToSingleInt
import org.signal.core.util.readToSingleLong
import org.signal.core.util.readToSingleLongOrNull
import org.signal.core.util.readToSingleObject
import org.signal.core.util.requireBlob
import org.signal.core.util.requireBoolean
@@ -449,6 +450,32 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
.joinToString(" OR ")
}
/**
* A message that can be correctly identified with an author/sent timestamp across devices.
*
* Must be:
* - Incoming or sent outgoing
* - Secure or push
* - Not a group update
* - Not a key exchange message
* - Not an encryption message
* - Not a report spam message
* - Not a message rqeuest accepted message
* - Have a valid sent timestamp
* - Be a normal message or direct (1:1) story reply
*/
private const val IS_ADDRESSABLE_CLAUSE = """
(($TYPE & ${MessageTypes.BASE_TYPE_MASK}) = ${MessageTypes.BASE_SENT_TYPE} OR ($TYPE & ${MessageTypes.BASE_TYPE_MASK}) = ${MessageTypes.BASE_INBOX_TYPE}) AND
($TYPE & (${MessageTypes.SECURE_MESSAGE_BIT} | ${MessageTypes.PUSH_MESSAGE_BIT})) != 0 AND
($TYPE & ${MessageTypes.GROUP_MASK}) = 0 AND
($TYPE & ${MessageTypes.KEY_EXCHANGE_MASK}) = 0 AND
($TYPE & ${MessageTypes.ENCRYPTION_MASK}) = 0 AND
($TYPE & ${MessageTypes.SPECIAL_TYPES_MASK}) != ${MessageTypes.SPECIAL_TYPE_REPORTED_SPAM} AND
($TYPE & ${MessageTypes.SPECIAL_TYPES_MASK}) != ${MessageTypes.SPECIAL_TYPE_MESSAGE_REQUEST_ACCEPTED} AND
$DATE_SENT > 0 AND
$PARENT_STORY_ID <= 0
"""
@JvmStatic
fun mmsReaderFor(cursor: Cursor): MmsReader {
return MmsReader(cursor)
@@ -1722,6 +1749,33 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
.readToSingleLong(-1)
}
fun getLatestReceivedAt(threadId: Long, messages: List<SyncMessageId>): Long? {
if (messages.isEmpty()) {
return null
}
val args: List<Array<String>> = messages.map { arrayOf(it.timetamp.toString(), it.recipientId.serialize(), threadId.toString()) }
val queries = SqlUtil.buildCustomCollectionQuery("$DATE_SENT = ? AND $FROM_RECIPIENT_ID = ? AND $THREAD_ID = ?", args)
var overallLatestReceivedAt: Long? = null
for (query in queries) {
val latestReceivedAt: Long? = readableDatabase
.select("MAX($DATE_RECEIVED)")
.from(TABLE_NAME)
.where(query.where, query.whereArgs)
.run()
.readToSingleLongOrNull()
if (overallLatestReceivedAt == null) {
overallLatestReceivedAt = latestReceivedAt
} else if (latestReceivedAt != null) {
overallLatestReceivedAt = max(overallLatestReceivedAt, latestReceivedAt)
}
}
return overallLatestReceivedAt
}
fun getScheduledMessageCountForThread(threadId: Long): Int {
return readableDatabase
.select("COUNT(*)")
@@ -3200,7 +3254,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
return deleteMessage(messageId, threadId)
}
private fun deleteMessage(messageId: Long, threadId: Long = getThreadIdForMessage(messageId), notify: Boolean = true, updateThread: Boolean = true): Boolean {
@VisibleForTesting
fun deleteMessage(messageId: Long, threadId: Long, notify: Boolean = true, updateThread: Boolean = true): Boolean {
Log.d(TAG, "deleteMessage($messageId)")
attachments.deleteAttachmentsForMessage(messageId)
@@ -3378,12 +3433,12 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
writableDatabase.withinTransaction { db ->
SqlUtil.buildCollectionQuery(THREAD_ID, threadIds).forEach { query ->
db.select(ID)
db.select(ID, THREAD_ID)
.from(TABLE_NAME)
.where(query.where, query.whereArgs)
.run()
.forEach { cursor ->
deleteMessage(cursor.requireLong(ID), notify = false, updateThread = false)
deleteMessage(cursor.requireLong(ID), cursor.requireLong(THREAD_ID), notify = false, updateThread = false)
}
}
}
@@ -3394,10 +3449,12 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
OptimizeMessageSearchIndexJob.enqueue()
}
fun deleteMessagesInThreadBeforeDate(threadId: Long, date: Long): Int {
fun deleteMessagesInThreadBeforeDate(threadId: Long, date: Long, inclusive: Boolean): Int {
val condition = if (inclusive) "<=" else "<"
return writableDatabase
.delete(TABLE_NAME)
.where("$THREAD_ID = ? AND $DATE_RECEIVED < $date", threadId)
.where("$THREAD_ID = ? AND $DATE_RECEIVED $condition $date", threadId)
.run()
}
@@ -3423,6 +3480,48 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
}
fun deleteMessages(messagesToDelete: List<MessageTable.SyncMessageId>): List<SyncMessageId> {
val threads = mutableSetOf<Long>()
val unhandled = mutableListOf<SyncMessageId>()
for (message in messagesToDelete) {
readableDatabase
.select(ID, THREAD_ID)
.from(TABLE_NAME)
.where("$DATE_SENT = ? AND $FROM_RECIPIENT_ID = ?", message.timetamp, message.recipientId)
.run()
.use {
if (it.moveToFirst()) {
val messageId = it.requireLong(ID)
val threadId = it.requireLong(THREAD_ID)
deleteMessage(
messageId = messageId,
threadId = threadId,
notify = false,
updateThread = false
)
threads += threadId
} else {
unhandled += message
}
}
}
threads
.forEach { threadId ->
SignalDatabase.threads.update(threadId, unarchive = false)
notifyConversationListeners(threadId)
}
notifyConversationListListeners()
notifyStickerListeners()
notifyStickerPackListeners()
OptimizeMessageSearchIndexJob.enqueue()
return unhandled
}
private fun getMessagesInThreadAfterInclusive(threadId: Long, timestamp: Long, limit: Long): List<MessageRecord> {
val where = "$TABLE_NAME.$THREAD_ID = ? AND $TABLE_NAME.$DATE_RECEIVED >= ? AND $TABLE_NAME.$SCHEDULED_DATE = -1 AND $TABLE_NAME.$LATEST_REVISION_ID IS NULL"
val args = buildArgs(threadId, timestamp)
@@ -4863,6 +4962,48 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
.run()
}
fun threadContainsAddressableMessages(threadId: Long): Boolean {
return readableDatabase
.exists(TABLE_NAME)
.where("$IS_ADDRESSABLE_CLAUSE AND $THREAD_ID = ?", threadId)
.run()
}
fun threadIsEmpty(threadId: Long): Boolean {
val hasMessages = readableDatabase
.exists(TABLE_NAME)
.where("$THREAD_ID = ?", threadId)
.run()
return !hasMessages
}
fun getMostRecentAddressableMessages(threadId: Long): Set<MessageRecord> {
return readableDatabase
.select(*MMS_PROJECTION)
.from(TABLE_NAME)
.where("$IS_ADDRESSABLE_CLAUSE AND $THREAD_ID = ?", threadId)
.orderBy("$DATE_RECEIVED DESC")
.limit(5)
.run()
.use {
MmsReader(it).toSet()
}
}
fun getAddressableMessagesBefore(threadId: Long, beforeTimestamp: Long): Set<MessageRecord> {
return readableDatabase
.select(*MMS_PROJECTION)
.from(TABLE_NAME)
.where("$IS_ADDRESSABLE_CLAUSE AND $THREAD_ID = ? AND $DATE_RECEIVED < ?", threadId, beforeTimestamp)
.orderBy("$DATE_RECEIVED DESC")
.limit(5)
.run()
.use {
MmsReader(it).toSet()
}
}
protected enum class ReceiptType(val columnName: String, val groupStatus: Int) {
READ(HAS_READ_RECEIPT, GroupReceiptTable.STATUS_READ),
DELIVERY(HAS_DELIVERY_RECEIPT, GroupReceiptTable.STATUS_DELIVERED),

View File

@@ -90,6 +90,7 @@ public interface MessageTypes {
long PUSH_MESSAGE_BIT = 0x200000;
// Group Message Information
long GROUP_MASK = 0xF0000;
long GROUP_UPDATE_BIT = 0x10000;
// Note: Leave bit was previous QUIT bit for GV1, now also general member leave for GV2
long GROUP_LEAVE_BIT = 0x20000;

View File

@@ -50,6 +50,7 @@ import org.thoughtcrime.securesms.database.model.serialize
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.groups.BadGroupIdException
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.jobs.MultiDeviceDeleteSendSyncJob
import org.thoughtcrime.securesms.jobs.OptimizeMessageSearchIndexJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.SlideDeck
@@ -61,6 +62,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.RecipientUtil
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.JsonUtils
import org.thoughtcrime.securesms.util.JsonUtils.SaneJSONObject
import org.thoughtcrime.securesms.util.LRUCache
@@ -324,13 +326,23 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
return
}
val syncThreadTrimDeletes = SignalStore.settings().shouldSyncThreadTrimDeletes() && FeatureFlags.deleteSyncEnabled()
val threadTrimsToSync = mutableListOf<Pair<Long, Set<MessageRecord>>>()
readableDatabase
.select(ID)
.from(TABLE_NAME)
.run()
.use { cursor ->
while (cursor.moveToNext()) {
trimThreadInternal(cursor.requireLong(ID), length, trimBeforeDate)
trimThreadInternal(
threadId = cursor.requireLong(ID),
syncThreadTrimDeletes = syncThreadTrimDeletes,
length = length,
trimBeforeDate = trimBeforeDate
)?.also {
threadTrimsToSync += it
}
}
}
@@ -346,18 +358,29 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
Log.i(TAG, "Trim all threads caused $deletes attachments to be deleted.")
}
if (syncThreadTrimDeletes && threadTrimsToSync.isNotEmpty()) {
MultiDeviceDeleteSendSyncJob.enqueueThreadDeletes(threadTrimsToSync, isFullDelete = false)
}
notifyAttachmentListeners()
notifyStickerPackListeners()
OptimizeMessageSearchIndexJob.enqueue()
}
fun trimThread(threadId: Long, length: Int, trimBeforeDate: Long) {
fun trimThread(
threadId: Long,
syncThreadTrimDeletes: Boolean,
length: Int = NO_TRIM_MESSAGE_COUNT_SET,
trimBeforeDate: Long = NO_TRIM_BEFORE_DATE_SET,
inclusive: Boolean = false
) {
if (length == NO_TRIM_MESSAGE_COUNT_SET && trimBeforeDate == NO_TRIM_BEFORE_DATE_SET) {
return
}
var threadTrimToSync: Pair<Long, Set<MessageRecord>>? = null
val deletes = writableDatabase.withinTransaction {
trimThreadInternal(threadId, length, trimBeforeDate)
threadTrimToSync = trimThreadInternal(threadId, syncThreadTrimDeletes, length, trimBeforeDate, inclusive)
messages.deleteAbandonedMessages()
attachments.trimAllAbandonedAttachments()
groupReceipts.deleteAbandonedRows()
@@ -369,14 +392,24 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
Log.i(TAG, "Trim thread $threadId caused $deletes attachments to be deleted.")
}
if (syncThreadTrimDeletes && threadTrimToSync != null) {
MultiDeviceDeleteSendSyncJob.enqueueThreadDeletes(listOf(threadTrimToSync!!), isFullDelete = false)
}
notifyAttachmentListeners()
notifyStickerPackListeners()
OptimizeMessageSearchIndexJob.enqueue()
}
private fun trimThreadInternal(threadId: Long, length: Int, trimBeforeDate: Long) {
private fun trimThreadInternal(
threadId: Long,
syncThreadTrimDeletes: Boolean,
length: Int,
trimBeforeDate: Long,
inclusive: Boolean = false
): Pair<Long, Set<MessageRecord>>? {
if (length == NO_TRIM_MESSAGE_COUNT_SET && trimBeforeDate == NO_TRIM_BEFORE_DATE_SET) {
return
return null
}
val finalTrimBeforeDate = if (length != NO_TRIM_MESSAGE_COUNT_SET && length > 0) {
@@ -393,19 +426,29 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
}
if (finalTrimBeforeDate != NO_TRIM_BEFORE_DATE_SET) {
Log.i(TAG, "Trimming thread: $threadId before: $finalTrimBeforeDate")
Log.i(TAG, "Trimming thread: $threadId before: $finalTrimBeforeDate inclusive: $inclusive")
val addressableMessages: Set<MessageRecord> = if (syncThreadTrimDeletes) messages.getAddressableMessagesBefore(threadId, finalTrimBeforeDate) else emptySet()
val deletes = messages.deleteMessagesInThreadBeforeDate(threadId, finalTrimBeforeDate, inclusive)
val deletes = messages.deleteMessagesInThreadBeforeDate(threadId, finalTrimBeforeDate)
if (deletes > 0) {
Log.i(TAG, "Trimming deleted $deletes messages thread: $threadId")
setLastScrolled(threadId, 0)
update(threadId, false)
val threadDeleted = update(threadId, false)
notifyConversationListeners(threadId)
SignalDatabase.calls.updateCallEventDeletionTimestamps()
return if (syncThreadTrimDeletes && (threadDeleted || addressableMessages.isNotEmpty())) {
threadId to addressableMessages
} else {
null
}
} else {
Log.i(TAG, "Trimming deleted no messages thread: $threadId")
}
}
return null
}
fun setAllThreadsRead(): List<MarkedMessageInfo> {
@@ -1068,10 +1111,30 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
}
}
fun deleteConversation(threadId: Long) {
fun deleteConversationIfContainsOnlyLocal(threadId: Long): Boolean {
return writableDatabase.withinTransaction {
val containsAddressable = messages.threadContainsAddressableMessages(threadId)
val isEmpty = messages.threadIsEmpty(threadId)
if (containsAddressable || isEmpty) {
false
} else {
deleteConversation(threadId, syncThreadDeletes = false)
true
}
}
}
@JvmOverloads
fun deleteConversation(threadId: Long, syncThreadDeletes: Boolean = true) {
val recipientIdForThreadId = getRecipientIdForThreadId(threadId)
var addressableMessages: Set<MessageRecord> = emptySet()
writableDatabase.withinTransaction { db ->
if (syncThreadDeletes && FeatureFlags.deleteSyncEnabled()) {
addressableMessages = messages.getMostRecentAddressableMessages(threadId)
}
messages.deleteThread(threadId)
drafts.clearDrafts(threadId)
db.deactivateThread(threadId)
@@ -1080,6 +1143,10 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
}
}
if (syncThreadDeletes) {
MultiDeviceDeleteSendSyncJob.enqueueThreadDeletes(listOf(threadId to addressableMessages), isFullDelete = true)
}
notifyConversationListListeners()
notifyConversationListeners(threadId)
ApplicationDependencies.getDatabaseObserver().notifyConversationDeleteListeners(threadId)
@@ -1089,12 +1156,20 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
fun deleteConversations(selectedConversations: Set<Long>) {
val recipientIds = getRecipientIdsForThreadIds(selectedConversations)
val addressableMessages = mutableListOf<Pair<Long, Set<MessageRecord>>>()
val queries: List<SqlUtil.Query> = SqlUtil.buildCollectionQuery(ID, selectedConversations)
writableDatabase.withinTransaction { db ->
for (query in queries) {
db.deactivateThread(query)
}
if (FeatureFlags.deleteSyncEnabled()) {
for (threadId in selectedConversations) {
addressableMessages += threadId to messages.getMostRecentAddressableMessages(threadId)
}
}
messages.deleteAbandonedMessages()
attachments.trimAllAbandonedAttachments()
groupReceipts.deleteAbandonedRows()
@@ -1108,6 +1183,8 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
}
}
MultiDeviceDeleteSendSyncJob.enqueueThreadDeletes(addressableMessages, isFullDelete = true)
notifyConversationListListeners()
notifyConversationListeners(selectedConversations)
ApplicationDependencies.getDatabaseObserver().notifyConversationDeleteListeners(selectedConversations)

View File

@@ -72,6 +72,7 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.function.Consumer;
import java.util.function.Function;
@@ -707,7 +708,7 @@ public abstract class MessageRecord extends DisplayRecord {
}
public int hashCode() {
return (int)getId();
return Objects.hash(id, isMms());
}
public int getSubscriptionId() {

View File

@@ -163,6 +163,7 @@ public final class JobManagerFactories {
put(MultiDeviceConfigurationUpdateJob.KEY, new MultiDeviceConfigurationUpdateJob.Factory());
put(MultiDeviceContactSyncJob.KEY, new MultiDeviceContactSyncJob.Factory());
put(MultiDeviceContactUpdateJob.KEY, new MultiDeviceContactUpdateJob.Factory());
put(MultiDeviceDeleteSendSyncJob.KEY, new MultiDeviceDeleteSendSyncJob.Factory());
put(MultiDeviceKeysUpdateJob.KEY, new MultiDeviceKeysUpdateJob.Factory());
put(MultiDeviceMessageRequestResponseJob.KEY, new MultiDeviceMessageRequestResponseJob.Factory());
put(MultiDeviceOutgoingPaymentSyncJob.KEY, new MultiDeviceOutgoingPaymentSyncJob.Factory());

View File

@@ -0,0 +1,306 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.jobs
import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread
import okio.ByteString.Companion.toByteString
import org.signal.core.util.logging.Log
import org.signal.core.util.orNull
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.jobs.protos.DeleteSyncJobData
import org.thoughtcrime.securesms.jobs.protos.DeleteSyncJobData.AddressableMessage
import org.thoughtcrime.securesms.jobs.protos.DeleteSyncJobData.ThreadDelete
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.pad
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.Recipient.Companion.self
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException
import org.whispersystems.signalservice.internal.push.Content
import org.whispersystems.signalservice.internal.push.SyncMessage
import org.whispersystems.signalservice.internal.push.SyncMessage.DeleteForMe
import java.io.IOException
import java.util.Optional
import kotlin.time.Duration.Companion.days
/**
* Send delete for me sync messages for the various type of delete syncs.
*/
class MultiDeviceDeleteSendSyncJob private constructor(
private var data: DeleteSyncJobData,
parameters: Parameters = Parameters.Builder()
.addConstraint(NetworkConstraint.KEY)
.setMaxAttempts(Parameters.UNLIMITED)
.setLifespan(1.days.inWholeMilliseconds)
.build()
) : Job(parameters) {
companion object {
const val KEY = "MultiDeviceDeleteSendSyncJob"
private val TAG = Log.tag(MultiDeviceDeleteSendSyncJob::class.java)
private const val CHUNK_SIZE = 500
private const val THREAD_CHUNK_SIZE = CHUNK_SIZE / 5
@WorkerThread
@JvmStatic
fun enqueueMessageDeletes(messageRecords: Set<MessageRecord>) {
if (!TextSecurePreferences.isMultiDevice(ApplicationDependencies.getApplication())) {
return
}
if (!FeatureFlags.deleteSyncEnabled()) {
Log.i(TAG, "Delete sync support not enabled.")
return
}
messageRecords.chunked(CHUNK_SIZE).forEach { chunk ->
ApplicationDependencies.getJobManager().add(createMessageDeletes(chunk))
}
}
@WorkerThread
fun enqueueThreadDeletes(threads: List<Pair<Long, Set<MessageRecord>>>, isFullDelete: Boolean) {
if (!TextSecurePreferences.isMultiDevice(ApplicationDependencies.getApplication())) {
return
}
if (!FeatureFlags.deleteSyncEnabled()) {
Log.i(TAG, "Delete sync support not enabled.")
return
}
threads.chunked(THREAD_CHUNK_SIZE).forEach { chunk ->
ApplicationDependencies.getJobManager().add(createThreadDeletes(chunk, isFullDelete))
}
}
@WorkerThread
@VisibleForTesting
fun createMessageDeletes(messageRecords: Collection<MessageRecord>): MultiDeviceDeleteSendSyncJob {
val deletes = messageRecords.mapNotNull { message ->
val threadRecipient = SignalDatabase.threads.getRecipientForThreadId(message.threadId)
if (threadRecipient == null) {
Log.w(TAG, "Unable to find thread recipient for message: ${message.id} thread: ${message.threadId}")
null
} else if (threadRecipient.isReleaseNotes) {
Log.w(TAG, "Syncing release channel deletes are not currently supported")
null
} else {
AddressableMessage(
threadRecipientId = threadRecipient.id.toLong(),
sentTimestamp = message.dateSent,
authorRecipientId = message.fromRecipient.id.toLong()
)
}
}
return MultiDeviceDeleteSendSyncJob(messages = deletes)
}
@WorkerThread
@VisibleForTesting
fun createThreadDeletes(threads: List<Pair<Long, Set<MessageRecord>>>, isFullDelete: Boolean): MultiDeviceDeleteSendSyncJob {
val threadDeletes: List<ThreadDelete> = threads.mapNotNull { (threadId, messages) ->
val threadRecipient = SignalDatabase.threads.getRecipientForThreadId(threadId)
if (threadRecipient == null) {
Log.w(TAG, "Unable to find thread recipient for thread: $threadId")
null
} else if (threadRecipient.isReleaseNotes) {
Log.w(TAG, "Syncing release channel delete is not currently supported")
null
} else {
ThreadDelete(
threadRecipientId = threadRecipient.id.toLong(),
isFullDelete = isFullDelete,
messages = messages.map {
AddressableMessage(
sentTimestamp = it.dateSent,
authorRecipientId = it.fromRecipient.id.toLong()
)
}
)
}
}
return MultiDeviceDeleteSendSyncJob(
threads = threadDeletes.filter { it.messages.isNotEmpty() },
localOnlyThreads = threadDeletes.filter { it.messages.isEmpty() }
)
}
}
@VisibleForTesting
constructor(
messages: List<AddressableMessage> = emptyList(),
threads: List<ThreadDelete> = emptyList(),
localOnlyThreads: List<ThreadDelete> = emptyList()
) : this(
DeleteSyncJobData(
messageDeletes = messages,
threadDeletes = threads,
localOnlyThreadDeletes = localOnlyThreads
)
)
override fun serialize(): ByteArray = data.encode()
override fun getFactoryKey(): String = KEY
override fun run(): Result {
if (!self().isRegistered) {
Log.w(TAG, "Not registered")
return Result.failure()
}
if (!TextSecurePreferences.isMultiDevice(context)) {
Log.w(TAG, "Not multi-device")
return Result.failure()
}
if (data.messageDeletes.isNotEmpty()) {
val success = syncDelete(
DeleteForMe(
messageDeletes = data.messageDeletes.groupBy { it.threadRecipientId }.mapNotNull { (threadRecipientId, messages) ->
val conversation = Recipient.resolved(RecipientId.from(threadRecipientId)).toDeleteSyncConversationId()
if (conversation != null) {
DeleteForMe.MessageDeletes(
conversation = conversation,
messages = messages.mapNotNull { it.toDeleteSyncMessage() }
)
} else {
Log.w(TAG, "Unable to resolve $threadRecipientId to conversation id")
null
}
}
)
)
if (!success) {
return Result.retry(defaultBackoff())
}
}
if (data.threadDeletes.isNotEmpty()) {
val success = syncDelete(
DeleteForMe(
conversationDeletes = data.threadDeletes.mapNotNull {
val conversation = Recipient.resolved(RecipientId.from(it.threadRecipientId)).toDeleteSyncConversationId()
if (conversation != null) {
DeleteForMe.ConversationDelete(
conversation = conversation,
mostRecentMessages = it.messages.mapNotNull { m -> m.toDeleteSyncMessage() },
isFullDelete = it.isFullDelete
)
} else {
Log.w(TAG, "Unable to resolve ${it.threadRecipientId} to conversation id")
null
}
}
)
)
if (!success) {
return Result.retry(defaultBackoff())
}
}
if (data.localOnlyThreadDeletes.isNotEmpty()) {
val success = syncDelete(
DeleteForMe(
localOnlyConversationDeletes = data.localOnlyThreadDeletes.mapNotNull {
val conversation = Recipient.resolved(RecipientId.from(it.threadRecipientId)).toDeleteSyncConversationId()
if (conversation != null) {
DeleteForMe.LocalOnlyConversationDelete(
conversation = conversation
)
} else {
Log.w(TAG, "Unable to resolve ${it.threadRecipientId} to conversation id")
null
}
}
)
)
if (!success) {
return Result.retry(defaultBackoff())
}
}
return Result.success()
}
override fun onFailure() = Unit
private fun syncDelete(deleteForMe: DeleteForMe): Boolean {
if (deleteForMe.conversationDeletes.isEmpty() && deleteForMe.messageDeletes.isEmpty() && deleteForMe.localOnlyConversationDeletes.isEmpty()) {
Log.i(TAG, "No valid deletes, nothing to send, skipping")
return true
}
val syncMessageContent = deleteForMeContent(deleteForMe)
return try {
ApplicationDependencies.getSignalServiceMessageSender().sendSyncMessage(syncMessageContent, true, Optional.empty()).isSuccess
} catch (e: IOException) {
Log.w(TAG, "Unable to send message delete sync", e)
false
} catch (e: UntrustedIdentityException) {
Log.w(TAG, "Unable to send message delete sync", e)
false
}
}
private fun deleteForMeContent(deleteForMe: DeleteForMe): Content {
val syncMessage = SyncMessage.Builder()
.pad()
.deleteForMe(deleteForMe)
return Content(syncMessage = syncMessage.build())
}
private fun Recipient.toDeleteSyncConversationId(): DeleteForMe.ConversationIdentifier? {
return when {
isGroup -> DeleteForMe.ConversationIdentifier(threadGroupId = requireGroupId().decodedId.toByteString())
hasAci -> DeleteForMe.ConversationIdentifier(threadAci = requireAci().toString())
hasE164 -> DeleteForMe.ConversationIdentifier(threadE164 = requireE164())
else -> null
}
}
private fun AddressableMessage.toDeleteSyncMessage(): DeleteForMe.AddressableMessage? {
val author: Recipient = Recipient.resolved(RecipientId.from(authorRecipientId))
val authorAci: String? = author.aci.orNull()?.toString()
val authorE164: String? = if (authorAci == null) {
author.e164.orNull()
} else {
null
}
return if (authorAci == null && authorE164 == null) {
Log.w(TAG, "Unable to send sync message without aci and e164 recipient: ${author.id}")
null
} else {
DeleteForMe.AddressableMessage(
authorAci = authorAci,
authorE164 = authorE164,
sentTimestamp = sentTimestamp
)
}
}
class Factory : Job.Factory<MultiDeviceDeleteSendSyncJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): MultiDeviceDeleteSendSyncJob {
return MultiDeviceDeleteSendSyncJob(DeleteSyncJobData.ADAPTER.decode(serializedData!!), parameters)
}
}
}

View File

@@ -28,6 +28,7 @@ import org.thoughtcrime.securesms.jobmanager.JsonJobData;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.keyvalue.KeepMessagesDuration;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.FeatureFlags;
public class TrimThreadJob extends BaseJob {
@@ -77,7 +78,7 @@ public class TrimThreadJob extends BaseJob {
long trimBeforeDate = keepMessagesDuration != KeepMessagesDuration.FOREVER ? System.currentTimeMillis() - keepMessagesDuration.getDuration()
: ThreadTable.NO_TRIM_BEFORE_DATE_SET;
SignalDatabase.threads().trimThread(threadId, trimLength, trimBeforeDate);
SignalDatabase.threads().trimThread(threadId, SignalStore.settings().shouldSyncThreadTrimDeletes() && FeatureFlags.deleteSyncEnabled(), trimLength, trimBeforeDate, false);
}
@Override

View File

@@ -70,6 +70,7 @@ public final class SettingsValues extends SignalStoreValues {
private static final String CENSORSHIP_CIRCUMVENTION_ENABLED = "settings.censorshipCircumventionEnabled";
private static final String KEEP_MUTED_CHATS_ARCHIVED = "settings.keepMutedChatsArchived";
private static final String USE_COMPACT_NAVIGATION_BAR = "settings.useCompactNavigationBar";
private static final String THREAD_TRIM_SYNC_TO_LINKED_DEVICES = "settings.storage.syncThreadTrimDeletes";
public static final int BACKUP_DEFAULT_HOUR = 2;
public static final int BACKUP_DEFAULT_MINUTE = 0;
@@ -123,7 +124,8 @@ public final class SettingsValues extends SignalStoreValues {
UNIVERSAL_EXPIRE_TIMER,
SENT_MEDIA_QUALITY,
KEEP_MUTED_CHATS_ARCHIVED,
USE_COMPACT_NAVIGATION_BAR);
USE_COMPACT_NAVIGATION_BAR,
THREAD_TRIM_SYNC_TO_LINKED_DEVICES);
}
public @NonNull LiveData<String> getOnConfigurationSettingChanged() {
@@ -162,6 +164,18 @@ public final class SettingsValues extends SignalStoreValues {
putInteger(THREAD_TRIM_LENGTH, length);
}
public boolean shouldSyncThreadTrimDeletes() {
if (!getStore().containsKey(THREAD_TRIM_SYNC_TO_LINKED_DEVICES)) {
setSyncThreadTrimDeletes(!isTrimByLengthEnabled() && getKeepMessagesDuration() == KeepMessagesDuration.FOREVER);
}
return getBoolean(THREAD_TRIM_SYNC_TO_LINKED_DEVICES, true);
}
public void setSyncThreadTrimDeletes(boolean syncDeletes) {
putBoolean(THREAD_TRIM_SYNC_TO_LINKED_DEVICES, syncDeletes);
}
public void setSignalBackupDirectory(@NonNull Uri uri) {
putString(SIGNAL_BACKUP_DIRECTORY, uri.toString());
putString(SIGNAL_LATEST_BACKUP_DIRECTORY, uri.toString());

View File

@@ -25,6 +25,7 @@ public class UiHints extends SignalStoreValues {
private static final String HAS_COMPLETED_USERNAME_ONBOARDING = "uihints.has_completed_username_onboarding";
private static final String HAS_SEEN_DOUBLE_TAP_EDIT_EDUCATION_SHEET = "uihints.has_seen_double_tap_edit_education_sheet";
private static final String DISMISSED_CONTACTS_PERMISSION_BANNER = "uihints.dismissed_contacts_permission_banner";
private static final String HAS_SEEN_DELETE_SYNC_EDUCATION_SHEET = "uihints.has_seen_delete_sync_education_sheet";
UiHints(@NonNull KeyValueStore store) {
super(store);
@@ -176,4 +177,12 @@ public class UiHints extends SignalStoreValues {
public boolean getDismissedContactsPermissionBanner() {
return getBoolean(DISMISSED_CONTACTS_PERMISSION_BANNER, false);
}
public void setHasSeenDeleteSyncEducationSheet(boolean seen) {
putBoolean(HAS_SEEN_DELETE_SYNC_EDUCATION_SHEET, seen);
}
public boolean getHasSeenDeleteSyncEducationSheet() {
return getBoolean(HAS_SEEN_DELETE_SYNC_EDUCATION_SHEET, false);
}
}

View File

@@ -13,15 +13,23 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.MediaTable;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.jobs.MultiDeviceDeleteSendSyncJob;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.util.AttachmentUtil;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
import org.thoughtcrime.securesms.util.StorageUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
final class MediaActions {
@@ -56,9 +64,17 @@ final class MediaActions {
String confirmTitle = res.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_title,
recordCount,
recordCount);
String confirmMessage = res.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_message,
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;
}

View File

@@ -30,9 +30,11 @@ import com.bumptech.glide.Glide;
import com.codewaves.stickyheadergrid.StickyHeaderGridLayoutManager;
import org.signal.core.util.DimensionUnit;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
import org.thoughtcrime.securesms.components.DeleteSyncEducationDialog;
import org.thoughtcrime.securesms.components.menu.ActionItem;
import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
@@ -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)
));
}
}

View File

@@ -21,10 +21,12 @@ import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.media
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.jobs.MultiDeviceDeleteSendSyncJob
import org.thoughtcrime.securesms.longmessage.resolveBody
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sms.MessageSender
import org.thoughtcrime.securesms.util.AttachmentUtil
import org.thoughtcrime.securesms.util.FeatureFlags
/**
* Repository for accessing the attachments in the encrypted database.
@@ -80,9 +82,12 @@ class MediaPreviewRepository {
}.subscribeOn(Schedulers.io()).toFlowable()
}
fun localDelete(context: Context, attachment: DatabaseAttachment): Completable {
fun localDelete(attachment: DatabaseAttachment): Completable {
return Completable.fromRunnable {
AttachmentUtil.deleteAttachment(context.applicationContext, attachment)
val deletedMessageRecord = AttachmentUtil.deleteAttachment(attachment)
if (deletedMessageRecord != null && FeatureFlags.deleteSyncEnabled()) {
MultiDeviceDeleteSendSyncJob.enqueueMessageDeletes(setOf(deletedMessageRecord))
}
}.subscribeOn(Schedulers.io())
}

View File

@@ -40,10 +40,12 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.concurrent.addTo
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.components.DeleteSyncEducationDialog
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.mention.MentionAnnotation
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment
@@ -65,12 +67,14 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.ContextUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.Debouncer
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.FullscreenHelper
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.MessageConstraintsUtil
import org.thoughtcrime.securesms.util.SaveAttachmentTask
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.StorageUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.visible
import java.util.Locale
@@ -585,10 +589,19 @@ class MediaPreviewV2Fragment : LoggingFragment(R.layout.fragment_media_preview_v
private fun deleteMedia(mediaItem: MediaTable.MediaRecord) {
val attachment: DatabaseAttachment = mediaItem.attachment ?: return
if (DeleteSyncEducationDialog.shouldShow()) {
DeleteSyncEducationDialog
.show(childFragmentManager)
.subscribe { deleteMedia(mediaItem) }
.addTo(lifecycleDisposable)
return
}
MaterialAlertDialogBuilder(requireContext()).apply {
setIcon(R.drawable.symbol_error_triangle_fill_24)
setTitle(R.string.MediaPreviewActivity_media_delete_confirmation_title)
setMessage(R.string.MediaPreviewActivity_media_delete_confirmation_message)
setMessage(if (TextSecurePreferences.isMultiDevice(requireContext()) && FeatureFlags.deleteSyncEnabled()) R.string.MediaPreviewActivity_media_delete_confirmation_message_linked_device else R.string.MediaPreviewActivity_media_delete_confirmation_message)
setCancelable(true)
setNegativeButton(android.R.string.cancel, null)
setPositiveButton(R.string.ConversationFragment_delete_for_me) { _, _ ->

View File

@@ -93,7 +93,7 @@ class MediaPreviewV2ViewModel : ViewModel() {
}
fun localDelete(context: Context, attachment: DatabaseAttachment): Completable {
return repository.localDelete(context, attachment).subscribeOn(Schedulers.io())
return repository.localDelete(attachment).subscribeOn(Schedulers.io())
}
fun jumpToFragment(context: Context, messageId: Long): Single<Intent> {

View File

@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.messages
import ProtoUtil.isNotEmpty
import com.squareup.wire.Message
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.signal.core.util.orNull
import org.signal.libsignal.protocol.message.DecryptionErrorMessage
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
@@ -25,8 +26,10 @@ import org.whispersystems.signalservice.internal.push.DataMessage
import org.whispersystems.signalservice.internal.push.DataMessage.Payment
import org.whispersystems.signalservice.internal.push.GroupContextV2
import org.whispersystems.signalservice.internal.push.StoryMessage
import org.whispersystems.signalservice.internal.push.SyncMessage
import org.whispersystems.signalservice.internal.push.SyncMessage.Sent
import org.whispersystems.signalservice.internal.push.TypingMessage
import org.whispersystems.signalservice.internal.util.Util
import java.util.Optional
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@@ -186,6 +189,11 @@ object SignalServiceProtoUtil {
return Money.picoMobileCoin(this)
}
fun SyncMessage.Builder.pad(length: Int = 512): SyncMessage.Builder {
padding(Util.getRandomLengthSecretBytes(length).toByteString())
return this
}
@Suppress("UNCHECKED_CAST")
inline fun <reified MessageType : Message<MessageType, BuilderType>, BuilderType : Message.Builder<MessageType, BuilderType>> Message.Builder<MessageType, BuilderType>.buildWith(block: BuilderType.() -> Unit): MessageType {
block(this as BuilderType)

View File

@@ -158,6 +158,7 @@ object SyncMessageProcessor {
syncMessage.callEvent != null -> handleSynchronizeCallEvent(syncMessage.callEvent!!, envelope.timestamp!!)
syncMessage.callLinkUpdate != null -> handleSynchronizeCallLink(syncMessage.callLinkUpdate!!, envelope.timestamp!!)
syncMessage.callLogEvent != null -> handleSynchronizeCallLogEvent(syncMessage.callLogEvent!!, envelope.timestamp!!)
syncMessage.deleteForMe != null -> handleSynchronizeDeleteForMe(context, syncMessage.deleteForMe!!, envelope.timestamp!!, earlyMessageCacheEntry)
else -> warn(envelope.timestamp!!, "Contains no known sync types...")
}
}
@@ -1451,4 +1452,146 @@ object SyncMessageProcessor {
}
}
}
private fun handleSynchronizeDeleteForMe(context: Context, deleteForMe: SyncMessage.DeleteForMe, envelopeTimestamp: Long, earlyMessageCacheEntry: EarlyMessageCacheEntry?) {
if (!FeatureFlags.deleteSyncEnabled()) {
warn(envelopeTimestamp, "Delete for me sync message dropped as support not enabled")
return
}
log(envelopeTimestamp, "Synchronize delete message messageDeletes=${deleteForMe.messageDeletes.size} conversationDeletes=${deleteForMe.conversationDeletes.size} localOnlyConversationDeletes=${deleteForMe.localOnlyConversationDeletes.size}")
if (deleteForMe.messageDeletes.isNotEmpty()) {
handleSynchronizeMessageDeletes(deleteForMe.messageDeletes, envelopeTimestamp, earlyMessageCacheEntry)
}
if (deleteForMe.conversationDeletes.isNotEmpty()) {
handleSynchronizeConversationDeletes(deleteForMe.conversationDeletes, envelopeTimestamp)
}
if (deleteForMe.localOnlyConversationDeletes.isNotEmpty()) {
handleSynchronizeLocalOnlyConversationDeletes(deleteForMe.localOnlyConversationDeletes, envelopeTimestamp)
}
ApplicationDependencies.getMessageNotifier().updateNotification(context)
}
private fun handleSynchronizeMessageDeletes(messageDeletes: List<SyncMessage.DeleteForMe.MessageDeletes>, envelopeTimestamp: Long, earlyMessageCacheEntry: EarlyMessageCacheEntry?) {
val messagesToDelete: List<MessageTable.SyncMessageId> = messageDeletes
.asSequence()
.map { it.messages }
.flatten()
.mapNotNull { it.toSyncMessageId(envelopeTimestamp) }
.toList()
val unhandled: List<MessageTable.SyncMessageId> = SignalDatabase.messages.deleteMessages(messagesToDelete)
for (syncMessage in unhandled) {
warn(envelopeTimestamp, "[handleSynchronizeDeleteForMe] Could not find matching message! timestamp: ${syncMessage.timetamp} author: ${syncMessage.recipientId}")
if (earlyMessageCacheEntry != null) {
ApplicationDependencies.getEarlyMessageCache().store(syncMessage.recipientId, syncMessage.timetamp, earlyMessageCacheEntry)
}
}
if (unhandled.isNotEmpty() && earlyMessageCacheEntry != null) {
PushProcessEarlyMessagesJob.enqueue()
}
}
private fun handleSynchronizeConversationDeletes(conversationDeletes: List<SyncMessage.DeleteForMe.ConversationDelete>, envelopeTimestamp: Long) {
for (delete in conversationDeletes) {
val threadRecipientId: RecipientId? = delete.conversation?.toRecipientId()
if (threadRecipientId == null) {
warn(envelopeTimestamp, "[handleSynchronizeDeleteForMe] Could not find matching conversation recipient")
continue
}
val threadId = SignalDatabase.threads.getThreadIdFor(threadRecipientId)
if (threadId == null) {
log(envelopeTimestamp, "[handleSynchronizeDeleteForMe] No thread for matching conversation for recipient: $threadRecipientId")
continue
}
val mostRecentMessagesToDelete: List<MessageTable.SyncMessageId> = delete.mostRecentMessages.mapNotNull { it.toSyncMessageId(envelopeTimestamp) }
val latestReceivedAt = SignalDatabase.messages.getLatestReceivedAt(threadId, mostRecentMessagesToDelete)
if (latestReceivedAt != null) {
SignalDatabase.threads.trimThread(threadId = threadId, syncThreadTrimDeletes = false, trimBeforeDate = latestReceivedAt, inclusive = true)
if (delete.isFullDelete == true) {
val deleted = SignalDatabase.threads.deleteConversationIfContainsOnlyLocal(threadId)
if (deleted) {
log(envelopeTimestamp, "[handleSynchronizeDeleteForMe] Deleted thread with only local remaining")
}
}
} else {
warn(envelopeTimestamp, "[handleSynchronizeDeleteForMe] Unable to find most recent received at timestamp for recipient: $threadRecipientId thread: $threadId")
}
}
}
private fun handleSynchronizeLocalOnlyConversationDeletes(conversationDeletes: List<SyncMessage.DeleteForMe.LocalOnlyConversationDelete>, envelopeTimestamp: Long) {
for (delete in conversationDeletes) {
val threadRecipientId: RecipientId? = delete.conversation?.toRecipientId()
if (threadRecipientId == null) {
warn(envelopeTimestamp, "[handleSynchronizeDeleteForMe] Could not find matching conversation recipient")
continue
}
val threadId = SignalDatabase.threads.getThreadIdFor(threadRecipientId)
if (threadId == null) {
log(envelopeTimestamp, "[handleSynchronizeDeleteForMe] No thread for matching conversation for recipient: $threadRecipientId")
continue
}
val deleted = SignalDatabase.threads.deleteConversationIfContainsOnlyLocal(threadId)
if (!deleted) {
log(envelopeTimestamp, "[handleSynchronizeDeleteForMe] Thread is not local only or already empty recipient: $threadRecipientId thread: $threadId")
}
}
}
private fun SyncMessage.DeleteForMe.ConversationIdentifier.toRecipientId(): RecipientId? {
return when {
threadGroupId != null -> {
try {
val groupId: GroupId = GroupId.push(threadGroupId!!)
Recipient.externalPossiblyMigratedGroup(groupId).id
} catch (e: BadGroupIdException) {
null
}
}
threadAci != null -> {
ServiceId.parseOrNull(threadAci)?.let {
SignalDatabase.recipients.getOrInsertFromServiceId(it)
}
}
threadE164 != null -> {
SignalDatabase.recipients.getOrInsertFromE164(threadE164!!)
}
else -> null
}
}
private fun SyncMessage.DeleteForMe.AddressableMessage.toSyncMessageId(envelopeTimestamp: Long): MessageTable.SyncMessageId? {
return if (this.sentTimestamp != null && (this.authorAci != null || this.authorE164 != null)) {
val serviceId = ServiceId.parseOrNull(this.authorAci)
val id = if (serviceId != null) {
SignalDatabase.recipients.getOrInsertFromServiceId(serviceId)
} else {
SignalDatabase.recipients.getOrInsertFromE164(this.authorE164!!)
}
MessageTable.SyncMessageId(id, this.sentTimestamp!!)
} else {
warn(envelopeTimestamp, "[handleSynchronizeDeleteForMe] Invalid delete sync missing timestamp or author")
null
}
}
}

View File

@@ -69,22 +69,26 @@ public class AttachmentUtil {
/**
* Deletes the specified attachment. If its the only attachment for its linked message, the entire
* message is deleted.
*
* @return message record of deleted message if a message is deleted
*/
@WorkerThread
public static void deleteAttachment(@NonNull Context context,
@NonNull DatabaseAttachment attachment)
{
public static @Nullable MessageRecord deleteAttachment(@NonNull DatabaseAttachment attachment) {
AttachmentId attachmentId = attachment.attachmentId;
long mmsId = attachment.mmsId;
int attachmentCount = SignalDatabase.attachments()
.getAttachmentsForMessage(mmsId)
.size();
MessageRecord deletedMessageRecord = null;
if (attachmentCount <= 1) {
deletedMessageRecord = SignalDatabase.messages().getMessageRecordOrNull(mmsId);
SignalDatabase.messages().deleteMessage(mmsId);
} else {
SignalDatabase.attachments().deleteAttachment(attachmentId);
}
return deletedMessageRecord;
}
private static boolean isNonDocumentType(String contentType) {

View File

@@ -8,6 +8,7 @@ import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.jobs.MultiDeviceDeleteSendSyncJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.sms.MessageSender
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask
@@ -100,13 +101,19 @@ object DeleteDialog {
R.string.ConversationFragment_deleting_messages
) {
override fun doInBackground(vararg params: Void?): Boolean {
return messageRecords.map { record ->
if (record.isMms) {
SignalDatabase.messages.deleteMessage(record.id)
} else {
SignalDatabase.messages.deleteMessage(record.id)
var threadDeleted = false
messageRecords.forEach { record ->
if (SignalDatabase.messages.deleteMessage(record.id)) {
threadDeleted = true
}
}.any { it }
}
if (FeatureFlags.deleteSyncEnabled()) {
MultiDeviceDeleteSendSyncJob.enqueueMessageDeletes(messageRecords)
}
return threadDeleted
}
override fun onPostExecute(result: Boolean?) {

View File

@@ -131,6 +131,7 @@ public final class FeatureFlags {
private static final String LIBSIGNAL_WEB_SOCKET_ENABLED = "android.libsignalWebSocketEnabled";
private static final String RESTORE_POST_REGISTRATION = "android.registration.restorePostRegistration";
private static final String LIBSIGNAL_WEB_SOCKET_SHADOW_PCT = "android.libsignalWebSocketShadowingPercentage";
private static final String DELETE_SYNC_SEND_RECEIVE = "android.deleteSyncSendReceive";
/**
* We will only store remote values for flags in this set. If you want a flag to be controllable
@@ -211,7 +212,8 @@ public final class FeatureFlags {
LINKED_DEVICE_LIFESPAN_SECONDS,
CAMERAX_CUSTOM_CONTROLLER,
LIBSIGNAL_WEB_SOCKET_ENABLED,
LIBSIGNAL_WEB_SOCKET_SHADOW_PCT
LIBSIGNAL_WEB_SOCKET_SHADOW_PCT,
DELETE_SYNC_SEND_RECEIVE
);
@VisibleForTesting
@@ -287,7 +289,8 @@ public final class FeatureFlags {
CDSI_LIBSIGNAL_NET,
RX_MESSAGE_SEND,
LINKED_DEVICE_LIFESPAN_SECONDS,
CAMERAX_CUSTOM_CONTROLLER
CAMERAX_CUSTOM_CONTROLLER,
DELETE_SYNC_SEND_RECEIVE
);
/**
@@ -766,6 +769,11 @@ public final class FeatureFlags {
return Math.max(0, Math.min(value, 100));
}
/** Whether or not to delete syncing is enabled. */
public static boolean deleteSyncEnabled() {
return getBoolean(DELETE_SYNC_SEND_RECEIVE, false);
}
/** Only for rendering debug info. */
public static synchronized @NonNull Map<String, Object> getMemoryValues() {
return new TreeMap<>(REMOTE_VALUES);