diff --git a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt index 11d0c37..24f89ea 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt @@ -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 { + 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 { + 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")), ), ) } diff --git a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt index 636faa1..89d3198 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt @@ -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, - 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() +} diff --git a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt index 24d0c0d..891d985 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt @@ -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() val filesForMediaScan = mutableListOf() 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) { diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index f3eec09..1895a93 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -19,6 +19,8 @@ stažených Ukončit skenování Chyba: %1$s + Složka exportu: %1$s + Poskytovatel: %1$s Vybraná složka pro export již není dostupná. Vyberte prosím jinou složku. Nebyla nalezena žádná aplikace pro otevření tohoto souboru Nebyl rozpoznán žádná dokument diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a856698..e80b051 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -19,6 +19,8 @@ Downloads Scan beenden Fehler: %1$s + Exportordner: %1$s + Anbieter: %1$s Der ausgewählte Exportordner ist nicht mehr zugänglich. Bitte wählen Sie einen anderen Ordner. Keine App zum Öffnen dieser Datei gefunden Kein Dokument erkannt diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index f0882c4..579e51b 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -19,6 +19,8 @@ Descargas Finalizar escaneo Error: %1$s + Carpeta de exportación: %1$s + Proveedor: %1$s La carpeta de exportación seleccionada ya no es accesible. Por favor, elija otra carpeta. No se encontró ninguna aplicación para abrir este archivo No se detectó ningún documento diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 0eb8923..69147a0 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -19,6 +19,8 @@ Téléchargements Terminer le scan Erreur : %1$s + Dossier d’export : %1$s + Fournisseur : %1$s Le dossier d’export sélectionné n’est plus accessible. Veuillez choisir un autre dossier. Aucune application trouvée pour ouvrir ce fichier Aucun document détecté diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 09540cc..5be54b5 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -19,6 +19,8 @@ Download Termina scansione Errore: %1$s + Cartella di esportazione: %1$s + Provider: %1$s La cartella di esportazione selezionata non è più accessibile. Scegli un’altra cartella. Nessuna app trovata per aprire questo file Nessun documento rilevato diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 88578c0..59eafc0 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -19,6 +19,8 @@ Downloads Finalizar digitalização Erro: %1$s + Pasta de exportação: %1$s + Provedor: %1$s A pasta de exportação selecionada não está mais acessível. Escolha outra pasta. Nenhum app encontrado para abrir este arquivo Nenhum documento detectado diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 86ea4b0..2431668 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -19,6 +19,8 @@ Download Закончить Ошибка: %1$s + Папка экспорта: %1$s + Провайдер: %1$s Выбранная папка экспорта больше недоступна. Пожалуйста, выберите другую папку. Не найдено приложение для открытия этого файла Документ не обнаружен diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index fb21ac3..cc90d1e 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -19,6 +19,8 @@ İndirilenler Taramayı bitir Error: %1$s + Dışa aktarma klasörü: %1$s + Sağlayıcı: %1$s Seçilen dışa aktarma dizini artık erişilebilir değil. Lütfen başka bir dizin seçin. No app found to open this file No document detected diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 3247559..0095ab9 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -19,6 +19,8 @@ 下載 (Downloads) 結束掃描 錯誤:%1$s + 匯出資料夾:%1$s + 提供者:%1$s 所選的匯出目錄已無法存取。請選擇其他目錄。 找不到可開啟此檔案的應用程式 未偵測到文件 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 6e4c035..94ad3e4 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -19,6 +19,8 @@ 下载 结束扫描 错误: %1$s + 导出文件夹:%1$s + 提供方:%1$s 所选的导出目录已无法访问。请选择其他目录。 未找到可打开此文件的应用 未检测到任何文档 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cd16a68..12218d4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -20,6 +20,8 @@ Downloads End scan Error: %1$s + Export folder: %1$s + Provider: %1$s The selected export folder is no longer accessible. Please choose another folder. Failed to launch system file picker on this device