Improve deletion in all media screen.

This commit is contained in:
Greyson Parrelli
2026-04-29 11:46:25 -04:00
parent e11f7225d3
commit 41b833e788
8 changed files with 273 additions and 98 deletions
@@ -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<Long>) {
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<MessageRecord> {
@@ -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<MediaTable.MediaRecord> mediaRecords)
{
return new AttachmentSaver(fragment).saveAttachmentsRx(mediaRecords);
}
static void handleDeleteMedia(@NonNull Context context,
@NonNull Collection<MediaTable.MediaRecord> 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<MediaTable.MediaRecord, Void, Void>(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<MessageRecord> 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();
}
}
@@ -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<MediaTable.MediaRecord>): Completable {
return AttachmentSaver(fragment).saveAttachmentsRx(mediaRecords)
}
@JvmStatic
fun handleDeleteMedia(fragment: Fragment, mediaRecords: Collection<MediaTable.MediaRecord>) {
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()
}
}
@@ -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)
}
}
}
@@ -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> = _state.asStateFlow()
private var job: Job? = null
fun start(records: Collection<MediaTable.MediaRecord>) {
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) }
}
}
}
@@ -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();
}
@@ -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<MediaTable.MediaRecord>, onProgress: (Int) -> Unit): Set<MessageRecord> {
val deletedMessageRecords = mutableSetOf<MessageRecord>()
val touchedThreadIds = mutableSetOf<Long>()
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<String>, typeKey: String?, label: String): Boolean {
val notInCall = NotInCallConstraint.isNotInConnectedCall()
val typeAllowed = typeKey != null && allowedTypes.contains(typeKey)
+2
View File
@@ -1825,6 +1825,8 @@
</plurals>
<string name="MediaOverviewActivity_Media_delete_progress_title">Deleting</string>
<string name="MediaOverviewActivity_Media_delete_progress_message">Deleting messages…</string>
<!-- Format string showing current vs. total items being deleted, e.g. "12 of 207" -->
<string name="MediaOverviewActivity_Media_delete_progress_count">%1$d of %2$d</string>
<string name="MediaOverviewActivity_Sort_by">Sort by</string>
<string name="MediaOverviewActivity_Newest">Newest</string>
<string name="MediaOverviewActivity_Oldest">Oldest</string>