From 41bc116c56e0c8eb0ae1eab5cf5c45eecff741ba Mon Sep 17 00:00:00 2001 From: OneWay Date: Mon, 12 Oct 2026 15:33:42 +0800 Subject: [PATCH] feat(download): show progress before a link download starts The source check, folder access and request ran before the first byte with no visible feedback. Enter the loading state on tap and show an indeterminate wavy indicator until download progress is known. File import is unchanged. An early cancel deletes only files created by the current download, so installed dictionaries survive. --- .../progress/DictionaryProgressModel.kt | 7 +- .../feature/download/DownloadManager.kt | 6 +- .../repository/DictionaryRepository.kt | 94 +++++-------------- .../components/DictionaryManagement.kt | 1 + .../components/DictionaryProgressSection.kt | 40 +++++--- 5 files changed, 57 insertions(+), 91 deletions(-) diff --git a/app/src/main/java/com/example/research/common/progress/DictionaryProgressModel.kt b/app/src/main/java/com/example/research/common/progress/DictionaryProgressModel.kt index 9073975..b44b2c9 100644 --- a/app/src/main/java/com/example/research/common/progress/DictionaryProgressModel.kt +++ b/app/src/main/java/com/example/research/common/progress/DictionaryProgressModel.kt @@ -21,14 +21,16 @@ object DictionaryProgressModel { val progress: Float, val percent: Int, @param:StringRes val titleRes: Int, + val indeterminate: Boolean = false, ) { override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is Snapshot) return false - return percent == other.percent && titleRes == other.titleRes + return percent == other.percent && titleRes == other.titleRes && + indeterminate == other.indeterminate } override fun hashCode(): Int { - return 31 * percent + titleRes + return 31 * (31 * percent + titleRes) + indeterminate.hashCode() } } fun computeProgressSnapshot( @@ -92,6 +94,7 @@ object DictionaryProgressModel { progress = p, percent = pct, titleRes = R.string.notification_download_title, + indeterminate = dl <= 0f, ) } diff --git a/app/src/main/java/com/example/research/feature/download/DownloadManager.kt b/app/src/main/java/com/example/research/feature/download/DownloadManager.kt index e956a23..fcde452 100644 --- a/app/src/main/java/com/example/research/feature/download/DownloadManager.kt +++ b/app/src/main/java/com/example/research/feature/download/DownloadManager.kt @@ -57,6 +57,7 @@ class DownloadManager( if (!downloadScope.isActive) return onFlowStarted?.invoke() cancelCleanupJob = null + updateDownloadProgressState(DownloadState.Loading, 0f) downloadJob = downloadScope.launch { mutex.withLock { try { @@ -81,8 +82,6 @@ class DownloadManager( return@withLock } - updateDownloadProgressState(DownloadState.Loading, 0f) - var lastReportedProgressBucket = -1 val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) { dictionaryRepository.downloadSources(downloadableSources) { progress -> @@ -149,6 +148,7 @@ class DownloadManager( if (!downloadScope.isActive) return onFlowStarted?.invoke() cancelCleanupJob = null + updateDownloadProgressState(DownloadState.Loading, 0f) downloadJob = downloadScope.launch { mutex.withLock { try { @@ -177,8 +177,6 @@ class DownloadManager( return@withLock } - updateDownloadProgressState(DownloadState.Loading, 0f) - var lastReportedProgressBucket = -1 val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) { dictionaryRepository.downloadSources(downloadableSources) { progress -> diff --git a/app/src/main/java/com/example/research/feature/download/repository/DictionaryRepository.kt b/app/src/main/java/com/example/research/feature/download/repository/DictionaryRepository.kt index 0b4830a..2e63450 100644 --- a/app/src/main/java/com/example/research/feature/download/repository/DictionaryRepository.kt +++ b/app/src/main/java/com/example/research/feature/download/repository/DictionaryRepository.kt @@ -25,8 +25,10 @@ class DictionaryRepository( private val extractor = DictionaryExtractor(context, fileStorageManager) private val cleaner = DictionaryCleaner(context, fileStorageManager) - private var filesBeforeDownload = mutableSetOf() - private var downloadedPrefixes = mutableSetOf() + private class DownloadSession(val filesBefore: Set, val prefixes: Set) + + @Volatile + private var session: DownloadSession? = null companion object { private const val TAG = "DictionaryRepository" @@ -81,15 +83,10 @@ class DictionaryRepository( val files = fileStorageManager.listFilesInFolder(uri) cleaner.deleteCorruptedArchives(uri, files) - synchronized(filesBeforeDownload) { - filesBeforeDownload = files.map { it.name }.toMutableSet() - } - - synchronized(downloadedPrefixes) { - downloadedPrefixes = sources.mapNotNull { - DictionarySource.extractPrefix(it.urlTemplate) - }.toMutableSet() - } + session = DownloadSession( + filesBefore = files.mapTo(mutableSetOf()) { it.name }, + prefixes = sources.mapNotNullTo(mutableSetOf()) { DictionarySource.extractPrefix(it.urlTemplate) }, + ) downloader.downloadDictionaries( folderUri = uri, @@ -114,63 +111,22 @@ class DictionaryRepository( cleaner.deleteUnprocessedFiles(uri, files) - val filesAfterStandardCleanup = fileStorageManager.listFilesInFolder(uri) + val current = session ?: return@withContext Result.success(Unit) + session = null - val existingFiles = synchronized(filesBeforeDownload) { - filesBeforeDownload.toSet() - } - - val prefixes = synchronized(downloadedPrefixes) { - downloadedPrefixes.toSet() - } - - val finalPrefixes = prefixes.ifEmpty { - val sources = preferencesManager.dictionarySources.first() - sources.mapNotNull { DictionarySource.extractPrefix(it.urlTemplate) }.toSet() - } - - val filesToDelete = if (existingFiles.isEmpty()) { - filesAfterStandardCleanup.filter { file -> - val fileName = file.name - finalPrefixes.any { prefix -> fileName.startsWith("${prefix}_") } + fileStorageManager.listFilesInFolder(uri) + .filter { file -> + file.name !in current.filesBefore && + current.prefixes.any { prefix -> file.name.startsWith("${prefix}_") } } - } else { - filesAfterStandardCleanup.filter { file -> - file.name !in existingFiles && - finalPrefixes.any { prefix -> file.name.startsWith("${prefix}_") } - } - } - - if (filesToDelete.isNotEmpty()) { - var deletedCount = 0 - - for ((fileName, uri) in filesToDelete) { - val docFile = fileStorageManager.getDocumentFile(uri) - if (docFile != null && docFile.exists()) { - val deleted = docFile.delete() - if (deleted) { - deletedCount++ - } else { - val forceDeleted = fileStorageManager.forceDeleteFile(docFile, fileName) - if (!forceDeleted) { - Log.e(TAG, "deleteUnprocessedFiles() - force delete failed for: $fileName") - } else { - deletedCount++ - } - } + .forEach { (fileName, fileUri) -> + val docFile = fileStorageManager.getDocumentFile(fileUri) + if (docFile != null && docFile.exists() && !docFile.delete() && + !fileStorageManager.forceDeleteFile(docFile, fileName) + ) { + Log.e(TAG, "deleteUnprocessedFiles() - force delete failed for: $fileName") } } - } - - synchronized(filesBeforeDownload) { - if (filesBeforeDownload.isNotEmpty()) { - filesBeforeDownload.clear() - } - } - - synchronized(downloadedPrefixes) { - downloadedPrefixes.clear() - } Result.success(Unit) } @@ -183,15 +139,7 @@ class DictionaryRepository( val prefixes = sources.mapNotNull { DictionarySource.extractPrefix(it.urlTemplate) }.toSet() val result = cleaner.performAllCleanup(uri, prefixes, files) - - synchronized(filesBeforeDownload) { - filesBeforeDownload.clear() - } - - synchronized(downloadedPrefixes) { - downloadedPrefixes.clear() - } - + session = null result } } diff --git a/app/src/main/java/com/example/research/ui/settings/components/DictionaryManagement.kt b/app/src/main/java/com/example/research/ui/settings/components/DictionaryManagement.kt index a094b87..c41838b 100644 --- a/app/src/main/java/com/example/research/ui/settings/components/DictionaryManagement.kt +++ b/app/src/main/java/com/example/research/ui/settings/components/DictionaryManagement.kt @@ -268,6 +268,7 @@ private fun DictionaryProgressSectionHost( label = label, onCancel = onCancel, percent = progressSnapshot?.percent ?: 0, + indeterminate = progressSnapshot?.indeterminate ?: false, testTag = progressTestTag, ) } diff --git a/app/src/main/java/com/example/research/ui/settings/components/DictionaryProgressSection.kt b/app/src/main/java/com/example/research/ui/settings/components/DictionaryProgressSection.kt index 0dfccec..191a27e 100644 --- a/app/src/main/java/com/example/research/ui/settings/components/DictionaryProgressSection.kt +++ b/app/src/main/java/com/example/research/ui/settings/components/DictionaryProgressSection.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.ui.res.painterResource import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag @@ -31,6 +32,7 @@ fun DictionaryProgressSection( onCancel: () -> Unit, modifier: Modifier = Modifier, percent: Int, + indeterminate: Boolean, testTag: String, ) { AnimatedVisibility( @@ -44,6 +46,7 @@ fun DictionaryProgressSection( var displayedLabel by remember { mutableStateOf(label) } var displayedPercent by remember { mutableIntStateOf(percent) } var displayedTestTag by remember { mutableStateOf(testTag) } + var displayedIndeterminate by remember { mutableStateOf(indeterminate) } LaunchedEffect(visible) { if (visible) { @@ -60,6 +63,7 @@ fun DictionaryProgressSection( displayedLabel = label displayedPercent = percent displayedTestTag = testTag + displayedIndeterminate = indeterminate } } val animatedState = remember { Animatable(0f) } @@ -88,21 +92,33 @@ fun DictionaryProgressSection( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary ) - Text( - text = "$displayedPercent%", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary - ) + if (!displayedIndeterminate) { + Text( + text = "$displayedPercent%", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } } - Spacer(Modifier.height(8.dp)) - LinearProgressIndicator( - progress = { animatedProgress }, + Box( modifier = Modifier .fillMaxWidth() - .height(4.dp) - .clip(MaterialTheme.shapes.small), - ) - Spacer(Modifier.height(8.dp)) + .height(WavyProgressIndicatorDefaults.LinearContainerHeight), + contentAlignment = Alignment.Center, + ) { + if (displayedIndeterminate) { + LinearWavyProgressIndicator(modifier = Modifier.fillMaxWidth()) + } else { + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(MaterialTheme.shapes.small), + ) + } + } + Spacer(Modifier.height(4.dp)) OutlinedButton( onClick = onCancel, modifier = Modifier.fillMaxWidth(),