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 progress: Float,
val percent: Int, val percent: Int,
@param:StringRes val titleRes: Int, @param:StringRes val titleRes: Int,
val indeterminate: Boolean = false,
) { ) {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (other !is Snapshot) return false 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 { override fun hashCode(): Int {
return 31 * percent + titleRes return 31 * (31 * percent + titleRes) + indeterminate.hashCode()
} }
} }
fun computeProgressSnapshot( fun computeProgressSnapshot(
@@ -92,6 +94,7 @@ object DictionaryProgressModel {
progress = p, progress = p,
percent = pct, percent = pct,
titleRes = R.string.notification_download_title, titleRes = R.string.notification_download_title,
indeterminate = dl <= 0f,
) )
} }
@@ -57,6 +57,7 @@ class DownloadManager(
if (!downloadScope.isActive) return if (!downloadScope.isActive) return
onFlowStarted?.invoke() onFlowStarted?.invoke()
cancelCleanupJob = null cancelCleanupJob = null
updateDownloadProgressState(DownloadState.Loading, 0f)
downloadJob = downloadScope.launch { downloadJob = downloadScope.launch {
mutex.withLock { mutex.withLock {
try { try {
@@ -81,8 +82,6 @@ class DownloadManager(
return@withLock return@withLock
} }
updateDownloadProgressState(DownloadState.Loading, 0f)
var lastReportedProgressBucket = -1 var lastReportedProgressBucket = -1
val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) { val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) {
dictionaryRepository.downloadSources(downloadableSources) { progress -> dictionaryRepository.downloadSources(downloadableSources) { progress ->
@@ -149,6 +148,7 @@ class DownloadManager(
if (!downloadScope.isActive) return if (!downloadScope.isActive) return
onFlowStarted?.invoke() onFlowStarted?.invoke()
cancelCleanupJob = null cancelCleanupJob = null
updateDownloadProgressState(DownloadState.Loading, 0f)
downloadJob = downloadScope.launch { downloadJob = downloadScope.launch {
mutex.withLock { mutex.withLock {
try { try {
@@ -177,8 +177,6 @@ class DownloadManager(
return@withLock return@withLock
} }
updateDownloadProgressState(DownloadState.Loading, 0f)
var lastReportedProgressBucket = -1 var lastReportedProgressBucket = -1
val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) { val result = ReSearchTrace.asyncSection(ReSearchTrace.DICTIONARY_DOWNLOAD) {
dictionaryRepository.downloadSources(downloadableSources) { progress -> dictionaryRepository.downloadSources(downloadableSources) { progress ->
@@ -25,8 +25,10 @@ class DictionaryRepository(
private val extractor = DictionaryExtractor(context, fileStorageManager) private val extractor = DictionaryExtractor(context, fileStorageManager)
private val cleaner = DictionaryCleaner(context, fileStorageManager) private val cleaner = DictionaryCleaner(context, fileStorageManager)
private var filesBeforeDownload = mutableSetOf<String>() private class DownloadSession(val filesBefore: Set<String>, val prefixes: Set<String>)
private var downloadedPrefixes = mutableSetOf<String>()
@Volatile
private var session: DownloadSession? = null
companion object { companion object {
private const val TAG = "DictionaryRepository" private const val TAG = "DictionaryRepository"
@@ -81,15 +83,10 @@ class DictionaryRepository(
val files = fileStorageManager.listFilesInFolder(uri) val files = fileStorageManager.listFilesInFolder(uri)
cleaner.deleteCorruptedArchives(uri, files) cleaner.deleteCorruptedArchives(uri, files)
synchronized(filesBeforeDownload) { session = DownloadSession(
filesBeforeDownload = files.map { it.name }.toMutableSet() filesBefore = files.mapTo(mutableSetOf()) { it.name },
} prefixes = sources.mapNotNullTo(mutableSetOf()) { DictionarySource.extractPrefix(it.urlTemplate) },
)
synchronized(downloadedPrefixes) {
downloadedPrefixes = sources.mapNotNull {
DictionarySource.extractPrefix(it.urlTemplate)
}.toMutableSet()
}
downloader.downloadDictionaries( downloader.downloadDictionaries(
folderUri = uri, folderUri = uri,
@@ -114,63 +111,22 @@ class DictionaryRepository(
cleaner.deleteUnprocessedFiles(uri, files) cleaner.deleteUnprocessedFiles(uri, files)
val filesAfterStandardCleanup = fileStorageManager.listFilesInFolder(uri) val current = session ?: return@withContext Result.success(Unit)
session = null
val existingFiles = synchronized(filesBeforeDownload) { fileStorageManager.listFilesInFolder(uri)
filesBeforeDownload.toSet() .filter { file ->
file.name !in current.filesBefore &&
current.prefixes.any { prefix -> file.name.startsWith("${prefix}_") }
} }
.forEach { (fileName, fileUri) ->
val prefixes = synchronized(downloadedPrefixes) { val docFile = fileStorageManager.getDocumentFile(fileUri)
downloadedPrefixes.toSet() if (docFile != null && docFile.exists() && !docFile.delete() &&
} !fileStorageManager.forceDeleteFile(docFile, fileName)
) {
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) {
Log.e(TAG, "deleteUnprocessedFiles() - force delete failed for: $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) Result.success(Unit)
} }
@@ -183,15 +139,7 @@ class DictionaryRepository(
val prefixes = sources.mapNotNull { DictionarySource.extractPrefix(it.urlTemplate) }.toSet() val prefixes = sources.mapNotNull { DictionarySource.extractPrefix(it.urlTemplate) }.toSet()
val result = cleaner.performAllCleanup(uri, prefixes, files) val result = cleaner.performAllCleanup(uri, prefixes, files)
session = null
synchronized(filesBeforeDownload) {
filesBeforeDownload.clear()
}
synchronized(downloadedPrefixes) {
downloadedPrefixes.clear()
}
result result
} }
} }
@@ -268,6 +268,7 @@ private fun DictionaryProgressSectionHost(
label = label, label = label,
onCancel = onCancel, onCancel = onCancel,
percent = progressSnapshot?.percent ?: 0, percent = progressSnapshot?.percent ?: 0,
indeterminate = progressSnapshot?.indeterminate ?: false,
testTag = progressTestTag, testTag = progressTestTag,
) )
} }
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
@@ -31,6 +32,7 @@ fun DictionaryProgressSection(
onCancel: () -> Unit, onCancel: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
percent: Int, percent: Int,
indeterminate: Boolean,
testTag: String, testTag: String,
) { ) {
AnimatedVisibility( AnimatedVisibility(
@@ -44,6 +46,7 @@ fun DictionaryProgressSection(
var displayedLabel by remember { mutableStateOf(label) } var displayedLabel by remember { mutableStateOf(label) }
var displayedPercent by remember { mutableIntStateOf(percent) } var displayedPercent by remember { mutableIntStateOf(percent) }
var displayedTestTag by remember { mutableStateOf(testTag) } var displayedTestTag by remember { mutableStateOf(testTag) }
var displayedIndeterminate by remember { mutableStateOf(indeterminate) }
LaunchedEffect(visible) { LaunchedEffect(visible) {
if (visible) { if (visible) {
@@ -60,6 +63,7 @@ fun DictionaryProgressSection(
displayedLabel = label displayedLabel = label
displayedPercent = percent displayedPercent = percent
displayedTestTag = testTag displayedTestTag = testTag
displayedIndeterminate = indeterminate
} }
} }
val animatedState = remember { Animatable(0f) } val animatedState = remember { Animatable(0f) }
@@ -88,13 +92,23 @@ fun DictionaryProgressSection(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
if (!displayedIndeterminate) {
Text( Text(
text = "$displayedPercent%", text = "$displayedPercent%",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary 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( LinearProgressIndicator(
progress = { animatedProgress }, progress = { animatedProgress },
modifier = Modifier modifier = Modifier
@@ -102,7 +116,9 @@ fun DictionaryProgressSection(
.height(4.dp) .height(4.dp)
.clip(MaterialTheme.shapes.small), .clip(MaterialTheme.shapes.small),
) )
Spacer(Modifier.height(8.dp)) }
}
Spacer(Modifier.height(4.dp))
OutlinedButton( OutlinedButton(
onClick = onCancel, onClick = onCancel,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),