From bde1a941224c06ab782b1c34791bcc674b758231 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Mon, 20 Apr 2026 16:56:12 -0300 Subject: [PATCH] Parallelize file deletion when turning off local backups. --- .../backup/v2/local/ArchiveFileSystem.kt | 100 ++++++++++++++++-- .../local/LocalBackupsSettingsScreen.kt | 10 +- .../local/LocalBackupsSettingsState.kt | 4 +- .../backups/local/LocalBackupsViewModel.kt | 8 +- .../securesms/jobs/LocalArchiveJob.kt | 8 +- .../securesms/util/BackupUtil.java | 6 +- .../org/signal/core/ui/compose/Dialogs.kt | 29 +++++ 7 files changed, 146 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/local/ArchiveFileSystem.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/local/ArchiveFileSystem.kt index 535e114eee..11cb93a15b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/local/ArchiveFileSystem.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/local/ArchiveFileSystem.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.supervisorScope import org.signal.archive.local.ArchivedFilesReader import org.signal.core.models.backup.MediaName import org.signal.core.util.Stopwatch @@ -122,6 +123,57 @@ class ArchiveFileSystem private constructor(private val context: Context, root: fun openInputStream(context: Context, uri: Uri): InputStream? { return context.contentResolver.openInputStream(uri) } + + /** + * Recursively delete the entire SignalBackups directory using parallelized SAF calls. + */ + @JvmStatic + @JvmOverloads + fun deleteAll(signalBackupsDir: DocumentFile, progressListener: AllFilesProgressListener? = null) { + Log.i(TAG, "Deleting all backup data") + + val units = mutableListOf() + for (child in signalBackupsDir.listFiles()) { + if (child.isDirectory && child.name == "files") { + units += child.listFiles() + } else { + units += child + } + } + + if (units.isEmpty()) { + signalBackupsDir.delete() + return + } + + val total = units.size + val completed = AtomicInteger(0) + val deleted = AtomicInteger(0) + val concurrency = Runtime.getRuntime().availableProcessors().coerceAtMost(8) + val chunkSize = ((total + concurrency - 1) / concurrency).coerceAtLeast(1) + + runBlocking { + coroutineScope { + units.chunked(chunkSize).map { chunk -> + async(Dispatchers.IO) { + for (unit in chunk) { + if (unit.delete()) { + deleted.incrementAndGet() + } + progressListener?.onProgress(completed.incrementAndGet(), total) + } + } + }.awaitAll() + } + } + + for (child in signalBackupsDir.listFiles()) { + child.delete() + } + signalBackupsDir.delete() + + Log.d(TAG, "Deleted ${deleted.get()}/$total top-level units") + } } private val signalBackups: DocumentFile @@ -236,8 +288,14 @@ class ArchiveFileSystem private constructor(private val context: Context, root: /** * Clean up unused files in the shared files directory leveraged across all current snapshots. A file * is unused if it is not referenced directly by any current snapshots. + * + * @param allFilesProgressListener reports progress of the enumeration phase (fast, 256 shards) + * @param deletionProgressListener reports progress of the deletion phase (slow, potentially thousands of SAF calls). Fires from multiple threads. */ - fun deleteUnusedFiles(allFilesProgressListener: AllFilesProgressListener? = null) { + fun deleteUnusedFiles( + allFilesProgressListener: AllFilesProgressListener? = null, + deletionProgressListener: AllFilesProgressListener? = null + ) { Log.i(TAG, "Deleting unused files") val allFiles: MutableMap = filesFileSystem.allFiles(allFilesProgressListener).toMutableMap() @@ -251,16 +309,38 @@ class ArchiveFileSystem private constructor(private val context: Context, root: } } - var deleted = 0 - allFiles - .values - .forEach { - if (it.documentFile.delete()) { - deleted++ - } - } + val toDelete = allFiles.values.toList() + val total = toDelete.size + if (total == 0) { + Log.d(TAG, "Cleanup removed 0/0 files") + return + } - Log.d(TAG, "Cleanup removed $deleted/${allFiles.size} files") + val deleted = AtomicInteger(0) + val completed = AtomicInteger(0) + val concurrency = Runtime.getRuntime().availableProcessors().coerceAtMost(8) + val chunkSize = ((total + concurrency - 1) / concurrency).coerceAtLeast(1) + + runBlocking { + supervisorScope { + toDelete.chunked(chunkSize).map { chunk -> + async(Dispatchers.IO) { + try { + for (info in chunk) { + if (info.documentFile.delete()) { + deleted.incrementAndGet() + } + deletionProgressListener?.onProgress(completed.incrementAndGet(), total) + } + } catch (e: Exception) { + Log.w(TAG, "Failed to clean up a chunk.", e) + } + } + }.awaitAll() + } + } + + Log.d(TAG, "Cleanup removed ${deleted.get()}/$total files") } /** Useful metadata for a given archive snapshot */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsSettingsScreen.kt index 59bf297327..3faef41950 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsSettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsSettingsScreen.kt @@ -233,7 +233,15 @@ internal fun LocalBackupsSettingsScreen( } if (state.isDeleting) { - Dialogs.IndeterminateProgressDialog(message = stringResource(id = R.string.BackupDialog_deleting_local_backup)) + val message = stringResource(id = R.string.BackupDialog_deleting_local_backup) + if (state.deleteTotal > 0) { + Dialogs.DeterminateProgressDialog( + message = message, + progress = { state.deleteCompleted.toFloat() / state.deleteTotal } + ) + } else { + Dialogs.IndeterminateProgressDialog(message = message) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsSettingsState.kt index 04fd8c647c..dce43499a8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsSettingsState.kt @@ -19,5 +19,7 @@ data class LocalBackupsSettingsState( val folderDisplayName: String? = null, val scheduleTimeLabel: String? = null, val progress: LocalBackupCreationProgress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle()), - val isDeleting: Boolean = false + val isDeleting: Boolean = false, + val deleteCompleted: Int = 0, + val deleteTotal: Int = 0 ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsViewModel.kt index 8439ceb849..d94903073b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsViewModel.kt @@ -113,7 +113,7 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler { } fun turnOffAndDelete(context: Context) { - internalSettingsState.update { it.copy(isDeleting = true) } + internalSettingsState.update { it.copy(isDeleting = true, deleteCompleted = 0, deleteTotal = 0) } viewModelScope.launch { withContext(Dispatchers.IO) { @@ -121,10 +121,12 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler { val path = SignalStore.backup.newLocalBackupsDirectory SignalStore.backup.newLocalBackupsDirectory = null AppDependencies.jobManager.cancelAllInQueue(LocalBackupJob.QUEUE) - BackupUtil.deleteUnifiedBackups(context, path) + BackupUtil.deleteUnifiedBackups(context, path) { completed, total -> + internalSettingsState.update { it.copy(deleteCompleted = completed, deleteTotal = total) } + } } - internalSettingsState.update { it.copy(isDeleting = false) } + internalSettingsState.update { it.copy(isDeleting = false, deleteCompleted = 0, deleteTotal = 0) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalArchiveJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalArchiveJob.kt index 8e94e982ad..ed92f1d380 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalArchiveJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalArchiveJob.kt @@ -137,9 +137,11 @@ class LocalArchiveJob internal constructor(parameters: Parameters) : Job(paramet archiveFileSystem.deleteOldBackups() stopwatch.split("delete-old") - archiveFileSystem.deleteUnusedFiles { completed, total -> - setProgress(LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.FINALIZING, frameExportCount = completed.toLong(), frameTotalCount = total.toLong())), notification) - } + archiveFileSystem.deleteUnusedFiles( + deletionProgressListener = { completed, total -> + setProgress(LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.FINALIZING, frameExportCount = completed.toLong(), frameTotalCount = total.toLong())), notification) + } + ) stopwatch.split("delete-unused") stopwatch.stop(TAG) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java index de47bf70df..f4dc733166 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java @@ -93,6 +93,10 @@ public class BackupUtil { } public static void deleteUnifiedBackups(@NonNull Context context, @Nullable String backupDirectoryPath) { + deleteUnifiedBackups(context, backupDirectoryPath, null); + } + + public static void deleteUnifiedBackups(@NonNull Context context, @Nullable String backupDirectoryPath, @Nullable org.thoughtcrime.securesms.backup.v2.local.AllFilesProgressListener progressListener) { if (backupDirectoryPath != null) { Uri backupDirectoryUri = Uri.parse(backupDirectoryPath); DocumentFile backupDirectory = DocumentFile.fromTreeUri(context, backupDirectoryUri); @@ -104,7 +108,7 @@ public class BackupUtil { for (DocumentFile file : backupDirectory.listFiles()) { if (file.isDirectory() && Objects.equals(file.getName(), ArchiveFileSystem.MAIN_DIRECTORY_NAME)) { - file.delete(); + ArchiveFileSystem.deleteAll(file, progressListener); } } } diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt b/core/ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt index 1bbcab410f..33ffa700bd 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt @@ -334,6 +334,35 @@ object Dialogs { ) } + /** + * Progress spinner that shows [message] below a determinate circular progress indicator + * driven by [progress]. Non-cancellable; use for short actions where the total work is known. + */ + @Composable + fun DeterminateProgressDialog(message: String, progress: () -> Float) { + BaseAlertDialog( + onDismissRequest = {}, + confirmButton = {}, + dismissButton = {}, + text = { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + Spacer(modifier = Modifier.size(24.dp)) + CircularProgressIndicator(progress = progress) + Spacer(modifier = Modifier.size(20.dp)) + Text(text = message, textAlign = TextAlign.Center) + } + }, + modifier = Modifier + .size(200.dp) + ) + } + /** * Customizable progress spinner that can be dismissed while showing [message] * and [caption] below the spinner to let users know an action is completing