mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-14 12:10:36 +01:00
Improve deletion in all media screen.
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
+115
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user