From cf64986371a82e6fbdee46eacf8ad3c525e1cab6 Mon Sep 17 00:00:00 2001 From: OneWay Date: Tue, 8 Sep 2026 00:22:27 +0800 Subject: [PATCH] fix: clean up orphaned and partial files after cancelled downloads Treat inactive download contexts as cancellation, delete the partial file, and rethrow the cancellation so the pipeline does not retain an incomplete payload. Also remove orphaned .dsl/.dz files left behind by cancelled downloads. Run the partial/orphan deletions on NonCancellable: the cleanup is invoked from already-cancelled coroutines, where withContext(Dispatchers.IO) aborts before executing the block and silently leaves the files behind. --- .../progress/DictionaryPipelineCoordinator.kt | 22 +++++++++++- .../repository/LocalDictionaryRepository.kt | 15 ++++---- .../repository/DictionaryDownloader.kt | 35 +++++++++++-------- .../repository/DictionaryExtractor.kt | 30 ++++++++++++++++ 4 files changed, 80 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/example/research/common/progress/DictionaryPipelineCoordinator.kt b/app/src/main/java/com/example/research/common/progress/DictionaryPipelineCoordinator.kt index 74e3591..883f038 100644 --- a/app/src/main/java/com/example/research/common/progress/DictionaryPipelineCoordinator.kt +++ b/app/src/main/java/com/example/research/common/progress/DictionaryPipelineCoordinator.kt @@ -61,7 +61,7 @@ class DictionaryPipelineCoordinator( private fun runPipeline(isImport: Boolean) { pipelineJob?.cancel() pipelineJob = scope.launch(launchDispatcher) { - val dir = if (isImport) File(dictionaryPath()) else null + val dir = File(dictionaryPath()) val filesBeforeReindex = dir?.listFiles()?.map { it.name }?.toSet() ?: emptySet() try { triggerReindexing(isImport) @@ -83,6 +83,7 @@ class DictionaryPipelineCoordinator( } cleanupNewFiles(dir, filesBeforeReindex) } + cleanupOrphanedFiles(dir) throw e } catch (e: Exception) { ui.showError() @@ -115,6 +116,25 @@ class DictionaryPipelineCoordinator( } } + private fun cleanupOrphanedFiles(dir: File?) { + try { + dir?.listFiles()?.forEach { file -> + val n = file.name.lowercase() + val isRawDsl = n.endsWith(".dsl") && !n.endsWith(".dsl.dz") && !n.endsWith(".dsl.gz") + val isBareDz = n.endsWith(".dz") && !n.endsWith(".dsl.dz") + val orphaned = when { + isBareDz -> true + isRawDsl -> !File(file.parent, file.name + ".dz").exists() + else -> false + } + if (orphaned) { + try { file.delete() } catch (_: Exception) {} + } + } + } catch (_: Exception) { + } + } + private fun cleanupNewFiles(dir: File?, filesBeforeSnapshot: Set) { try { dir?.listFiles()?.forEach { file -> diff --git a/app/src/main/java/com/example/research/data/repository/LocalDictionaryRepository.kt b/app/src/main/java/com/example/research/data/repository/LocalDictionaryRepository.kt index d367e42..be7d581 100644 --- a/app/src/main/java/com/example/research/data/repository/LocalDictionaryRepository.kt +++ b/app/src/main/java/com/example/research/data/repository/LocalDictionaryRepository.kt @@ -390,12 +390,15 @@ class LocalDictionaryRepository( } ?: emptyList() } - private fun isDictionaryPayloadFile(file: File): Boolean = - file.isFile && ( - file.name.endsWith(".dsl") || - file.name.endsWith(".dsl.dz") || - file.name.endsWith(".dsl.gz") - ) + private fun isDictionaryPayloadFile(file: File): Boolean { + if (!file.isFile) return false + val n = file.name.lowercase() + if (n.endsWith(".dsl.dz") || n.endsWith(".dsl.gz")) return true + if (n.endsWith(".dsl")) { + return File(file.parent, file.name + ".dz").exists() + } + return false + } suspend fun search(query: String): OperationResult> = withContext(defaultDispatcher) { if (query.isBlank()) return@withContext OperationResult.Success(emptyList()) diff --git a/app/src/main/java/com/example/research/feature/download/repository/DictionaryDownloader.kt b/app/src/main/java/com/example/research/feature/download/repository/DictionaryDownloader.kt index 06d193c..981476b 100644 --- a/app/src/main/java/com/example/research/feature/download/repository/DictionaryDownloader.kt +++ b/app/src/main/java/com/example/research/feature/download/repository/DictionaryDownloader.kt @@ -15,8 +15,11 @@ import com.example.research.feature.download.config.DictionaryConfig import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.isActive import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import kotlinx.coroutines.yield @@ -58,20 +61,22 @@ class DictionaryDownloader( ) { if (fileUri == null) return - runCatching { - val fileToDelete = fileStorageManager.getDocumentFile(fileUri) - if (fileToDelete != null && fileToDelete.exists()) { - val deleted = fileStorageManager.forceDeleteFile(fileToDelete, fileName) - if (!deleted) { - fileStorageManager.forceDeleteFileByName(folderUri, fileName) - } - } - }.onFailure { e -> - Log.e(TAG, "Failed to delete partial file: $fileUri", e) + withContext(NonCancellable) { runCatching { - fileStorageManager.forceDeleteFileByName(folderUri, fileName) - }.onFailure { deleteError -> - Log.e(TAG, "Failed to delete file by name: $fileName", deleteError) + val fileToDelete = fileStorageManager.getDocumentFile(fileUri) + if (fileToDelete != null && fileToDelete.exists()) { + val deleted = fileStorageManager.forceDeleteFile(fileToDelete, fileName) + if (!deleted) { + fileStorageManager.forceDeleteFileByName(folderUri, fileName) + } + } + }.onFailure { e -> + Log.e(TAG, "Failed to delete partial file: $fileUri", e) + runCatching { + fileStorageManager.forceDeleteFileByName(folderUri, fileName) + }.onFailure { deleteError -> + Log.e(TAG, "Failed to delete file by name: $fileName", deleteError) + } } } } @@ -169,7 +174,7 @@ class DictionaryDownloader( } private suspend fun deletePartialFileIfExists(folderUri: Uri, fileName: String) { - withContext(Dispatchers.IO) { + withContext(Dispatchers.IO + NonCancellable) { runCatching { val existingFile = fileStorageManager.findFile(folderUri, fileName) if (existingFile != null && existingFile.exists()) { @@ -313,7 +318,7 @@ class DictionaryDownloader( call?.cancel() response?.close() outputStream?.close() - if (e is CancellationException) { + if (e is CancellationException || !currentCoroutineContext().isActive) { deletePartialFileIfExists(folderUri, fileName) deletePartialFileSafely(fileUri, folderUri, fileName) throw e diff --git a/app/src/main/java/com/example/research/feature/download/repository/DictionaryExtractor.kt b/app/src/main/java/com/example/research/feature/download/repository/DictionaryExtractor.kt index 9342d8f..0547ca3 100644 --- a/app/src/main/java/com/example/research/feature/download/repository/DictionaryExtractor.kt +++ b/app/src/main/java/com/example/research/feature/download/repository/DictionaryExtractor.kt @@ -9,7 +9,9 @@ import com.example.research.common.util.FileStorageManager import com.example.research.data.dictzip.DictZipRandomAccessFile import com.example.research.data.dictzip.DictZipWriter import com.example.research.feature.download.config.DictionaryConfig +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext class DictionaryExtractor( @@ -138,11 +140,39 @@ class DictionaryExtractor( onProgress?.invoke(1f) Result.success(Unit) + } catch (e: CancellationException) { + deleteOrphanedFiles(folderUri) + throw e } catch (e: Exception) { Result.failure(e) } } + private suspend fun deleteOrphanedFiles(folderUri: Uri) { + try { + withContext(Dispatchers.IO + NonCancellable) { + val files = fileStorageManager.listFilesInFolder(folderUri) + for (file in files) { + val n = file.name.lowercase() + val isRawDsl = n.endsWith(DictionaryConfig.DSL_EXTENSION) && + !n.endsWith(DictionaryConfig.DICTZIP_EXTENSION) && + !n.endsWith(".dsl.gz") + val isBareDz = n.endsWith(".dz") && !n.endsWith(DictionaryConfig.DICTZIP_EXTENSION) + when { + isBareDz -> ArchiveUtils.deleteFile(context, file.uri) + isRawDsl -> { + val pair = fileStorageManager.findFile(folderUri, file.name + ".dz") + if (pair == null || !pair.exists()) { + ArchiveUtils.deleteFile(context, file.uri) + } + } + } + } + } + } catch (_: Exception) { + } + } + private suspend fun ensureDslDictZip( dslUri: Uri, dslFileName: String,