Export: detailed error reporting

This commit is contained in:
Pierre-Yves Nicolas
2026-01-30 19:18:21 +01:00
parent a5b20c22c0
commit 429d4e7a37
14 changed files with 191 additions and 29 deletions

View File

@@ -14,12 +14,15 @@
*/ */
package org.fairscan.app.ui.screens.export package org.fairscan.app.ui.screens.export
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.text.format.Formatter import android.text.format.Formatter
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -30,9 +33,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
@@ -81,6 +86,7 @@ import org.fairscan.app.ui.dummyNavigation
import org.fairscan.app.ui.screens.settings.ExportFormat.PDF import org.fairscan.app.ui.screens.settings.ExportFormat.PDF
import org.fairscan.app.ui.theme.FairScanTheme import org.fairscan.app.ui.theme.FairScanTheme
import java.io.File import java.io.File
import java.io.IOException
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -243,8 +249,8 @@ private fun TextFieldAndPdfInfos(
} }
} }
SaveStatusBar(uiState, onOpen) SaveStatusBar(uiState, onOpen)
if (uiState.errorMessage != null) { if (uiState.error != null) {
ErrorBar(uiState.errorMessage) ErrorBar(uiState.error)
} }
} }
@@ -376,7 +382,7 @@ fun ExportButton(
@Composable @Composable
private fun SaveInfoBar(savedBundle: SavedBundle, onOpen: (SavedItem) -> Unit) { private fun SaveInfoBar(savedBundle: SavedBundle, onOpen: (SavedItem) -> Unit) {
val dirName = savedBundle.exportDirName?:stringResource(R.string.download_dirname) val dirName = savedBundle.saveDir?.name?:stringResource(R.string.download_dirname)
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Absolute.SpaceBetween, horizontalArrangement = Arrangement.Absolute.SpaceBetween,
@@ -409,18 +415,128 @@ private fun SaveInfoBar(savedBundle: SavedBundle, onOpen: (SavedItem) -> Unit) {
} }
@Composable @Composable
private fun ErrorBar(errorMessage: String) { private fun ErrorBar(error: ExportError) {
Text( val (summary, details) = error.toDisplayText()
text = stringResource(R.string.error, errorMessage), val context = LocalContext.current
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error, Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.errorContainer) .border(
.padding(16.dp), 1.dp,
MaterialTheme.colorScheme.error,
RoundedCornerShape(12.dp)
) )
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = summary,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.weight(1f)
)
if (details != null) {
IconButton(
onClick = {
val clipboard =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val text = buildString {
append(summary)
append("\n\n")
append(details)
}
clipboard.setPrimaryClip(
ClipData.newPlainText("Export error", text)
)
},
) {
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = stringResource(R.string.copy_logs),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
}
if (details != null) {
Text(
text = details,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error.copy(alpha = 0.8f)
)
}
}
} }
@Composable
private fun ExportError.toDisplayText(): Pair<String, String?> {
return when (this) {
is ExportError.OnPrepare -> {
val summary = message
val details = throwable.message
summary to details
}
is ExportError.OnSave -> {
val summary = stringResource(messageRes)
val contextLines = buildErrorContextLines(saveDir)
val details = buildString {
if (contextLines.isNotEmpty()) {
append(contextLines.joinToString("\n"))
}
throwable?.message?.let {
if (isNotEmpty()) append("\n\n")
append(it)
}
}.ifEmpty { null }
summary to details
}
}
}
@Composable
private fun buildErrorContextLines(
saveDir: SaveDir?,
): List<String> {
val defaultDirName = stringResource(R.string.download_dirname)
val folderLine = when {
saveDir == null ->
stringResource(R.string.error_context_folder, defaultDirName)
saveDir.name != null ->
stringResource(R.string.error_context_folder, saveDir.name)
else -> null
}
val providerLine = saveDir?.uri?.authority
?.let(::providerLabel)
?.let { stringResource(R.string.error_context_provider, it) }
return listOfNotNull(folderLine, providerLine)
}
fun providerLabel(authority: String): String =
when {
authority.contains("nextcloud", ignoreCase = true) ->
"Nextcloud"
authority == "com.android.externalstorage.documents" ->
"Local storage"
else ->
authority
}
fun defaultFilename(): String { fun defaultFilename(): String {
val timestamp = SimpleDateFormat("yyyy-MM-dd HH.mm.ss", Locale.getDefault()).format(Date()) val timestamp = SimpleDateFormat("yyyy-MM-dd HH.mm.ss", Locale.getDefault()).format(Date())
return "Scan $timestamp" return "Scan $timestamp"
@@ -468,7 +584,8 @@ fun PreviewExportScreenAfterSave() {
@Composable @Composable
fun ExportScreenPreviewWithError() { fun ExportScreenPreviewWithError() {
ExportPreviewToCustomize( ExportPreviewToCustomize(
ExportUiState(errorMessage = "PDF generation failed") ExportUiState(error =
ExportError.OnPrepare("PDF generation failed", IOException("Boom")))
) )
} }
@@ -481,7 +598,7 @@ fun PreviewExportScreenAfterSaveHorizontal() {
result = ExportResult.Pdf(file, 442897L, 3), result = ExportResult.Pdf(file, 442897L, 3),
savedBundle = SavedBundle( savedBundle = SavedBundle(
listOf(SavedItem(file.toUri(), "my_file.pdf", PDF)), listOf(SavedItem(file.toUri(), "my_file.pdf", PDF)),
exportDirName="MyVeryVeryLongDirectoryName"), SaveDir("fil:///root/dir".toUri() ,"MyVeryVeryLongDirectoryName")),
), ),
) )
} }

View File

@@ -24,7 +24,7 @@ data class ExportUiState(
val result: ExportResult? = null, val result: ExportResult? = null,
val savedBundle: SavedBundle? = null, val savedBundle: SavedBundle? = null,
val hasShared: Boolean = false, val hasShared: Boolean = false,
val errorMessage: String? = null, val error: ExportError? = null,
) { ) {
val hasSavedOrShared get() = savedBundle != null || hasShared val hasSavedOrShared get() = savedBundle != null || hasShared
} }
@@ -37,6 +37,24 @@ data class SavedItem(
data class SavedBundle( data class SavedBundle(
val items: List<SavedItem>, val items: List<SavedItem>,
val exportDir: Uri? = null, val saveDir: SaveDir? = null,
val exportDirName: String? = null,
) )
data class SaveDir(
val uri: Uri,
val name: String?,
)
sealed class ExportError {
data class OnPrepare(
val message: String,
val throwable: Throwable,
) : ExportError()
data class OnSave(
val messageRes: Int,
val saveDir: SaveDir?,
val throwable: Throwable? = null,
): ExportError()
}

View File

@@ -116,7 +116,7 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
_uiState.update { _uiState.update {
it.copy( it.copy(
isGenerating = false, isGenerating = false,
errorMessage = message error = ExportError.OnPrepare(message, e),
) )
} }
} }
@@ -192,23 +192,24 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
fun onRequestSave(context: Context) { fun onRequestSave(context: Context) {
viewModelScope.launch { viewModelScope.launch {
_uiState.update {it.copy(isSaving = true, errorMessage = null, savedBundle = null) } _uiState.update {it.copy(isSaving = true, error = null, savedBundle = null) }
val saveDir = saveDir(context)
try { try {
// Must not run on the main thread: some SAF providers (e.g. Nextcloud) // Must not run on the main thread: some SAF providers (e.g. Nextcloud)
// may perform network I/O // may perform network I/O
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
save(context) save(context, saveDir)
} }
} catch (e: MissingExportDirPermissionException) { } catch (e: MissingExportDirPermissionException) {
logger.e("FairScan", "Missing export dir permission", e) logger.e("FairScan", "Missing export dir permission", e)
_uiState.update { _uiState.update {
it.copy(errorMessage = it.copy(error =
context.getString(R.string.error_export_dir_permission_lost)) ExportError.OnSave(R.string.error_export_dir_permission_lost, saveDir))
} }
} catch (e: Exception) { } catch (e: Exception) {
logger.e("FairScan", "Failed to save PDF", e) logger.e("FairScan", "Failed to save PDF", e)
_uiState.update { _uiState.update {
it.copy(errorMessage = context.getString(R.string.error_save)) it.copy(error = ExportError.OnSave(R.string.error_save, saveDir, e))
} }
} finally { } finally {
_uiState.update { it.copy(isSaving = false) } _uiState.update { it.copy(isSaving = false) }
@@ -216,14 +217,19 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
} }
} }
private suspend fun save(context:Context) { private suspend fun saveDir(context:Context): SaveDir? {
val uri = settingsRepository.exportDirUri.first()?.toUri() ?: return null
val name = resolveExportDirName(context, uri)
return SaveDir(uri, name)
}
private suspend fun save(context: Context, saveDir: SaveDir?) {
val result = applyRenaming() ?: return val result = applyRenaming() ?: return
val exportDir = settingsRepository.exportDirUri.first()?.toUri()
val savedItems = mutableListOf<SavedItem>() val savedItems = mutableListOf<SavedItem>()
val filesForMediaScan = mutableListOf<File>() val filesForMediaScan = mutableListOf<File>()
for (file in result.files) { for (file in result.files) {
val saved = if (exportDir == null) { val saved = if (saveDir == null) {
// No export dir defined -> save to Downloads // No export dir defined -> save to Downloads
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Android 10+: use MediaStore API // Android 10+: use MediaStore API
@@ -239,18 +245,17 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
} else { } else {
// Use Storage Access Framework to save to the chosen directory // Use Storage Access Framework to save to the chosen directory
if (!context.contentResolver.persistedUriPermissions.any { perm -> if (!context.contentResolver.persistedUriPermissions.any { perm ->
perm.uri == exportDir && perm.isWritePermission perm.uri == saveDir.uri && perm.isWritePermission
}) { }) {
throw MissingExportDirPermissionException(exportDir) throw MissingExportDirPermissionException(saveDir.uri)
} }
val safFile = saveViaSaf(context, file, exportDir, exportFormat) val safFile = saveViaSaf(context, file, saveDir.uri, exportFormat)
SavedItem(safFile.uri, safFile.name ?: file.name, exportFormat) SavedItem(safFile.uri, safFile.name ?: file.name, exportFormat)
} }
savedItems += saved savedItems += saved
} }
val exportDirName = resolveExportDirName(context, exportDir) val bundle = SavedBundle(savedItems, saveDir)
val bundle = SavedBundle(savedItems, exportDir, exportDirName)
_uiState.update { it.copy(savedBundle = bundle) } _uiState.update { it.copy(savedBundle = bundle) }
if (exportFormat == ExportFormat.PDF) { if (exportFormat == ExportFormat.PDF) {

View File

@@ -19,6 +19,8 @@
<string name="download_dirname">stažených</string> <string name="download_dirname">stažených</string>
<string name="end_scan">Ukončit skenování</string> <string name="end_scan">Ukončit skenování</string>
<string name="error">Chyba: %1$s</string> <string name="error">Chyba: %1$s</string>
<string name="error_context_folder">Složka exportu: %1$s</string>
<string name="error_context_provider">Poskytovatel: %1$s</string>
<string name="error_export_dir_permission_lost">Vybraná složka pro export již není dostupná. Vyberte prosím jinou složku.</string> <string name="error_export_dir_permission_lost">Vybraná složka pro export již není dostupná. Vyberte prosím jinou složku.</string>
<string name="error_no_app">Nebyla nalezena žádná aplikace pro otevření tohoto souboru</string> <string name="error_no_app">Nebyla nalezena žádná aplikace pro otevření tohoto souboru</string>
<string name="error_no_document">Nebyl rozpoznán žádná dokument</string> <string name="error_no_document">Nebyl rozpoznán žádná dokument</string>

View File

@@ -19,6 +19,8 @@
<string name="download_dirname">Downloads</string> <string name="download_dirname">Downloads</string>
<string name="end_scan">Scan beenden</string> <string name="end_scan">Scan beenden</string>
<string name="error">Fehler: %1$s</string> <string name="error">Fehler: %1$s</string>
<string name="error_context_folder">Exportordner: %1$s</string>
<string name="error_context_provider">Anbieter: %1$s</string>
<string name="error_export_dir_permission_lost">Der ausgewählte Exportordner ist nicht mehr zugänglich. Bitte wählen Sie einen anderen Ordner.</string> <string name="error_export_dir_permission_lost">Der ausgewählte Exportordner ist nicht mehr zugänglich. Bitte wählen Sie einen anderen Ordner.</string>
<string name="error_no_app">Keine App zum Öffnen dieser Datei gefunden</string> <string name="error_no_app">Keine App zum Öffnen dieser Datei gefunden</string>
<string name="error_no_document">Kein Dokument erkannt</string> <string name="error_no_document">Kein Dokument erkannt</string>

View File

@@ -19,6 +19,8 @@
<string name="download_dirname">Descargas</string> <string name="download_dirname">Descargas</string>
<string name="end_scan">Finalizar escaneo</string> <string name="end_scan">Finalizar escaneo</string>
<string name="error">Error: %1$s</string> <string name="error">Error: %1$s</string>
<string name="error_context_folder">Carpeta de exportación: %1$s</string>
<string name="error_context_provider">Proveedor: %1$s</string>
<string name="error_export_dir_permission_lost">La carpeta de exportación seleccionada ya no es accesible. Por favor, elija otra carpeta.</string> <string name="error_export_dir_permission_lost">La carpeta de exportación seleccionada ya no es accesible. Por favor, elija otra carpeta.</string>
<string name="error_no_app">No se encontró ninguna aplicación para abrir este archivo</string> <string name="error_no_app">No se encontró ninguna aplicación para abrir este archivo</string>
<string name="error_no_document">No se detectó ningún documento</string> <string name="error_no_document">No se detectó ningún documento</string>

View File

@@ -19,6 +19,8 @@
<string name="download_dirname">Téléchargements</string> <string name="download_dirname">Téléchargements</string>
<string name="end_scan">Terminer le scan</string> <string name="end_scan">Terminer le scan</string>
<string name="error">Erreur : %1$s</string> <string name="error">Erreur : %1$s</string>
<string name="error_context_folder">Dossier dexport : %1$s</string>
<string name="error_context_provider">Fournisseur : %1$s</string>
<string name="error_export_dir_permission_lost">Le dossier dexport sélectionné nest plus accessible. Veuillez choisir un autre dossier.</string> <string name="error_export_dir_permission_lost">Le dossier dexport sélectionné nest plus accessible. Veuillez choisir un autre dossier.</string>
<string name="error_no_app">Aucune application trouvée pour ouvrir ce fichier</string> <string name="error_no_app">Aucune application trouvée pour ouvrir ce fichier</string>
<string name="error_no_document">Aucun document détecté</string> <string name="error_no_document">Aucun document détecté</string>

View File

@@ -19,6 +19,8 @@
<string name="download_dirname">Download</string> <string name="download_dirname">Download</string>
<string name="end_scan">Termina scansione</string> <string name="end_scan">Termina scansione</string>
<string name="error">Errore: %1$s</string> <string name="error">Errore: %1$s</string>
<string name="error_context_folder">Cartella di esportazione: %1$s</string>
<string name="error_context_provider">Provider: %1$s</string>
<string name="error_export_dir_permission_lost">La cartella di esportazione selezionata non è più accessibile. Scegli unaltra cartella.</string> <string name="error_export_dir_permission_lost">La cartella di esportazione selezionata non è più accessibile. Scegli unaltra cartella.</string>
<string name="error_no_app">Nessuna app trovata per aprire questo file</string> <string name="error_no_app">Nessuna app trovata per aprire questo file</string>
<string name="error_no_document">Nessun documento rilevato</string> <string name="error_no_document">Nessun documento rilevato</string>

View File

@@ -19,6 +19,8 @@
<string name="download_dirname">Downloads</string> <string name="download_dirname">Downloads</string>
<string name="end_scan">Finalizar digitalização</string> <string name="end_scan">Finalizar digitalização</string>
<string name="error">Erro: %1$s</string> <string name="error">Erro: %1$s</string>
<string name="error_context_folder">Pasta de exportação: %1$s</string>
<string name="error_context_provider">Provedor: %1$s</string>
<string name="error_export_dir_permission_lost">A pasta de exportação selecionada não está mais acessível. Escolha outra pasta.</string> <string name="error_export_dir_permission_lost">A pasta de exportação selecionada não está mais acessível. Escolha outra pasta.</string>
<string name="error_no_app">Nenhum app encontrado para abrir este arquivo</string> <string name="error_no_app">Nenhum app encontrado para abrir este arquivo</string>
<string name="error_no_document">Nenhum documento detectado</string> <string name="error_no_document">Nenhum documento detectado</string>

View File

@@ -19,6 +19,8 @@
<string name="download_dirname">Download</string> <string name="download_dirname">Download</string>
<string name="end_scan">Закончить</string> <string name="end_scan">Закончить</string>
<string name="error">Ошибка: %1$s</string> <string name="error">Ошибка: %1$s</string>
<string name="error_context_folder">Папка экспорта: %1$s</string>
<string name="error_context_provider">Провайдер: %1$s</string>
<string name="error_export_dir_permission_lost">Выбранная папка экспорта больше недоступна. Пожалуйста, выберите другую папку.</string> <string name="error_export_dir_permission_lost">Выбранная папка экспорта больше недоступна. Пожалуйста, выберите другую папку.</string>
<string name="error_no_app">Не найдено приложение для открытия этого файла</string> <string name="error_no_app">Не найдено приложение для открытия этого файла</string>
<string name="error_no_document">Документ не обнаружен</string> <string name="error_no_document">Документ не обнаружен</string>

View File

@@ -19,6 +19,8 @@
<string name="download_dirname">İndirilenler</string> <string name="download_dirname">İndirilenler</string>
<string name="end_scan">Taramayı bitir</string> <string name="end_scan">Taramayı bitir</string>
<string name="error">Error: %1$s</string> <string name="error">Error: %1$s</string>
<string name="error_context_folder">Dışa aktarma klasörü: %1$s</string>
<string name="error_context_provider">Sağlayıcı: %1$s</string>
<string name="error_export_dir_permission_lost">Seçilen dışa aktarma dizini artık erişilebilir değil. Lütfen başka bir dizin seçin.</string> <string name="error_export_dir_permission_lost">Seçilen dışa aktarma dizini artık erişilebilir değil. Lütfen başka bir dizin seçin.</string>
<string name="error_no_app">No app found to open this file</string> <string name="error_no_app">No app found to open this file</string>
<string name="error_no_document">No document detected</string> <string name="error_no_document">No document detected</string>

View File

@@ -19,6 +19,8 @@
<string name="download_dirname">下載 (Downloads)</string> <string name="download_dirname">下載 (Downloads)</string>
<string name="end_scan">結束掃描</string> <string name="end_scan">結束掃描</string>
<string name="error">錯誤:%1$s</string> <string name="error">錯誤:%1$s</string>
<string name="error_context_folder">匯出資料夾:%1$s</string>
<string name="error_context_provider">提供者:%1$s</string>
<string name="error_export_dir_permission_lost">所選的匯出目錄已無法存取。請選擇其他目錄。</string> <string name="error_export_dir_permission_lost">所選的匯出目錄已無法存取。請選擇其他目錄。</string>
<string name="error_no_app">找不到可開啟此檔案的應用程式</string> <string name="error_no_app">找不到可開啟此檔案的應用程式</string>
<string name="error_no_document">未偵測到文件</string> <string name="error_no_document">未偵測到文件</string>

View File

@@ -19,6 +19,8 @@
<string name="download_dirname">下载</string> <string name="download_dirname">下载</string>
<string name="end_scan">结束扫描</string> <string name="end_scan">结束扫描</string>
<string name="error">错误: %1$s</string> <string name="error">错误: %1$s</string>
<string name="error_context_folder">导出文件夹:%1$s</string>
<string name="error_context_provider">提供方:%1$s</string>
<string name="error_export_dir_permission_lost">所选的导出目录已无法访问。请选择其他目录。</string> <string name="error_export_dir_permission_lost">所选的导出目录已无法访问。请选择其他目录。</string>
<string name="error_no_app">未找到可打开此文件的应用</string> <string name="error_no_app">未找到可打开此文件的应用</string>
<string name="error_no_document">未检测到任何文档</string> <string name="error_no_document">未检测到任何文档</string>

View File

@@ -20,6 +20,8 @@
<string name="download_dirname">Downloads</string> <string name="download_dirname">Downloads</string>
<string name="end_scan">End scan</string> <string name="end_scan">End scan</string>
<string name="error">Error: %1$s</string> <string name="error">Error: %1$s</string>
<string name="error_context_folder">Export folder: %1$s</string>
<string name="error_context_provider">Provider: %1$s</string>
<string name="error_export_dir_permission_lost">The selected export folder is no longer accessible. Please choose another folder.</string> <string name="error_export_dir_permission_lost">The selected export folder is no longer accessible. Please choose another folder.</string>
<!-- Rare error messages should not be translated --> <!-- Rare error messages should not be translated -->
<string name="error_file_picker_launch" translatable="false">Failed to launch system file picker on this device</string> <string name="error_file_picker_launch" translatable="false">Failed to launch system file picker on this device</string>