Export: detailed error reporting
This commit is contained in:
@@ -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,16 +415,126 @@ 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)
|
||||
.padding(16.dp),
|
||||
.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 {
|
||||
@@ -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")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 d’export : %1$s</string>
|
||||
<string name="error_context_provider">Fournisseur : %1$s</string>
|
||||
<string name="error_export_dir_permission_lost">Le dossier d’export sélectionné n’est 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>
|
||||
|
||||
@@ -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 un’altra cartella.</string>
|
||||
<string name="error_no_app">Nessuna app trovata per aprire questo file</string>
|
||||
<string name="error_no_document">Nessun documento rilevato</string>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user