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.
This commit is contained in:
OneWay committed 2026-10-12 15:33:42 +08:00
1 parent 7af9930667
commit 41bc116c56
5 files changed
+46 -80

No files matched your search

@@ -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,
)
}
@@ -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 ->
@@ -25,8 +25,10 @@ class DictionaryRepository(
private val extractor = DictionaryExtractor(context, fileStorageManager)
private val cleaner = DictionaryCleaner(context, fileStorageManager)
private var filesBeforeDownload = mutableSetOf<String>()
private var downloadedPrefixes = mutableSetOf<String>()
private class DownloadSession(val filesBefore: Set<String>, val prefixes: Set<String>)
@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()
fileStorageManager.listFilesInFolder(uri)
.filter { file ->
file.name !in current.filesBefore &&
current.prefixes.any { prefix -> file.name.startsWith("${prefix}_") }
}
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}_") }
}
} 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) {
.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")
} else {
deletedCount++
}
}
}
}
}
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
}
}
@@ -268,6 +268,7 @@ private fun DictionaryProgressSectionHost(
label = label,
onCancel = onCancel,
percent = progressSnapshot?.percent ?: 0,
indeterminate = progressSnapshot?.indeterminate ?: false,
testTag = progressTestTag,
)
}
@@ -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,13 +92,23 @@ fun DictionaryProgressSection(
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))
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(WavyProgressIndicatorDefaults.LinearContainerHeight),
contentAlignment = Alignment.Center,
) {
if (displayedIndeterminate) {
LinearWavyProgressIndicator(modifier = Modifier.fillMaxWidth())
} else {
LinearProgressIndicator(
progress = { animatedProgress },
modifier = Modifier
@@ -102,7 +116,9 @@ fun DictionaryProgressSection(
.height(4.dp)
.clip(MaterialTheme.shapes.small),
)
Spacer(Modifier.height(8.dp))
}
}
Spacer(Modifier.height(4.dp))
OutlinedButton(
onClick = onCancel,
modifier = Modifier.fillMaxWidth(),