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
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.text.format.Formatter
import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.OpenInNew
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.Download
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.theme.FairScanTheme
import java.io.File
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -243,8 +249,8 @@ private fun TextFieldAndPdfInfos(
}
}
SaveStatusBar(uiState, onOpen)
if (uiState.errorMessage != null) {
ErrorBar(uiState.errorMessage)
if (uiState.error != null) {
ErrorBar(uiState.error)
}
}
@@ -376,7 +382,7 @@ fun ExportButton(
@Composable
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(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Absolute.SpaceBetween,
@@ -409,18 +415,128 @@ private fun SaveInfoBar(savedBundle: SavedBundle, onOpen: (SavedItem) -> Unit) {
}
@Composable
private fun ErrorBar(errorMessage: String) {
Text(
text = stringResource(R.string.error, errorMessage),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
private fun ErrorBar(error: ExportError) {
val (summary, details) = error.toDisplayText()
val context = LocalContext.current
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.errorContainer)
.border(
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 {
val timestamp = SimpleDateFormat("yyyy-MM-dd HH.mm.ss", Locale.getDefault()).format(Date())
return "Scan $timestamp"
@@ -468,7 +584,8 @@ fun PreviewExportScreenAfterSave() {
@Composable
fun ExportScreenPreviewWithError() {
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),
savedBundle = SavedBundle(
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 savedBundle: SavedBundle? = null,
val hasShared: Boolean = false,
val errorMessage: String? = null,
val error: ExportError? = null,
) {
val hasSavedOrShared get() = savedBundle != null || hasShared
}
@@ -37,6 +37,24 @@ data class SavedItem(
data class SavedBundle(
val items: List<SavedItem>,
val exportDir: Uri? = null,
val exportDirName: String? = null,
val saveDir: SaveDir? = 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 {
it.copy(
isGenerating = false,
errorMessage = message
error = ExportError.OnPrepare(message, e),
)
}
}
@@ -192,23 +192,24 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
fun onRequestSave(context: Context) {
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 {
// Must not run on the main thread: some SAF providers (e.g. Nextcloud)
// may perform network I/O
withContext(Dispatchers.IO) {
save(context)
save(context, saveDir)
}
} catch (e: MissingExportDirPermissionException) {
logger.e("FairScan", "Missing export dir permission", e)
_uiState.update {
it.copy(errorMessage =
context.getString(R.string.error_export_dir_permission_lost))
it.copy(error =
ExportError.OnSave(R.string.error_export_dir_permission_lost, saveDir))
}
} catch (e: Exception) {
logger.e("FairScan", "Failed to save PDF", e)
_uiState.update {
it.copy(errorMessage = context.getString(R.string.error_save))
it.copy(error = ExportError.OnSave(R.string.error_save, saveDir, e))
}
} finally {
_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 exportDir = settingsRepository.exportDirUri.first()?.toUri()
val savedItems = mutableListOf<SavedItem>()
val filesForMediaScan = mutableListOf<File>()
for (file in result.files) {
val saved = if (exportDir == null) {
val saved = if (saveDir == null) {
// No export dir defined -> save to Downloads
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Android 10+: use MediaStore API
@@ -239,18 +245,17 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
} else {
// Use Storage Access Framework to save to the chosen directory
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)
}
savedItems += saved
}
val exportDirName = resolveExportDirName(context, exportDir)
val bundle = SavedBundle(savedItems, exportDir, exportDirName)
val bundle = SavedBundle(savedItems, saveDir)
_uiState.update { it.copy(savedBundle = bundle) }
if (exportFormat == ExportFormat.PDF) {

View File

@@ -19,6 +19,8 @@
<string name="download_dirname">stažených</string>
<string name="end_scan">Ukončit skenování</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_no_app">Nebyla nalezena žádná aplikace pro otevření tohoto souboru</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="end_scan">Scan beenden</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_no_app">Keine App zum Öffnen dieser Datei gefunden</string>
<string name="error_no_document">Kein Dokument erkannt</string>

View File

@@ -19,6 +19,8 @@
<string name="download_dirname">Descargas</string>
<string name="end_scan">Finalizar escaneo</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_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>

View File

@@ -19,6 +19,8 @@
<string name="download_dirname">Téléchargements</string>
<string name="end_scan">Terminer le scan</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_no_app">Aucune application trouvée pour ouvrir ce fichier</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="end_scan">Termina scansione</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_no_app">Nessuna app trovata per aprire questo file</string>
<string name="error_no_document">Nessun documento rilevato</string>

View File

@@ -19,6 +19,8 @@
<string name="download_dirname">Downloads</string>
<string name="end_scan">Finalizar digitalização</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_no_app">Nenhum app encontrado para abrir este arquivo</string>
<string name="error_no_document">Nenhum documento detectado</string>

View File

@@ -19,6 +19,8 @@
<string name="download_dirname">Download</string>
<string name="end_scan">Закончить</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_no_app">Не найдено приложение для открытия этого файла</string>
<string name="error_no_document">Документ не обнаружен</string>

View File

@@ -19,6 +19,8 @@
<string name="download_dirname">İndirilenler</string>
<string name="end_scan">Taramayı bitir</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_no_app">No app found to open this file</string>
<string name="error_no_document">No document detected</string>

View File

@@ -19,6 +19,8 @@
<string name="download_dirname">下載 (Downloads)</string>
<string name="end_scan">結束掃描</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_no_app">找不到可開啟此檔案的應用程式</string>
<string name="error_no_document">未偵測到文件</string>

View File

@@ -19,6 +19,8 @@
<string name="download_dirname">下载</string>
<string name="end_scan">结束扫描</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_no_app">未找到可打开此文件的应用</string>
<string name="error_no_document">未检测到任何文档</string>

View File

@@ -20,6 +20,8 @@
<string name="download_dirname">Downloads</string>
<string name="end_scan">End scan</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>
<!-- 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>