mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-24 10:51:27 +01:00
Add Delete for Me sync support.
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user