diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index 30bb756fd7..615f2b2f57 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -4379,18 +4379,24 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat } } - threads - .forEach { threadId -> - SignalDatabase.threads.update(threadId, unarchive = false) - notifyConversationListeners(threadId) - } + flushBulkDeleteNotifications(threads) + + return unhandled + } + + /** + * Helper to notify various database observers after doing deletions via [deleteMessage] with notifying disabled. + */ + fun flushBulkDeleteNotifications(touchedThreadIds: Set) { + touchedThreadIds.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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaActions.java b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaActions.java deleted file mode 100644 index adaa3a3412..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaActions.java +++ /dev/null @@ -1,89 +0,0 @@ -package org.thoughtcrime.securesms.mediaoverview; - -import android.content.Context; -import android.content.res.Resources; - -import androidx.annotation.NonNull; -import androidx.fragment.app.Fragment; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.attachments.AttachmentSaver; -import org.thoughtcrime.securesms.database.MediaTable; -import org.thoughtcrime.securesms.database.SignalDatabase; -import org.thoughtcrime.securesms.database.model.MessageRecord; -import org.thoughtcrime.securesms.jobs.MultiDeviceDeleteSyncJob; -import org.thoughtcrime.securesms.util.AttachmentUtil; -import org.signal.core.util.Util; -import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask; - -import java.util.Collection; -import java.util.HashSet; -import java.util.Set; - -import io.reactivex.rxjava3.core.Completable; - -final class MediaActions { - - private MediaActions() { - } - - static Completable handleSaveMedia(@NonNull Fragment fragment, - @NonNull Collection mediaRecords) - { - return new AttachmentSaver(fragment).saveAttachmentsRx(mediaRecords); - } - - static void handleDeleteMedia(@NonNull Context context, - @NonNull Collection mediaRecords) - { - int recordCount = mediaRecords.size(); - Resources res = context.getResources(); - 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); - - MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(context).setTitle(confirmTitle) - .setMessage(confirmMessage) - .setCancelable(true); - - builder.setPositiveButton(R.string.delete, (dialogInterface, i) -> - new ProgressDialogAsyncTask(context, - R.string.MediaOverviewActivity_Media_delete_progress_title, - R.string.MediaOverviewActivity_Media_delete_progress_message) - { - @Override - protected Void doInBackground(MediaTable.MediaRecord... records) { - if (records == null || records.length == 0) { - return null; - } - - Set deletedMessageRecords = new HashSet<>(records.length); - for (MediaTable.MediaRecord record : records) { - if (record.getAttachment() != null) { - MessageRecord deleted = AttachmentUtil.deleteAttachment(record.getAttachment()); - if (deleted != null) { - deletedMessageRecords.add(deleted); - } - } else { - MessageRecord deleted = SignalDatabase.messages().getMessageRecordOrNull(record.getMessageId()); - SignalDatabase.messages().deleteMessage(record.getMessageId()); - if (deleted != null) { - deletedMessageRecords.add(deleted); - } - } - } - - if (Util.hasItems(deletedMessageRecords)) { - MultiDeviceDeleteSyncJob.enqueueMessageDeletes(deletedMessageRecords); - } - - return null; - } - - }.execute(mediaRecords.toArray(new MediaTable.MediaRecord[0])) - ); - builder.setNegativeButton(android.R.string.cancel, null); - builder.show(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaActions.kt b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaActions.kt new file mode 100644 index 0000000000..8de8098c8a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaActions.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.mediaoverview + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.reactivex.rxjava3.core.Completable +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.attachments.AttachmentSaver +import org.thoughtcrime.securesms.database.MediaTable + +internal object MediaActions { + + @JvmStatic + fun handleSaveMedia(fragment: Fragment, mediaRecords: Collection): Completable { + return AttachmentSaver(fragment).saveAttachmentsRx(mediaRecords) + } + + @JvmStatic + fun handleDeleteMedia(fragment: Fragment, mediaRecords: Collection) { + val recordCount = mediaRecords.size + val res = fragment.resources + val confirmTitle = res.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_title, recordCount, recordCount) + val confirmMessage = res.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_message, recordCount, recordCount) + + MaterialAlertDialogBuilder(fragment.requireContext()) + .setTitle(confirmTitle) + .setMessage(confirmMessage) + .setCancelable(true) + .setPositiveButton(R.string.delete) { _, _ -> + val viewModel = ViewModelProvider(fragment)[MediaDeleteProgressViewModel::class.java] + viewModel.start(mediaRecords) + MediaDeleteProgressDialogFragment.show(fragment) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaDeleteProgressDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaDeleteProgressDialogFragment.kt new file mode 100644 index 0000000000..e7c1f177b4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaDeleteProgressDialogFragment.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.mediaoverview + +import android.app.Dialog +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.signal.core.ui.compose.ComposeDialogFragment +import org.thoughtcrime.securesms.R + +/** + * Non-cancelable Compose dialog that observes [MediaDeleteProgressViewModel] and shows + * determinate "X / Y" progress while a bulk media delete runs. Dismisses itself when the + * underlying job completes. + */ +class MediaDeleteProgressDialogFragment : ComposeDialogFragment() { + + private val viewModel: MediaDeleteProgressViewModel by viewModels(ownerProducer = { requireParentFragment() }) + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + isCancelable = false + return super.onCreateDialog(savedInstanceState).apply { + setCanceledOnTouchOutside(false) + window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + } + } + + @Composable + override fun DialogContent() { + val state by viewModel.state.collectAsStateWithLifecycle() + + LaunchedEffect(state.isDone) { + if (state.isDone) { + dismissAllowingStateLoss() + } + } + + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 8.dp, + modifier = Modifier.width(280.dp) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 24.dp) + ) { + Text( + text = stringResource(R.string.MediaOverviewActivity_Media_delete_progress_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + + Box(contentAlignment = Alignment.Center) { + val total = state.total + val processed = state.processed + if (total > 0) { + CircularProgressIndicator( + progress = { processed.toFloat() / total }, + modifier = Modifier.size(56.dp) + ) + } else { + CircularProgressIndicator(modifier = Modifier.size(56.dp)) + } + } + + Text( + text = stringResource( + R.string.MediaOverviewActivity_Media_delete_progress_count, + state.processed, + state.total.coerceAtLeast(state.processed) + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + companion object { + private const val TAG = "MediaDeleteProgressDialog" + + fun show(parent: Fragment) { + MediaDeleteProgressDialogFragment().show(parent.childFragmentManager, TAG) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaDeleteProgressViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaDeleteProgressViewModel.kt new file mode 100644 index 0000000000..a02fd35447 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaDeleteProgressViewModel.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.mediaoverview + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.signal.core.util.concurrent.SignalDispatchers +import org.thoughtcrime.securesms.database.MediaTable +import org.thoughtcrime.securesms.jobs.MultiDeviceDeleteSyncJob +import org.thoughtcrime.securesms.util.AttachmentUtil + +/** + * Drives a bulk media-delete operation and exposes [State] so a Compose dialog can show real + * X / Y progress instead of an indeterminate spinner. + */ +class MediaDeleteProgressViewModel : ViewModel() { + + data class State( + val processed: Int = 0, + val total: Int = 0, + val isDone: Boolean = false + ) + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + + private var job: Job? = null + + fun start(records: Collection) { + if (job?.isActive == true) return + val snapshot = records.toList() + _state.value = State(total = snapshot.size) + + job = viewModelScope.launch(SignalDispatchers.IO) { + val deletedMessageRecords = AttachmentUtil.deleteAttachments(snapshot) { processed -> + _state.update { it.copy(processed = processed) } + } + + if (deletedMessageRecords.isNotEmpty()) { + MultiDeviceDeleteSyncJob.enqueueMessageDeletes(deletedMessageRecords) + } + + _state.update { it.copy(isDone = true) } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewPageFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewPageFragment.java index 9d08610350..2723cebd99 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewPageFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewPageFragment.java @@ -439,7 +439,7 @@ public final class MediaOverviewPageFragment extends LoggingFragment ); return; } - MediaActions.handleDeleteMedia(requireContext(), Collections.singleton(mediaRecord)); + MediaActions.handleDeleteMedia(this, Collections.singleton(mediaRecord)); } private void handleDeleteSelectedMedia() { @@ -451,7 +451,7 @@ public final class MediaOverviewPageFragment extends LoggingFragment return; } - MediaActions.handleDeleteMedia(requireContext(), getListAdapter().getSelectedMedia()); + MediaActions.handleDeleteMedia(this, getListAdapter().getSelectedMedia()); exitMultiSelect(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.kt index 925029c5a5..629d139c6f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.kt @@ -11,6 +11,7 @@ import org.signal.core.util.mebiBytes import org.thoughtcrime.securesms.attachments.Attachment import org.thoughtcrime.securesms.attachments.Cdn import org.thoughtcrime.securesms.attachments.DatabaseAttachment +import org.thoughtcrime.securesms.database.MediaTable import org.thoughtcrime.securesms.database.NoSuchMessageException import org.thoughtcrime.securesms.database.SignalDatabase.Companion.attachments import org.thoughtcrime.securesms.database.SignalDatabase.Companion.messages @@ -105,6 +106,49 @@ object AttachmentUtil { return null } + /** + * Version of [deleteAttachment] optimized for bulk-delete. Suppresses observer notifications and bulk notifies at the end. + * + * @param onProgress invoked with the running count (1-based) after each item. + * @return the set of [MessageRecord]s that were fully deleted (i.e. items where the attachment + * was the last one on its message) + */ + @JvmStatic + @WorkerThread + fun deleteAttachments(records: Collection, onProgress: (Int) -> Unit): Set { + val deletedMessageRecords = mutableSetOf() + val touchedThreadIds = mutableSetOf() + + records.forEachIndexed { index, record -> + val attachment = record.attachment + if (attachment != null) { + val mmsId = attachment.mmsId + val attachmentCount = attachments.getAttachmentsForMessage(mmsId).size + + // If it's the only attachment, just delete the message + if (attachmentCount <= 1) { + val deletedMessageRecord = messages.getMessageRecordOrNull(mmsId) + if (deletedMessageRecord != null) { + messages.deleteMessage(mmsId, deletedMessageRecord.threadId, notify = false, updateThread = false) + touchedThreadIds += deletedMessageRecord.threadId + deletedMessageRecords += deletedMessageRecord + } + } else { + attachments.deleteAttachment(attachment.attachmentId) + enqueueAttachmentDelete(messages.getMessageRecordOrNull(mmsId), attachment) + } + } else { + Log.w(TAG, "No attachment found for message ${record.messageId}") + } + + onProgress(index + 1) + } + + messages.flushBulkDeleteNotifications(touchedThreadIds) + + return deletedMessageRecords + } + private fun allowedForType(allowedTypes: Set, typeKey: String?, label: String): Boolean { val notInCall = NotInCallConstraint.isNotInConnectedCall() val typeAllowed = typeKey != null && allowedTypes.contains(typeKey) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 416a53e7da..260cf4e59c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1825,6 +1825,8 @@ Deleting Deleting messages… + + %1$d of %2$d Sort by Newest Oldest