From 53c9bc363088eaa58fcd6e074c1a7928d35e3886 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Thu, 27 Nov 2025 18:47:18 +0100 Subject: [PATCH] Export PDF to preferred dir if it was defined --- app/build.gradle.kts | 1 + .../java/org/fairscan/app/MainActivity.kt | 15 +++-- .../app/ui/screens/export/ExportScreen.kt | 8 ++- .../app/ui/screens/export/ExportUiState.kt | 1 + .../app/ui/screens/export/ExportViewModel.kt | 64 ++++++++++++++++--- app/src/main/res/values-cs/strings.xml | 3 +- app/src/main/res/values-de/strings.xml | 3 +- app/src/main/res/values-es/strings.xml | 3 +- app/src/main/res/values-fr/strings.xml | 3 +- app/src/main/res/values-it/strings.xml | 3 +- app/src/main/res/values-pt-rBR/strings.xml | 3 +- app/src/main/res/values-ru/strings.xml | 3 +- app/src/main/res/values-zh/strings.xml | 3 +- app/src/main/res/values/strings.xml | 3 +- gradle/libs.versions.toml | 2 + 15 files changed, 91 insertions(+), 27 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b381d26..14a5dd8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -117,6 +117,7 @@ dependencies { implementation(libs.androidx.camera.view) implementation(libs.androidx.datastore) implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.documentfile) implementation(libs.protobuf.javalite) implementation(libs.litert) implementation(libs.litert.support) diff --git a/app/src/main/java/org/fairscan/app/MainActivity.kt b/app/src/main/java/org/fairscan/app/MainActivity.kt index 29806b5..608ec95 100644 --- a/app/src/main/java/org/fairscan/app/MainActivity.kt +++ b/app/src/main/java/org/fairscan/app/MainActivity.kt @@ -17,6 +17,7 @@ package org.fairscan.app import android.Manifest import android.content.ActivityNotFoundException import android.content.ClipData +import android.content.ContentResolver import android.content.Context import android.content.Intent import android.content.pm.PackageManager @@ -297,13 +298,15 @@ class MainActivity : ComponentActivity() { private fun openPdf(fileUri: Uri?) { if (fileUri == null) return - val uri = FileProvider.getUriForFile( - this, - "${applicationContext.packageName}.fileprovider", - fileUri.toFile() - ) + val uriToOpen: Uri = + if (fileUri.scheme == ContentResolver.SCHEME_CONTENT) { + fileUri + } else { + val authority = "${applicationContext.packageName}.fileprovider" + FileProvider.getUriForFile(this, authority, fileUri.toFile()) + } val openIntent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uri, PDF_MIME_TYPE) + setDataAndType(uriToOpen, PDF_MIME_TYPE) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } try { 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 ea25c47..95eb934 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 @@ -221,7 +221,7 @@ private fun TextFieldAndPdfInfos( } if (uiState.savedFileUri != null) { - SavedPdfBar(onOpen) + SavedPdfBar(uiState, onOpen) } if (uiState.errorMessage != null) { ErrorBar(uiState.errorMessage) @@ -335,7 +335,8 @@ fun ExportButton( } @Composable -private fun SavedPdfBar(onOpen: () -> Unit) { +private fun SavedPdfBar(uiState: PdfGenerationUiState, onOpen: () -> Unit) { + val dirName = uiState.exportDirName?:stringResource(R.string.download_dirname) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Absolute.SpaceBetween, @@ -345,7 +346,7 @@ private fun SavedPdfBar(onOpen: () -> Unit) { .padding(vertical = 8.dp, horizontal = 16.dp), ) { Text( - text = stringResource(R.string.pdf_saved_to), + text = stringResource(R.string.pdf_saved_to, dirName), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), ) @@ -428,6 +429,7 @@ fun PreviewExportScreenAfterSaveHorizontal() { uiState = PdfGenerationUiState( generatedPdf = GeneratedPdf(file, 442897L, 3), savedFileUri = file.toUri(), + exportDirName = "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 21e2b4d..13bb64a 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 @@ -22,6 +22,7 @@ data class PdfGenerationUiState( val generatedPdf: GeneratedPdf? = null, val desiredFilename: String = "", val savedFileUri: Uri? = null, + val exportDirName: String? = null, val hasSharedPdf: Boolean = false, val errorMessage: String? = null, ) { 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 04e9b4d..efed805 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 @@ -16,7 +16,9 @@ package org.fairscan.app.ui.screens.export import android.content.Context import android.media.MediaScannerConnection +import android.net.Uri import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers @@ -27,6 +29,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine @@ -36,6 +39,7 @@ import org.fairscan.app.data.GeneratedPdf import org.fairscan.app.data.PdfFileManager import org.fairscan.app.ui.screens.home.HomeViewModel import java.io.File +import java.io.FileInputStream private const val PDF_MIME_TYPE = "application/pdf" @@ -48,6 +52,7 @@ class ExportViewModel(container: AppContainer): ViewModel() { private val pdfFileManager = container.pdfFileManager private val imageRepository = container.imageRepository + private val settingsRepository = container.settingsRepository private val logger = container.logger private val _events = MutableSharedFlow() @@ -121,12 +126,6 @@ class ExportViewModel(container: AppContainer): ViewModel() { return _pdfUiState.value.generatedPdf } - fun saveFile(pdfFile: File): File { - val copiedFile = pdfFileManager.copyToExternalDir(pdfFile) - _pdfUiState.update { it.copy(savedFileUri = copiedFile.toUri()) } - return copiedFile - } - fun onSavePdfClicked() { viewModelScope.launch { _events.emit(ExportEvent.RequestSavePdf) @@ -142,13 +141,30 @@ class ExportViewModel(container: AppContainer): ViewModel() { private suspend fun performPdfSave(context: Context, homeViewModel: HomeViewModel) { try { val pdf = getFinalPdf() ?: return - val targetFile = saveFile(pdf.file) - mediaScan(context, targetFile) + val exportDir = settingsRepository.exportDirUri.first() + var fileInDownloads: File? = null + + val savedUri: Uri = + if (exportDir == null) { + fileInDownloads = pdfFileManager.copyToExternalDir(pdf.file) + fileInDownloads.toUri() + } else { + copyViaSaf(context, pdf.file, exportDir.toUri()) + } + + _pdfUiState.update { + it.copy( + savedFileUri = savedUri, + exportDirName = resolveExportDirName(context, exportDir?.toUri())) + } + + fileInDownloads?.let { mediaScan(context, it) } // TODO remove that call: that should be handled through the ExportEvent homeViewModel.addRecentDocument( - targetFile.absolutePath, + // FIXME This is not a file path + savedUri.toString(), pdf.pageCount ) } catch (e: Exception) { @@ -167,10 +183,40 @@ class ExportViewModel(container: AppContainer): ViewModel() { ) { _, _ -> continuation.resume(Unit) {} } } + private fun copyViaSaf( + context: Context, + source: File, + exportDirUri: Uri, + ): Uri { + val resolver = context.contentResolver + + val tree = DocumentFile.fromTreeUri(context, exportDirUri) + ?: throw IllegalStateException("Invalid SAF directory") + + // Name collisions are handled automatically by SAF provider + val target = tree.createFile(PDF_MIME_TYPE, source.name) + ?: throw IllegalStateException("Unable to create SAF file") + + resolver.openOutputStream(target.uri)?.use { output -> + FileInputStream(source).use { input -> + input.copyTo(output) + } + } ?: throw IllegalStateException("Failed to open SAF output stream") + + return target.uri + } + fun cleanUpOldPdfs(thresholdInMillis: Int) { pdfFileManager.cleanUpOldFiles(thresholdInMillis) } + private fun resolveExportDirName(context: Context, exportDirUri: Uri?): String? { + return if (exportDirUri == null) { + null + } else { + DocumentFile.fromTreeUri(context, exportDirUri)?.name + } + } } data class PdfGenerationActions( diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index fae4b2f..0f98a39 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -15,6 +15,7 @@ Chcete smazat tuto stránku? Vývojář Zrušit skenování + stažených Ukončit skenování Chyba: %1$s Nebyl rozpoznán žádná dokument @@ -33,7 +34,7 @@ Toto skenování bude ztraceno. Chcete pokračovat? Otevřít Otevřít PDF - PDF bylo uloženo do stažených + PDF bylo uloženo do %1s Obnovit Uložit Nové skenování diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 468048a..b3035ae 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -15,6 +15,7 @@ Möchten Sie diese Seite löschen? Entwickler Löschen + Downloads Scan beenden Fehler: %1$s Kein Dokument erkannt @@ -33,7 +34,7 @@ Das aktuelle Dokument geht verloren. Möchten Sie fortfahren? Öffnen PDF öffnen - PDF gespeichert in Downloads + PDF gespeichert in %1s Fortsetzen Speichern Neuer Scan diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index a8441d7..9cdd7b1 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -15,6 +15,7 @@ ¿Quieres eliminar esta página? Desarrollador Descartar escaneo + Descargas Finalizar escaneo Error: %1$s No se detectó ningún documento @@ -33,7 +34,7 @@ El escaneo actual se perderá. ¿Deseas continuar? Abrir Abrir PDF - PDF guardado en Descargas + PDF guardado en %1s Reanudar Guardar Nuevo escaneo diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index ced34a7..84e7904 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -15,6 +15,7 @@ Voulez-vous supprimer cette page ? Développeur Supprimer le scan + Téléchargements Terminer le scan Erreur : %1$s Aucun document détecté @@ -33,7 +34,7 @@ Le scan en cours sera perdu. Voulez-vous continuer ? Ouvrir Ouvrir le PDF - PDF enregistré dans Téléchargements + PDF enregistré dans %1s Reprendre Enregistrer Nouveau scan diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index b7a30c5..bc3cbae 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -15,6 +15,7 @@ Vuoi eliminare questa pagina? Sviluppatore Scarta scansione + Download Termina scansione Errore: %1$s Nessun documento rilevato @@ -33,7 +34,7 @@ La scansiona attuale verrà persa. Vuoi continuare? Apri Apri PDF - PDF salvato in Download + PDF salvato in %1s Riprendi Salva Nuova scansione diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index dcc36cb..ccbbcf9 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -15,6 +15,7 @@ Deseja excluir esta página? Desenvolvedor Descartar digitalização + Downloads Finalizar digitalização Erro: %1$s Nenhum documento detectado @@ -33,7 +34,7 @@ A digitalização atual será perdida. Deseja continuar? Abrir Abrir PDF - PDF salvo em Downloads + PDF salvo em %1s Retomar Salvar Nova digitalização diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 659209e..9602069 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -15,6 +15,7 @@ Вы желаете удалить эту страницу? Разработчик Отказаться + Download Закончить Ошибка: %1$s Документ не обнаружен @@ -33,7 +34,7 @@ Результаты текущего сканирования будут потеряны. Желаете продолжить? Открыть Открыть PDF - PDF сохранен в Download + PDF сохранен в %1s Продолжить Сохранить Начать diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 715d2ac..899ef83 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -15,6 +15,7 @@ 是否要删除此页面? 开发者 放弃扫描 + 下载 结束扫描 错误: %1$s 未检测到任何文档 @@ -33,7 +34,7 @@ 当前扫描将丢失。是否继续? 打开 打开 PDF - PDF 保存到 + PDF 已保存到 %1$s 恢复 保存 新建扫描 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e201c13..dc2b640 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,6 +16,7 @@ Do you want to delete this page? Developer Discard scan + Downloads End scan Error: %1$s No document detected @@ -34,7 +35,7 @@ The current scan will be lost. Do you want to continue? Open Open PDF - PDF saved in Downloads + PDF saved in %1s Resume Save New Scan diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fbce78d..05c6562 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ activityCompose = "1.10.1" composeBom = "2025.08.01" camerax = "1.4.2" datastore = "1.2.0" +documentfile = "1.1.0" litert = "1.4.0" opencv = "4.12.0" assertj = "3.27.4" @@ -47,6 +48,7 @@ androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycl androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" } androidx-datastore = { group = "androidx.datastore", name = "datastore" , version.ref = "datastore" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences" , version.ref = "datastore" } +androidx-documentfile = { group = "androidx.documentfile", name = "documentfile" , version.ref = "documentfile" } protobuf-javalite = { group = "com.google.protobuf", name="protobuf-javalite", version.ref = "protobufJavaLite"} litert = { group = "com.google.ai.edge.litert", name = "litert", version.ref = "litert" } litert-support = { group = "com.google.ai.edge.litert", name = "litert-support", version.ref = "litert" }