OCR: progress indicator for download

This commit is contained in:
Pierre-Yves Nicolas
2026-06-10 19:02:02 +02:00
parent f005eefdeb
commit 75bcda22cc
5 changed files with 133 additions and 8 deletions
@@ -296,6 +296,7 @@ class MainActivity : ComponentActivity() {
onExportFormatChanged = { format -> settingsViewModel.setExportFormat(format) },
onExportQualityChanged = { quality -> settingsViewModel.setExportQuality(quality) },
onInstallOcrLanguage = settingsViewModel::installLanguage,
onCancelOcrDownload = settingsViewModel::cancelOcrDownload,
onEnableOcrLanguage = settingsViewModel::setOcrLanguageEnabled,
onDeleteUnusedOcrLanguages = settingsViewModel::deleteUnusedOcrLanguages,
onBack = nav.back,
@@ -19,6 +19,7 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
@@ -81,7 +82,12 @@ class OcrLanguageRepository(
}
}
suspend fun downloadLanguage(code: String) =
data class DownloadProgress(
val downloadedBytes: Long,
val totalBytes: Long?,
)
suspend fun downloadLanguage(code: String, onProgress: (DownloadProgress) -> Unit) =
withContext(Dispatchers.IO) {
require(code in AVAILABLE_LANGUAGE_CODES)
tessdataDir.mkdirs()
@@ -102,7 +108,21 @@ class OcrLanguageRepository(
}
connection.inputStream.use { input ->
tempFile.outputStream().use { output ->
input.copyTo(output)
val totalBytes = connection.contentLengthLong.takeIf { it > 0 }
val buffer = ByteArray(8192)
var downloadedBytes = 0L
input.use { input ->
tempFile.outputStream().use { output ->
while (true) {
coroutineContext.ensureActive()
val read = input.read(buffer)
if (read < 0) break
output.write(buffer, 0, read)
downloadedBytes += read
onProgress(DownloadProgress(downloadedBytes, totalBytes))
}
}
}
}
}
if (tempFile.length() < 300_000) {
@@ -14,26 +14,35 @@
*/
package org.fairscan.app.ui.screens.settings
import android.content.Context
import android.text.format.Formatter
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Download
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.dp
import org.fairscan.app.data.OcrLanguage
@@ -125,3 +134,57 @@ private fun LanguageItem(
modifier = Modifier.clickable { onInstallLanguage(lang.code) }
)
}
@Composable
fun OcrDownloadDialog(
state: OcrDownloadUiState,
onCancel: () -> Unit,
) {
AlertDialog(
onDismissRequest = {}, // tapping outside the dialog should not cancel
title = { Text("Downloading OCR language") }, // TODO externalize
text = {
Column{
Text(state.language.displayName(Locale.current.platformLocale))
Spacer(Modifier.height(16.dp))
val progress =
state.totalBytes?.let { total ->
state.downloadedBytes.toFloat() / total
}
if (progress != null) {
LinearProgressIndicator(progress = { progress }, Modifier.fillMaxWidth())
} else {
LinearProgressIndicator(Modifier.fillMaxWidth())
}
Spacer(Modifier.height(12.dp))
Text(
buildProgressText(state.downloadedBytes, state.totalBytes, LocalContext.current),
style = MaterialTheme.typography.bodyMedium,
)
}
},
dismissButton = {
TextButton(onClick = onCancel) { Text("Cancel") } // TODO externalize
},
confirmButton = {}
)
}
private fun buildProgressText(
downloadedBytes: Long,
totalBytes: Long?,
context: Context,
): String {
return if (totalBytes != null) {
listOf(
Formatter.formatShortFileSize(context, downloadedBytes),
Formatter.formatShortFileSize(context, totalBytes),
).joinToString(" / ")
} else {
Formatter.formatShortFileSize(context, downloadedBytes)
}
}
@@ -78,6 +78,7 @@ fun SettingsScreen(
onExportFormatChanged: (ExportFormat) -> Unit,
onExportQualityChanged: (ExportQuality) -> Unit,
onInstallOcrLanguage: (String) -> Unit,
onCancelOcrDownload: () -> Unit,
onEnableOcrLanguage: (String, Boolean) -> Unit,
onDeleteUnusedOcrLanguages: () -> Unit,
onBack: () -> Unit,
@@ -99,9 +100,11 @@ fun SettingsScreen(
onExportFormatChanged,
onExportQualityChanged,
onInstallOcrLanguage,
onCancelOcrDownload,
onEnableOcrLanguage,
onDeleteUnusedOcrLanguages,
modifier = Modifier.padding(paddingValues))
modifier = Modifier.padding(paddingValues),
)
}
}
@@ -115,6 +118,7 @@ private fun SettingsContent(
onExportFormatChanged: (ExportFormat) -> Unit,
onExportQualityChanged: (ExportQuality) -> Unit,
onInstallOcrLanguage: (String) -> Unit,
onCancelOcrDownload: () -> Unit,
onEnableOcrLanguage: (String, Boolean) -> Unit,
onDeleteUnusedOcrLanguages: () -> Unit,
modifier: Modifier = Modifier,
@@ -267,6 +271,12 @@ private fun SettingsContent(
onConfirm = onDeleteUnusedOcrLanguages,
)
}
uiState.currentDownload?.let { download ->
OcrDownloadDialog(
state = download,
onCancel = onCancelOcrDownload,
)
}
}
@Composable
@@ -366,6 +376,7 @@ fun SettingsScreenPreview(uiState: SettingsUiState) {
onExportFormatChanged = {},
onExportQualityChanged = {},
onInstallOcrLanguage = {},
onCancelOcrDownload = {},
onEnableOcrLanguage = { _,_->},
onDeleteUnusedOcrLanguages = {},
onBack = {}
@@ -16,6 +16,7 @@ package org.fairscan.app.ui.screens.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -24,6 +25,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.fairscan.app.AppContainer
import org.fairscan.app.data.OcrLanguage
import org.fairscan.app.domain.ExportQuality
data class SettingsUiState(
@@ -31,6 +33,7 @@ data class SettingsUiState(
val export: ExportSettingsUiState = ExportSettingsUiState(),
val installedOcrLanguages: Set<String> = emptySet(),
val enabledOcrLanguages: Set<String> = emptySet(),
val currentDownload: OcrDownloadUiState? = null
)
data class ExportSettingsUiState(
@@ -40,12 +43,21 @@ data class ExportSettingsUiState(
val quality: ExportQuality = ExportQuality.BALANCED,
)
data class OcrDownloadUiState(
val language: OcrLanguage,
val downloadedBytes: Long = 0,
val totalBytes: Long? = null,
)
class SettingsViewModel(container: AppContainer) : ViewModel() {
private val repo = container.settingsRepository
private val ocrLanguageRepo = container.ocrLanguageRepository
private val _installedLanguages = MutableStateFlow<Set<String>>(emptySet())
private val _ocrDownload = MutableStateFlow<OcrDownloadUiState?>(null)
private var downloadJob: Job? = null
private val _dirName = MutableStateFlow<String?>(null)
val dirName: StateFlow<String?> = _dirName
@@ -64,12 +76,14 @@ class SettingsViewModel(container: AppContainer) : ViewModel() {
exportSettingsState,
_installedLanguages,
ocrLanguageRepo.enabledLanguages,
) { colorMode, exportSettings, installed, enabled ->
_ocrDownload,
) { colorMode, exportSettings, installed, enabled, download ->
SettingsUiState(
defaultColorMode = colorMode,
export = exportSettings,
installedOcrLanguages = installed,
enabledOcrLanguages = enabled,
currentDownload = download,
)
}.stateIn(
viewModelScope,
@@ -121,13 +135,29 @@ class SettingsViewModel(container: AppContainer) : ViewModel() {
}
fun installLanguage(code: String) {
viewModelScope.launch {
ocrLanguageRepo.downloadLanguage(code)
ocrLanguageRepo.setLanguageEnabled(code, true)
refreshInstalledLanguages()
downloadJob?.cancel()
downloadJob = viewModelScope.launch {
_ocrDownload.value = OcrDownloadUiState(OcrLanguage(code))
try {
ocrLanguageRepo.downloadLanguage(code) { progress ->
_ocrDownload.value =
_ocrDownload.value?.copy(
downloadedBytes = progress.downloadedBytes,
totalBytes = progress.totalBytes,
)
}
ocrLanguageRepo.setLanguageEnabled(code, true)
refreshInstalledLanguages()
} finally {
_ocrDownload.value = null
}
}
}
fun cancelOcrDownload() {
downloadJob?.cancel()
}
fun setOcrLanguageEnabled(code: String, enabled: Boolean) {
viewModelScope.launch {
ocrLanguageRepo.setLanguageEnabled(code, enabled)