Parallelize file deletion when turning off local backups.

This commit is contained in:
Alex Hart
2026-04-20 16:56:12 -03:00
parent 2b66d7485a
commit bde1a94122
7 changed files with 146 additions and 19 deletions

View File

@@ -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<DocumentFile>()
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<String, DocumentFileInfo> = 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 */

View File

@@ -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)
}
}
}

View File

@@ -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
)

View File

@@ -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) }
}
}

View File

@@ -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)

View File

@@ -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);
}
}
}