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:
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 ->
|
||||||
|
|||||||
+20
-72
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-2
@@ -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(),
|
||||||
|
|||||||
Reference in new issue
Block a user