From 93db54142b95b2ad23dd5ff155905346796145a3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Yakup=20Furkan=20Fidanc=C4=B1?=
<44572717+fidanci@users.noreply.github.com>
Date: Sun, 11 Oct 2026 03:44:01 +0300
Subject: [PATCH 01/11] Add persistent document library storage
---
.../fairscan/app/data/DocumentFilenames.kt | 44 +++
.../org/fairscan/app/data/DocumentLibrary.kt | 313 ++++++++++++++++++
2 files changed, 357 insertions(+)
create mode 100644 app/src/main/java/org/fairscan/app/data/DocumentFilenames.kt
create mode 100644 app/src/main/java/org/fairscan/app/data/DocumentLibrary.kt
diff --git a/app/src/main/java/org/fairscan/app/data/DocumentFilenames.kt b/app/src/main/java/org/fairscan/app/data/DocumentFilenames.kt
new file mode 100644
index 00000000..d8325f0b
--- /dev/null
+++ b/app/src/main/java/org/fairscan/app/data/DocumentFilenames.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2025-2026 The FairScan authors
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation, either version 3 of the License, or (at your option)
+ * any later version.
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+package org.fairscan.app.data
+
+import java.io.File
+
+fun safeDocumentName(name: String): String {
+ val cleaned = name.trim()
+ .replace(Regex("[\\\\/:*?\"<>|\\p{Cntrl}]"), "_")
+ .trim('.', ' ')
+ .ifBlank { "FairScan" }
+ val extension = cleaned.substringAfterLast('.', "")
+ .takeIf { it.lowercase() in setOf("pdf", "jpg", "jpeg") }
+ ?.let { ".$it" }.orEmpty()
+ var base = cleaned.removeSuffix(extension)
+ // Android filesystems limit names in bytes; leave room for an extension and collision suffix.
+ while (base.toByteArray(Charsets.UTF_8).size > 180) {
+ base = base.substring(0, base.offsetByCodePoints(base.length, -1))
+ }
+ return base.ifBlank { "FairScan" } + extension
+}
+
+fun availableDocumentFile(directory: File, filename: String): File {
+ val desired = File(directory, safeDocumentName(filename))
+ var target = desired
+ var suffix = 1
+ while (target.exists()) {
+ target = File(directory, "${desired.nameWithoutExtension} ($suffix).${desired.extension}")
+ suffix++
+ }
+ return target
+}
diff --git a/app/src/main/java/org/fairscan/app/data/DocumentLibrary.kt b/app/src/main/java/org/fairscan/app/data/DocumentLibrary.kt
new file mode 100644
index 00000000..f3420461
--- /dev/null
+++ b/app/src/main/java/org/fairscan/app/data/DocumentLibrary.kt
@@ -0,0 +1,313 @@
+/*
+ * Copyright 2025-2026 The FairScan authors
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation, either version 3 of the License, or (at your option)
+ * any later version.
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+package org.fairscan.app.data
+
+import android.Manifest
+import android.content.ContentUris
+import android.content.pm.PackageManager
+import android.content.ContentValues
+import android.content.Context
+import android.provider.OpenableColumns
+import android.net.Uri
+import android.os.Build
+import android.os.Environment
+import android.provider.MediaStore
+import androidx.annotation.RequiresApi
+import androidx.core.content.ContextCompat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import org.fairscan.app.ui.screens.export.ExportResult
+import org.fairscan.app.ui.screens.export.SavedBundle
+import org.fairscan.app.ui.screens.export.SavedItem
+import org.fairscan.app.ui.screens.settings.ExportFormat
+import org.fairscan.app.ui.uriForFile
+import java.io.File
+import java.io.IOException
+import kotlin.coroutines.coroutineContext
+
+data class LibraryDocument(
+ val uri: Uri,
+ val name: String,
+ val format: ExportFormat,
+ val size: Long,
+ val modifiedAt: Long,
+)
+
+/** The public Documents/FairScan folder, independent of disposable scan/preparation files. */
+class DocumentLibrary(private val context: Context) {
+ private val resolver = context.contentResolver
+ private val mutex = Mutex()
+ private val legacyDirectory get() = File(
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), "FairScan"
+ )
+
+ suspend fun documents(): List = withContext(Dispatchers.IO) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ // Downloads collection only permits paths under Download/ on current Android.
+ // FairScan is a non-media document library in Documents/FairScan, so use Files.
+ val collection = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
+ val projection = arrayOf(
+ MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DISPLAY_NAME,
+ MediaStore.MediaColumns.MIME_TYPE, MediaStore.MediaColumns.SIZE,
+ MediaStore.MediaColumns.DATE_MODIFIED,
+ )
+ val selection = "${MediaStore.MediaColumns.RELATIVE_PATH} = ? AND " +
+ "${MediaStore.MediaColumns.IS_PENDING} = 0"
+ resolver.query(collection, projection, selection, arrayOf(RELATIVE_PATH),
+ "${MediaStore.MediaColumns.DATE_MODIFIED} DESC, ${MediaStore.MediaColumns._ID} DESC"
+ )?.use { cursor ->
+ buildList {
+ while (cursor.moveToNext()) {
+ val format = ExportFormat.entries.find { it.mimeType == cursor.getString(2) }
+ ?: continue
+ add(LibraryDocument(
+ ContentUris.withAppendedId(collection, cursor.getLong(0)),
+ cursor.getString(1), format, cursor.getLong(3), cursor.getLong(4) * 1000,
+ ))
+ }
+ }
+ } ?: throw IOException("Cannot read the document library")
+ } else {
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ != PackageManager.PERMISSION_GRANTED) {
+ throw SecurityException("Storage permission is required on Android 8 and 9")
+ }
+ if (!legacyDirectory.exists()) return@withContext emptyList()
+ val files = legacyDirectory.listFiles() ?: throw IOException("Cannot read FairScan folder")
+ files.filter { it.isFile && it.extension.lowercase() in setOf("pdf", "jpg", "jpeg") }
+ .sortedByDescending { it.lastModified() }
+ .map { file -> LibraryDocument(
+ uriForFile(context, file), file.name,
+ if (file.extension.equals("pdf", true)) ExportFormat.PDF else ExportFormat.JPEG,
+ file.length(), file.lastModified(),
+ ) }
+ }
+ }
+
+ suspend fun save(result: ExportResult): SavedBundle = withContext(Dispatchers.IO) {
+ mutex.withLock {
+ require(result.files.isNotEmpty())
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) saveModern(result)
+ else saveLegacy(result)
+ }
+ }
+
+ suspend fun importDocuments(uris: List): Int = withContext(Dispatchers.IO) {
+ mutex.withLock {
+ var imported = 0
+ for (sourceUri in uris) {
+ val format = importFormat(sourceUri) ?: continue
+ val name = importedName(sourceUri, format)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ importModern(sourceUri, name, format)
+ } else {
+ importLegacy(sourceUri, name)
+ }
+ imported++
+ }
+ imported
+ }
+ }
+
+ suspend fun deleteDocuments(documents: List): Int = withContext(Dispatchers.IO) {
+ mutex.withLock {
+ var deleted = 0
+ for (document in documents) {
+ val succeeded = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ resolver.delete(document.uri, null, null) == 1
+ } else {
+ File(legacyDirectory, document.name).delete()
+ }
+ if (!succeeded) throw IOException("Cannot delete ${document.name}")
+ deleted++
+ }
+ deleted
+ }
+ }
+
+ suspend fun renameDocument(document: LibraryDocument, requestedName: String): Unit = withContext(Dispatchers.IO) {
+ mutex.withLock {
+ val extension = when (document.format) {
+ ExportFormat.PDF -> ".pdf"
+ ExportFormat.JPEG -> if (document.name.endsWith(".jpeg", true)) ".jpeg" else ".jpg"
+ }
+ val base = requestedName.trim().substringBeforeLast('.', requestedName.trim())
+ .trim().ifBlank { document.name.substringBeforeLast('.') }
+ val targetName = safeDocumentName(base + extension)
+ if (targetName == document.name) return@withLock
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ val uniqueName = uniqueModernName(targetName)
+ val updated = resolver.update(document.uri, ContentValues().apply {
+ put(MediaStore.MediaColumns.DISPLAY_NAME, uniqueName)
+ }, null, null)
+ if (updated != 1) throw IOException("Cannot rename ${document.name}")
+ } else {
+ val source = File(legacyDirectory, document.name)
+ val target = availableDocumentFile(legacyDirectory, targetName)
+ if (!source.renameTo(target)) throw IOException("Cannot rename ${document.name}")
+ }
+ }
+ }
+
+ private fun importFormat(uri: Uri): ExportFormat? {
+ val name = sourceName(uri)
+ return when {
+ resolver.getType(uri)?.equals("application/pdf", ignoreCase = true) == true ||
+ name.endsWith(".pdf", ignoreCase = true) -> ExportFormat.PDF
+ resolver.getType(uri)?.equals("image/jpeg", ignoreCase = true) == true ||
+ name.endsWith(".jpg", ignoreCase = true) || name.endsWith(".jpeg", ignoreCase = true) ->
+ ExportFormat.JPEG
+ else -> null
+ }
+ }
+
+ private fun importedName(uri: Uri, format: ExportFormat): String {
+ val originalName = safeDocumentName(sourceName(uri))
+ return if (originalName.substringAfterLast('.', "").isNotEmpty()) originalName
+ else originalName + if (format == ExportFormat.PDF) ".pdf" else ".jpg"
+ }
+
+ private fun sourceName(uri: Uri): String = resolver.query(
+ uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null
+ )?.use { cursor ->
+ if (cursor.moveToFirst()) cursor.getString(0) else null
+ }?.takeIf(String::isNotBlank) ?: uri.lastPathSegment ?: "FairScan"
+
+ @RequiresApi(Build.VERSION_CODES.Q)
+ private fun importModern(sourceUri: Uri, name: String, format: ExportFormat) {
+ val targetName = uniqueModernName(name)
+ val values = ContentValues().apply {
+ put(MediaStore.MediaColumns.DISPLAY_NAME, targetName)
+ put(MediaStore.MediaColumns.MIME_TYPE, format.mimeType)
+ put(MediaStore.MediaColumns.RELATIVE_PATH, RELATIVE_PATH)
+ put(MediaStore.MediaColumns.IS_PENDING, 1)
+ }
+ val targetUri = resolver.insert(
+ MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), values
+ ) ?: throw IOException("Cannot create imported document")
+ try {
+ val input = resolver.openInputStream(sourceUri) ?: throw IOException("Cannot read selected document")
+ val output = resolver.openOutputStream(targetUri) ?: throw IOException("Cannot write imported document")
+ input.use { source -> output.use { source.copyTo(it) } }
+ if (resolver.update(targetUri, ContentValues().apply {
+ put(MediaStore.MediaColumns.IS_PENDING, 0)
+ }, null, null) != 1) throw IOException("Cannot finish importing document")
+ } catch (error: Exception) {
+ runCatching { resolver.delete(targetUri, null, null) }
+ throw error
+ }
+ }
+
+ private fun importLegacy(sourceUri: Uri, name: String) {
+ if (!legacyDirectory.isDirectory && !legacyDirectory.mkdirs()) {
+ throw IOException("Cannot create FairScan folder")
+ }
+ val target = availableDocumentFile(legacyDirectory, name)
+ try {
+ val input = resolver.openInputStream(sourceUri) ?: throw IOException("Cannot read selected document")
+ input.use { source -> target.outputStream().use { source.copyTo(it) } }
+ } catch (error: Exception) {
+ target.delete()
+ throw error
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.Q)
+ private fun uniqueModernName(name: String): String {
+ val collection = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
+ val existing = resolver.query(
+ collection,
+ arrayOf(MediaStore.MediaColumns.DISPLAY_NAME),
+ "${MediaStore.MediaColumns.RELATIVE_PATH} = ?",
+ arrayOf(RELATIVE_PATH),
+ null,
+ )?.use { cursor ->
+ buildSet { while (cursor.moveToNext()) add(cursor.getString(0).lowercase()) }
+ } ?: emptySet()
+ val safeName = safeDocumentName(name)
+ if (safeName.lowercase() !in existing) return safeName
+ val base = safeName.substringBeforeLast('.', safeName)
+ val extension = safeName.substringAfterLast('.', "").takeIf(String::isNotEmpty)?.let { ".$it" }.orEmpty()
+ var suffix = 1
+ while ("$base ($suffix)$extension".lowercase() in existing) suffix++
+ return "$base ($suffix)$extension"
+ }
+
+ @RequiresApi(Build.VERSION_CODES.Q)
+ private suspend fun saveModern(result: ExportResult): SavedBundle {
+ val created = mutableListOf()
+ try {
+ val items = result.files.map { source ->
+ coroutineContext.ensureActive()
+ val values = ContentValues().apply {
+ put(MediaStore.MediaColumns.DISPLAY_NAME, source.name)
+ put(MediaStore.MediaColumns.MIME_TYPE, result.format.mimeType)
+ put(MediaStore.MediaColumns.RELATIVE_PATH, RELATIVE_PATH)
+ put(MediaStore.MediaColumns.IS_PENDING, 1)
+ }
+ val uri = resolver.insert(
+ MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), values
+ )
+ ?: throw IOException("Cannot create document")
+ created += uri
+ resolver.openOutputStream(uri)?.use { output ->
+ source.inputStream().use { it.copyTo(output) }
+ } ?: throw IOException("Cannot write document")
+ val actualName = resolver.query(uri, arrayOf(MediaStore.MediaColumns.DISPLAY_NAME),
+ null, null, null)?.use { if (it.moveToFirst()) it.getString(0) else null }
+ ?: source.name
+ SavedItem(uri, actualName, result.format)
+ }
+ coroutineContext.ensureActive()
+ for (uri in created) {
+ if (resolver.update(uri, ContentValues().apply {
+ put(MediaStore.MediaColumns.IS_PENDING, 0)
+ }, null, null) != 1) throw IOException("Cannot finish saving document")
+ }
+ return SavedBundle(items)
+ } catch (error: Exception) {
+ // Do not leave corrupt PDFs, incomplete JPEG batches or abandoned pending entries.
+ created.forEach { runCatching { resolver.delete(it, null, null) } }
+ throw error
+ }
+ }
+
+ private suspend fun saveLegacy(result: ExportResult): SavedBundle {
+ if (!legacyDirectory.isDirectory && !legacyDirectory.mkdirs()) {
+ throw IOException("Cannot create FairScan folder")
+ }
+ val created = mutableListOf()
+ try {
+ val items = result.files.map { source ->
+ coroutineContext.ensureActive()
+ val target = availableDocumentFile(legacyDirectory, source.name)
+ created += target
+ source.copyTo(target)
+ SavedItem(uriForFile(context, target), target.name, result.format)
+ }
+ return SavedBundle(items)
+ } catch (error: Exception) {
+ created.forEach { it.delete() }
+ throw error
+ }
+ }
+
+ companion object {
+ const val RELATIVE_PATH = "Documents/FairScan/"
+ }
+}
From 937706759e09bdb0cb54be073da38965d59ee6ef Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Yakup=20Furkan=20Fidanc=C4=B1?=
<44572717+fidanci@users.noreply.github.com>
Date: Sun, 11 Oct 2026 03:44:20 +0300
Subject: [PATCH 02/11] Connect document library with the app flow
---
.../main/java/org/fairscan/app/FairScanApp.kt | 2 +
.../java/org/fairscan/app/MainActivity.kt | 1130 +++++++++--------
.../java/org/fairscan/app/MainViewModel.kt | 23 +-
3 files changed, 610 insertions(+), 545 deletions(-)
diff --git a/app/src/main/java/org/fairscan/app/FairScanApp.kt b/app/src/main/java/org/fairscan/app/FairScanApp.kt
index 49a03b29..45209492 100644
--- a/app/src/main/java/org/fairscan/app/FairScanApp.kt
+++ b/app/src/main/java/org/fairscan/app/FairScanApp.kt
@@ -26,6 +26,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.fairscan.app.data.FileLogger
+import org.fairscan.app.data.DocumentLibrary
import org.fairscan.app.data.FileManager
import org.fairscan.app.data.LogRepository
import org.fairscan.app.data.OcrLanguageRepository
@@ -57,6 +58,7 @@ class AppContainer(context: Context) {
private val cacheDir = context.cacheDir
private val dataStore = context.dataStore
val preparationDir = File(context.cacheDir, "pdfs")
+ val documentLibrary = DocumentLibrary(context.applicationContext)
val ocrLanguageRepository =
OcrLanguageRepository(dataStore, File(context.filesDir, "tesseract/tessdata"))
val ocrService = OcrService(ocrLanguageRepository, scope)
diff --git a/app/src/main/java/org/fairscan/app/MainActivity.kt b/app/src/main/java/org/fairscan/app/MainActivity.kt
index 7c99fb95..afe3cb6d 100644
--- a/app/src/main/java/org/fairscan/app/MainActivity.kt
+++ b/app/src/main/java/org/fairscan/app/MainActivity.kt
@@ -1,542 +1,598 @@
-/*
- * Copyright 2025-2026 The FairScan authors
- *
- * This program is free software: you can redistribute it and/or modify it
- * under the terms of the GNU General Public License as published by the Free
- * Software Foundation, either version 3 of the License, or (at your option)
- * any later version.
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
- * more details.
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
-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
-import android.content.pm.PackageManager.PERMISSION_GRANTED
-import android.net.Uri
-import android.os.Build.VERSION.SDK_INT
-import android.os.Build.VERSION_CODES.Q
-import android.os.Bundle
-import android.util.Log
-import android.view.KeyEvent
-import android.widget.Toast
-import androidx.activity.ComponentActivity
-import androidx.activity.compose.ManagedActivityResultLauncher
-import androidx.activity.compose.rememberLauncherForActivityResult
-import androidx.activity.compose.setContent
-import androidx.activity.enableEdgeToEdge
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.activity.viewModels
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.platform.LocalClipboard
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.toClipEntry
-import androidx.compose.ui.res.stringResource
-import androidx.core.content.ContextCompat.checkSelfPermission
-import androidx.core.net.toFile
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import androidx.lifecycle.lifecycleScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import org.fairscan.app.data.FileLogger
-import org.fairscan.app.data.ImageRepository
-import org.fairscan.app.ui.Navigation
-import org.fairscan.app.ui.Screen
-import org.fairscan.app.ui.components.rememberCameraPermissionState
-import org.fairscan.app.ui.screens.LibrariesScreen
-import org.fairscan.app.ui.screens.ResumeScanScreen
-import org.fairscan.app.ui.screens.about.AboutEvent
-import org.fairscan.app.ui.screens.about.AboutScreen
-import org.fairscan.app.ui.screens.about.AboutViewModel
-import org.fairscan.app.ui.screens.about.createEmailWithImageIntent
-import org.fairscan.app.ui.screens.camera.CameraEvent
-import org.fairscan.app.ui.screens.camera.CameraScreen
-import org.fairscan.app.ui.screens.camera.CameraViewModel
-import org.fairscan.app.ui.screens.crop.CropScreen
-import org.fairscan.app.ui.screens.document.DocumentScreen
-import org.fairscan.app.ui.screens.export.ExportActions
-import org.fairscan.app.ui.screens.export.ExportEvent
-import org.fairscan.app.ui.screens.export.ExportResult
-import org.fairscan.app.ui.screens.export.ExportScreenWrapper
-import org.fairscan.app.ui.screens.export.ExportViewModel
-import org.fairscan.app.ui.screens.settings.OcrLanguagesScreen
-import org.fairscan.app.ui.screens.settings.SettingsScreen
-import org.fairscan.app.ui.screens.settings.SettingsUiState
-import org.fairscan.app.ui.screens.settings.SettingsViewModel
-import org.fairscan.app.ui.theme.FairScanTheme
-import org.opencv.android.OpenCVLoader
-import java.io.File
-
-class MainActivity : ComponentActivity() {
-
- private lateinit var cameraViewModel: CameraViewModel
- private lateinit var viewModel: MainViewModel
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- initLibraries()
-
- val appContainer = (application as FairScanApp).appContainer
- val launchMode = resolveLaunchMode(intent)
-
- val sessionViewModel: SessionViewModel by viewModels {
- SessionViewModelFactory(
- application = application,
- launchMode = launchMode,
- appContainer = appContainer
- )
- }
-
- val imageRepository = sessionViewModel.imageRepository
- viewModel = viewModels {
- appContainer.viewModelFactory {
- MainViewModel(imageRepository, appContainer.logger)
- }
- }.value
- val exportViewModel: ExportViewModel by viewModels {
- appContainer.viewModelFactory {
- ExportViewModel(appContainer, imageRepository)
- }
- }
- val aboutViewModel: AboutViewModel by viewModels {
- appContainer.viewModelFactory {
- AboutViewModel(appContainer, imageRepository)
- }
- }
- cameraViewModel = viewModels { appContainer.cameraViewModelFactory }.value
-
- val settingsViewModel: SettingsViewModel
- by viewModels { appContainer.settingsViewModelFactory }
- lifecycleScope.launch(Dispatchers.IO) {
- exportViewModel.cleanUpOldPreparedFiles(1000 * 3600)
- }
- enableEdgeToEdge()
- setContent {
- val logger = appContainer.logger
- val context = LocalContext.current
- val currentScreen by viewModel.currentScreen.collectAsStateWithLifecycle()
- val liveAnalysisState by cameraViewModel.liveAnalysisState.collectAsStateWithLifecycle()
- val importState by cameraViewModel.importState.collectAsStateWithLifecycle()
- val document by viewModel.documentUiModel.collectAsStateWithLifecycle()
- val documentUiState by viewModel.documentUiState.collectAsStateWithLifecycle()
- val cropInitialState by viewModel.cropInitState.collectAsStateWithLifecycle()
- val exportUiState by exportViewModel.uiState.collectAsStateWithLifecycle()
- val settingsUiState by settingsViewModel.uiState.collectAsStateWithLifecycle()
- val cameraPermission = rememberCameraPermissionState()
- CollectCameraEvents(cameraViewModel, viewModel)
- CollectExportEvents(context, exportViewModel)
- CollectAboutEvents(context, aboutViewModel, imageRepository)
-
- FairScanTheme {
- val navigation = navigation(viewModel, launchMode)
- val onExportClick = if (launchMode == LaunchMode.EXTERNAL_SCAN_TO_PDF) {
- {
- lifecycleScope.launch {
- try {
- val result = exportViewModel.generatePdfForExternalCall()
- sendActivityResult(result)
- } catch (e: Exception) {
- val message = "Export failed"
- showToast(message)
- appContainer.logger.e("MainActivity", message, e)
- return@launch
- }
- viewModel.startNewDocument()
- finish()
- }
- Unit
- }
- } else {
- navigation.toExportScreen
- }
-
- when (currentScreen) {
- null -> {
- // waiting to load pages to get an initial screen
- }
- is Screen.Main.ResumeScan -> {
- ResumeScanScreen(
- currentDocument = documentUiState,
- onResumeScan = navigation.toCameraScreen,
- onStartNewScan = {
- viewModel.startNewDocument()
- navigation.toCameraScreen()
- }
- )
- }
- is Screen.Main.Camera -> {
- val pickMultiple = rememberLauncherForActivityResult(
- ActivityResultContracts.GetMultipleContents()) {
- uris -> cameraViewModel.importPhotos(uris)
- }
- CameraScreen(
- viewModel,
- cameraViewModel,
- navigation,
- liveAnalysisState,
- importState,
- onImageAnalyzed = { image -> cameraViewModel.liveAnalysis(image) },
- onFinalizePressed = onExportClick,
- cameraPermission = cameraPermission,
- onImportClicked = {
- cameraViewModel.onImportClicked()
- pickMultiple.launch("image/*")
- }
- )
- }
- is Screen.Main.EditImage -> {
- CropScreen(
- pageId = documentUiState.currentPage?.key?.pageId ?: "",
- initState = cropInitialState,
- navigation = navigation,
- onUpdatePageQuad = { quad -> viewModel.setCurrentPageUserQuad(quad) },
- )
- }
- is Screen.Main.Document -> {
- DocumentScreen (
- uiState = documentUiState,
- navigation = navigation,
- onExportClick = onExportClick,
- onDeleteImage = { viewModel.deleteCurrentPage() },
- onRotateImage = { clockwise -> viewModel.rotateCurrentPage(clockwise) },
- onColorModeSelected = { viewModel.setCurrentPageColorMode(it) },
- onCropClick = { viewModel.onClickOnCropButton() },
- onPageReorder = { id, newIndex -> viewModel.movePage(id, newIndex) },
- onPageSelected = viewModel::onPageSelected
- )
- }
- is Screen.Main.Export -> {
- ExportScreenWrapper(
- navigation = navigation,
- uiState = exportUiState,
- currentDocument = document,
- exportActions = ExportActions(
- prepareExportIfNeeded = exportViewModel::prepareExportIfNeeded,
- setFilename = exportViewModel::setFilename,
- share = { exportViewModel.onShareClicked() },
- save = { exportViewModel.onSaveClicked() },
- open = { item -> openUri(item.uri, item.format.mimeType, logger) },
- cancelPreparationJob = exportViewModel::cancelPreparationJob,
- ),
- onCloseScan = {
- exportViewModel.resetFilename()
- viewModel.startNewDocument()
- viewModel.navigateTo(Screen.Main.Camera)
- }
- )
- }
- is Screen.Overlay.About -> {
- LaunchedEffect(Unit) {
- aboutViewModel.refreshLastCapturedImageState()
- }
- val aboutUiState by aboutViewModel.uiState.collectAsStateWithLifecycle()
- AboutScreen(
- aboutUiState = aboutUiState,
- onBack = navigation.back,
- onCopyLogs = { aboutViewModel.onCopyLogsClicked() },
- onContactWithLastImageClicked =
- { aboutViewModel.onContactWithLastImageClicked() },
- onStartActivity = { intent ->
- try {
- startActivity(intent)
- } catch (_: ActivityNotFoundException) {
- showToast(getString(R.string.error_occurred))
- }
+/*
+ * Copyright 2025-2026 The FairScan authors
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation, either version 3 of the License, or (at your option)
+ * any later version.
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+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.PERMISSION_GRANTED
+import android.net.Uri
+import android.os.Build.VERSION.SDK_INT
+import android.os.Build.VERSION_CODES.Q
+import android.os.Bundle
+import android.util.Log
+import android.view.KeyEvent
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.ManagedActivityResultLauncher
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.platform.LocalClipboard
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.toClipEntry
+import androidx.compose.ui.res.stringResource
+import androidx.core.content.ContextCompat.checkSelfPermission
+import androidx.core.net.toFile
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import androidx.lifecycle.lifecycleScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import org.fairscan.app.data.FileLogger
+import org.fairscan.app.data.ImageRepository
+import org.fairscan.app.ui.Navigation
+import org.fairscan.app.ui.Screen
+import org.fairscan.app.ui.components.rememberCameraPermissionState
+import org.fairscan.app.ui.screens.LibrariesScreen
+import org.fairscan.app.ui.screens.library.LibraryScreen
+import org.fairscan.app.ui.screens.library.LibraryViewModel
+import org.fairscan.app.ui.screens.library.LibraryDocumentViewer
+import org.fairscan.app.ui.screens.export.SavedBundle
+import org.fairscan.app.ui.screens.export.SavedItem
+import org.fairscan.app.ui.screens.ResumeScanScreen
+import org.fairscan.app.ui.screens.about.AboutEvent
+import org.fairscan.app.ui.screens.about.AboutScreen
+import org.fairscan.app.ui.screens.about.AboutViewModel
+import org.fairscan.app.ui.screens.about.createEmailWithImageIntent
+import org.fairscan.app.ui.screens.camera.CameraEvent
+import org.fairscan.app.ui.screens.camera.CameraScreen
+import org.fairscan.app.ui.screens.camera.CameraViewModel
+import org.fairscan.app.ui.screens.crop.CropScreen
+import org.fairscan.app.ui.screens.document.DocumentScreen
+import org.fairscan.app.ui.screens.export.ExportActions
+import org.fairscan.app.ui.screens.export.ExportEvent
+import org.fairscan.app.ui.screens.export.ExportResult
+import org.fairscan.app.ui.screens.export.ExportScreenWrapper
+import org.fairscan.app.ui.screens.export.ExportViewModel
+import org.fairscan.app.ui.screens.settings.OcrLanguagesScreen
+import org.fairscan.app.ui.screens.settings.SettingsScreen
+import org.fairscan.app.ui.screens.settings.SettingsUiState
+import org.fairscan.app.ui.screens.settings.SettingsViewModel
+import org.fairscan.app.ui.theme.FairScanTheme
+import org.opencv.android.OpenCVLoader
+import java.io.File
+
+class MainActivity : ComponentActivity() {
+
+ private lateinit var cameraViewModel: CameraViewModel
+ private lateinit var viewModel: MainViewModel
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ initLibraries()
+
+ val appContainer = (application as FairScanApp).appContainer
+ val launchMode = resolveLaunchMode(intent)
+
+ val sessionViewModel: SessionViewModel by viewModels {
+ SessionViewModelFactory(
+ application = application,
+ launchMode = launchMode,
+ appContainer = appContainer
+ )
+ }
+
+ val imageRepository = sessionViewModel.imageRepository
+ viewModel = viewModels {
+ appContainer.viewModelFactory {
+ MainViewModel(imageRepository, appContainer.logger, launchMode)
+ }
+ }.value
+ val exportViewModel: ExportViewModel by viewModels {
+ appContainer.viewModelFactory {
+ ExportViewModel(appContainer, imageRepository)
+ }
+ }
+ val libraryViewModel: LibraryViewModel by viewModels {
+ appContainer.viewModelFactory {
+ LibraryViewModel(appContainer.documentLibrary, appContainer.logger)
+ }
+ }
+ val aboutViewModel: AboutViewModel by viewModels {
+ appContainer.viewModelFactory {
+ AboutViewModel(appContainer, imageRepository)
+ }
+ }
+ cameraViewModel = viewModels { appContainer.cameraViewModelFactory }.value
+
+ val settingsViewModel: SettingsViewModel
+ by viewModels { appContainer.settingsViewModelFactory }
+ lifecycleScope.launch(Dispatchers.IO) {
+ exportViewModel.cleanUpOldPreparedFiles(1000 * 3600)
+ }
+ enableEdgeToEdge()
+ setContent {
+ val logger = appContainer.logger
+ val context = LocalContext.current
+ val currentScreen by viewModel.currentScreen.collectAsStateWithLifecycle()
+ val liveAnalysisState by cameraViewModel.liveAnalysisState.collectAsStateWithLifecycle()
+ val importState by cameraViewModel.importState.collectAsStateWithLifecycle()
+ val document by viewModel.documentUiModel.collectAsStateWithLifecycle()
+ val documentUiState by viewModel.documentUiState.collectAsStateWithLifecycle()
+ val cropInitialState by viewModel.cropInitState.collectAsStateWithLifecycle()
+ val exportUiState by exportViewModel.uiState.collectAsStateWithLifecycle()
+ val settingsUiState by settingsViewModel.uiState.collectAsStateWithLifecycle()
+ val cameraPermission = rememberCameraPermissionState()
+ CollectCameraEvents(cameraViewModel, viewModel)
+ CollectExportEvents(context, exportViewModel) {
+ exportViewModel.resetFilename()
+ viewModel.startNewDocument { viewModel.navigateTo(Screen.Main.Home) }
+ }
+ CollectAboutEvents(context, aboutViewModel, imageRepository)
+
+ FairScanTheme {
+ val navigation = navigation(viewModel, launchMode)
+ val onExportClick = if (launchMode == LaunchMode.EXTERNAL_SCAN_TO_PDF) {
+ {
+ lifecycleScope.launch {
+ try {
+ val result = exportViewModel.generatePdfForExternalCall()
+ sendActivityResult(result)
+ } catch (e: Exception) {
+ val message = "Export failed"
+ showToast(message)
+ appContainer.logger.e("MainActivity", message, e)
+ return@launch
+ }
+ viewModel.startNewDocument()
+ finish()
+ }
+ Unit
+ }
+ } else {
+ navigation.toExportScreen
+ }
+
+ when (currentScreen) {
+ null -> {
+ // waiting to load pages to get an initial screen
+ }
+ is Screen.Main.Home -> {
+ val libraryState by libraryViewModel.uiState.collectAsStateWithLifecycle()
+ val libraryPermission = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { libraryViewModel.refresh() }
+ val importDocuments = rememberLauncherForActivityResult(
+ ActivityResultContracts.OpenMultipleDocuments()
+ ) { uris -> libraryViewModel.importDocuments(uris) }
+ LifecycleResumeEffect(Unit) {
+ libraryViewModel.refresh()
+ onPauseOrDispose { }
+ }
+ LibraryScreen(
+ state = libraryState,
+ hasDraft = document.pageCount() > 0,
+ onScan = viewModel::openScanner,
+ onResumeDraft = navigation.toCameraScreen,
+ onOpen = {
+ viewModel.navigateTo(Screen.Main.LibraryDocument(
+ it.uri.toString(), it.name, it.format.mimeType
+ ))
},
- onViewLibraries = navigation.toLibrariesScreen)
- }
- is Screen.Overlay.Libraries -> {
- LibrariesScreen(onBack = navigation.back)
- }
- is Screen.Overlay.Settings -> {
- SettingsScreenWrapper(settingsUiState, settingsViewModel, navigation, logger)
+ onShare = { share(SavedBundle(listOf(SavedItem(it.uri, it.name, it.format)))) },
+ onAddDocuments = {
+ importDocuments.launch(arrayOf("application/pdf", "image/jpeg"))
+ },
+ onRename = libraryViewModel::renameDocument,
+ onDelete = libraryViewModel::deleteDocument,
+ onRetry = {
+ checkPermissionThen(libraryPermission) { libraryViewModel.refresh() }
+ },
+ )
+ }
+ is Screen.Main.ResumeScan -> {
+ BackHandler { navigation.back() }
+ ResumeScanScreen(
+ currentDocument = documentUiState,
+ onResumeScan = navigation.toCameraScreen,
+ onStartNewScan = {
+ viewModel.startNewDocument { navigation.toCameraScreen() }
+ }
+ )
}
- is Screen.Overlay.OcrLanguages -> {
- OcrLanguagesScreen(
- uiState = settingsUiState,
+ is Screen.Main.LibraryDocument -> {
+ BackHandler { navigation.back() }
+ val libraryDocument = currentScreen as Screen.Main.LibraryDocument
+ LibraryDocumentViewer(
+ uri = Uri.parse(libraryDocument.uri),
+ name = libraryDocument.name,
+ mimeType = libraryDocument.mimeType,
onBack = navigation.back,
- onLanguageClick = settingsViewModel::onLanguageClick,
- onRemoveLanguage = settingsViewModel::onRemoveLanguage,
- onCancelOcrDownload = settingsViewModel::cancelOcrDownload,
)
}
- }
- }
- }
- }
-
- private fun resolveLaunchMode(intent: Intent?): LaunchMode {
- return when (intent?.action) {
- "org.fairscan.app.action.SCAN_TO_PDF" -> LaunchMode.EXTERNAL_SCAN_TO_PDF
- "android.intent.action.GET_CONTENT" -> LaunchMode.EXTERNAL_SCAN_TO_PDF
- else -> LaunchMode.NORMAL
- }
- }
-
- private fun showToast(text: String) {
- Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
- }
-
- @Composable
- private fun SettingsScreenWrapper(
- settingsUiState: SettingsUiState,
- settingsViewModel: SettingsViewModel,
- nav: Navigation,
- logger: FileLogger,
- ) {
- val launcher = rememberLauncherForActivityResult(
- contract = ActivityResultContracts.OpenDocumentTree()
- ) { uri ->
- if (uri != null) {
- val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
- Intent.FLAG_GRANT_WRITE_URI_PERMISSION
- try {
- contentResolver.takePersistableUriPermission(uri, flags)
- settingsViewModel.setExportDirUri(uri.toString())
- } catch (e: Exception) {
- logger.e("Settings", "Failed to set export dir to $uri", e)
- showToast(this.getString(R.string.error_file_picker_result))
- }
- }
- }
- LaunchedEffect(Unit) {
- settingsViewModel.refreshExportDirName()
- }
- SettingsScreen(
- settingsUiState,
- onDefaultColorModeChanged = { mode -> settingsViewModel.setDefaultColorMode(mode) },
- onChooseDirectoryClick = {
- try {
- launcher.launch(null)
- } catch (e: Exception) {
- val message = getString(R.string.error_file_picker_launch)
- logger.e("Settings", message, e)
- showToast(message)
- }
- },
- onResetExportDirClick = { settingsViewModel.setExportDirUri(null) },
- onExportFormatChanged = { format -> settingsViewModel.setExportFormat(format) },
- onExportQualityChanged = { quality -> settingsViewModel.setExportQuality(quality) },
- onDefaultFileNameStyleChanged = settingsViewModel::setDefaultFilenameStyle,
- navigation = nav,
- )
- }
-
- @Composable
- private fun CollectAboutEvents(
- context: Context,
- aboutViewModel: AboutViewModel,
- imageRepository: ImageRepository,
- ) {
- val clipboard = LocalClipboard.current
- val msgCopiedLogs = stringResource(R.string.copied_logs)
- LaunchedEffect(Unit) {
- aboutViewModel.events.collect { event ->
- when (event) {
- is AboutEvent.CopyLogs -> {
- clipboard.setClipEntry(
- ClipData.newPlainText("FairScan logs", event.logs).toClipEntry()
- )
- showToast(msgCopiedLogs)
- }
- is AboutEvent.PrepareEmailWithLastImage -> {
- val file = imageRepository.lastAddedSourceFile()
- if (file != null) {
- startActivity(createEmailWithImageIntent(context, file))
- }
- }
- }
- }
- }
- }
-
- @Composable
- private fun CollectExportEvents(
- context: Context,
- exportViewModel: ExportViewModel,
- ) {
- val storagePermissionLauncher = rememberLauncherForActivityResult(
- ActivityResultContracts.RequestPermission()
- ) { isGranted ->
- if (isGranted) {
- exportViewModel.onSaveClicked()
- } else {
- showToast(this.getString(R.string.storage_permission_denied))
- }
- }
- LaunchedEffect(Unit) {
- exportViewModel.events.collect { event ->
- when (event) {
- ExportEvent.RequestSave -> {
- checkPermissionThen(storagePermissionLauncher) {
- exportViewModel.onRequestSave(context)
- }
- }
- is ExportEvent.Share -> {
- share(event.result)
- }
- }
- }
- }
- }
-
- @Composable
- private fun CollectCameraEvents(
- cameraViewModel: CameraViewModel,
- viewModel: MainViewModel,
- ) {
- LaunchedEffect(Unit) {
- cameraViewModel.events.collect { event ->
- when (event) {
- is CameraEvent.ImageCaptured -> viewModel.handleImageCaptured(event.page)
- }
- }
- }
- }
-
- private fun share(result: ExportResult) {
- if (result.files.isEmpty()) return
-
- val uris = result.files.map(::uriForFile)
- val intent = Intent().apply {
- action = if (uris.size == 1) Intent.ACTION_SEND else Intent.ACTION_SEND_MULTIPLE
- type = result.format.mimeType
- addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
-
- if (uris.size == 1) {
- putExtra(Intent.EXTRA_STREAM, uris[0])
- } else {
- putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris))
- }
- }
- val chooser = Intent.createChooser(intent, getString(R.string.share_document))
-
- val resolveInfos = packageManager.queryIntentActivities(chooser, PackageManager.MATCH_DEFAULT_ONLY)
- for (info in resolveInfos) {
- val pkg = info.activityInfo.packageName
- for (uri in uris) {
- grantUriPermission(pkg, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
- }
- }
-
- startActivity(chooser)
- }
-
- private fun sendActivityResult(result: ExportResult?) {
- val pdf = result as? ExportResult.Pdf ?: return
-
- val uri = uriForFile(pdf.file)
- val resultIntent = Intent().apply {
- data = uri
- clipData = ClipData.newRawUri(null, uri)
- addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
- }
-
- setResult(RESULT_OK, resultIntent)
- }
-
- private fun uriForFile(file: File): Uri {
- return org.fairscan.app.ui.uriForFile(this, file)
- }
-
- private fun checkPermissionThen(
- requestPermissionLauncher: ManagedActivityResultLauncher,
- action: () -> Unit
- ) {
- val permission = Manifest.permission.WRITE_EXTERNAL_STORAGE
- if (SDK_INT < Q && checkSelfPermission(this, permission) != PERMISSION_GRANTED
- ) {
- requestPermissionLauncher.launch(permission)
- } else {
- action()
- }
- }
-
- private fun openUri(fileUri: Uri?, mimeType: String, logger: FileLogger) {
- if (fileUri == null) return
- val uriToOpen: Uri =
- if (fileUri.scheme == ContentResolver.SCHEME_CONTENT) {
- fileUri
- } else {
- uriForFile(fileUri.toFile())
- }
- try {
- val openIntent = Intent(Intent.ACTION_VIEW).apply {
- setDataAndType(uriToOpen, mimeType)
- addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
- clipData = ClipData.newUri(contentResolver, "file", uriToOpen)
- }
- val chooser = Intent.createChooser(openIntent, getString(R.string.open_file)).apply {
- addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
- }
- startActivity(chooser)
- } catch (_: ActivityNotFoundException) {
- showToast(getString(R.string.error_no_app))
- } catch (e: Exception) {
- val errorMessage =
- "Failed to open URI, scheme=${uriToOpen.scheme}, authority=${uriToOpen.authority}"
- logger.e("OpenUri", errorMessage, e)
- showToast(getString(R.string.error_occurred))
- }
- }
-
- private fun initLibraries() {
- com.tom_roush.pdfbox.android.PDFBoxResourceLoader.init(applicationContext)
-
- if (!OpenCVLoader.initLocal()) {
- Log.e("OpenCV", "Initialization failed")
- } else {
- Log.d("OpenCV", "Initialization successful")
- }
- }
-
- private fun navigation(viewModel: MainViewModel, launchMode: LaunchMode): Navigation = Navigation(
- toCameraScreen = { viewModel.navigateTo(Screen.Main.Camera) },
- toEditImageScreen = { viewModel.navigateTo(Screen.Main.EditImage) },
- toDocumentScreen = { viewModel.navigateTo(Screen.Main.Document()) },
- toExportScreen = { viewModel.navigateTo(Screen.Main.Export) },
- toAboutScreen = { viewModel.navigateTo(Screen.Overlay.About) },
- toLibrariesScreen = { viewModel.navigateTo(Screen.Overlay.Libraries) },
- toSettingsScreen = if (launchMode == LaunchMode.EXTERNAL_SCAN_TO_PDF) null else {
- {
- viewModel.navigateTo(Screen.Overlay.Settings)
- }
- },
- toOcrLanguagesScreen = { viewModel.navigateTo(Screen.Overlay.OcrLanguages) },
- back = {
- val origin = viewModel.currentScreen.value
- viewModel.navigateBack()
- val destination = viewModel.currentScreen.value
- if (destination == origin && launchMode == LaunchMode.EXTERNAL_SCAN_TO_PDF) {
- setResult(RESULT_CANCELED)
- finish()
- }
- },
- shouldDisplayBackButton = {
- viewModel.currentScreen.value !is Screen.Main.Camera
- || launchMode == LaunchMode.EXTERNAL_SCAN_TO_PDF
- }
- )
-
- override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
- if (keyCode == KeyEvent.KEYCODE_VOLUME_UP ||
- keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
- if (viewModel.currentScreen.value is Screen.Main.Camera) {
- cameraViewModel.onVolumeKeyPressed()
- return true
- }
- }
- return super.onKeyDown(keyCode, event)
- }
-}
+ is Screen.Main.Camera -> {
+ BackHandler { navigation.back() }
+ val pickMultiple = rememberLauncherForActivityResult(
+ ActivityResultContracts.GetMultipleContents()) {
+ uris -> cameraViewModel.importPhotos(uris)
+ }
+ CameraScreen(
+ viewModel,
+ cameraViewModel,
+ navigation,
+ liveAnalysisState,
+ importState,
+ onImageAnalyzed = { image -> cameraViewModel.liveAnalysis(image) },
+ onFinalizePressed = onExportClick,
+ cameraPermission = cameraPermission,
+ onImportClicked = {
+ cameraViewModel.onImportClicked()
+ pickMultiple.launch("image/*")
+ }
+ )
+ }
+ is Screen.Main.EditImage -> {
+ CropScreen(
+ pageId = documentUiState.currentPage?.key?.pageId ?: "",
+ initState = cropInitialState,
+ navigation = navigation,
+ onUpdatePageQuad = { quad -> viewModel.setCurrentPageUserQuad(quad) },
+ )
+ }
+ is Screen.Main.Document -> {
+ DocumentScreen (
+ uiState = documentUiState,
+ navigation = navigation,
+ onExportClick = onExportClick,
+ onDeleteImage = { viewModel.deleteCurrentPage() },
+ onRotateImage = { clockwise -> viewModel.rotateCurrentPage(clockwise) },
+ onColorModeSelected = { viewModel.setCurrentPageColorMode(it) },
+ onCropClick = { viewModel.onClickOnCropButton() },
+ onPageReorder = { id, newIndex -> viewModel.movePage(id, newIndex) },
+ onPageSelected = viewModel::onPageSelected
+ )
+ }
+ is Screen.Main.Export -> {
+ ExportScreenWrapper(
+ navigation = navigation,
+ uiState = exportUiState,
+ currentDocument = document,
+ exportActions = ExportActions(
+ prepareExportIfNeeded = exportViewModel::prepareExportIfNeeded,
+ setFilename = exportViewModel::setFilename,
+ share = { exportViewModel.onShareClicked() },
+ save = { exportViewModel.onSaveClicked() },
+ open = { item -> openUri(item.uri, item.format.mimeType, logger) },
+ cancelPreparationJob = exportViewModel::cancelPreparationJob,
+ ),
+ onCloseScan = exportViewModel::onFinishClicked
+ )
+ }
+ is Screen.Overlay.About -> {
+ LaunchedEffect(Unit) {
+ aboutViewModel.refreshLastCapturedImageState()
+ }
+ val aboutUiState by aboutViewModel.uiState.collectAsStateWithLifecycle()
+ AboutScreen(
+ aboutUiState = aboutUiState,
+ onBack = navigation.back,
+ onCopyLogs = { aboutViewModel.onCopyLogsClicked() },
+ onContactWithLastImageClicked =
+ { aboutViewModel.onContactWithLastImageClicked() },
+ onStartActivity = { intent ->
+ try {
+ startActivity(intent)
+ } catch (_: ActivityNotFoundException) {
+ showToast(getString(R.string.error_occurred))
+ }
+ },
+ onViewLibraries = navigation.toLibrariesScreen)
+ }
+ is Screen.Overlay.Libraries -> {
+ LibrariesScreen(onBack = navigation.back)
+ }
+ is Screen.Overlay.Settings -> {
+ SettingsScreenWrapper(settingsUiState, settingsViewModel, navigation, logger)
+ }
+ is Screen.Overlay.OcrLanguages -> {
+ OcrLanguagesScreen(
+ uiState = settingsUiState,
+ onBack = navigation.back,
+ onLanguageClick = settingsViewModel::onLanguageClick,
+ onRemoveLanguage = settingsViewModel::onRemoveLanguage,
+ onCancelOcrDownload = settingsViewModel::cancelOcrDownload,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ private fun resolveLaunchMode(intent: Intent?): LaunchMode {
+ return when (intent?.action) {
+ "org.fairscan.app.action.SCAN_TO_PDF" -> LaunchMode.EXTERNAL_SCAN_TO_PDF
+ "android.intent.action.GET_CONTENT" -> LaunchMode.EXTERNAL_SCAN_TO_PDF
+ else -> LaunchMode.NORMAL
+ }
+ }
+
+ private fun showToast(text: String) {
+ Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
+ }
+
+ @Composable
+ private fun SettingsScreenWrapper(
+ settingsUiState: SettingsUiState,
+ settingsViewModel: SettingsViewModel,
+ nav: Navigation,
+ logger: FileLogger,
+ ) {
+ val launcher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.OpenDocumentTree()
+ ) { uri ->
+ if (uri != null) {
+ val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
+ Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ try {
+ contentResolver.takePersistableUriPermission(uri, flags)
+ settingsViewModel.setExportDirUri(uri.toString())
+ } catch (e: Exception) {
+ logger.e("Settings", "Failed to set export dir to $uri", e)
+ showToast(this.getString(R.string.error_file_picker_result))
+ }
+ }
+ }
+ LaunchedEffect(Unit) {
+ settingsViewModel.refreshExportDirName()
+ }
+ SettingsScreen(
+ settingsUiState,
+ onDefaultColorModeChanged = { mode -> settingsViewModel.setDefaultColorMode(mode) },
+ onChooseDirectoryClick = {
+ try {
+ launcher.launch(null)
+ } catch (e: Exception) {
+ val message = getString(R.string.error_file_picker_launch)
+ logger.e("Settings", message, e)
+ showToast(message)
+ }
+ },
+ onResetExportDirClick = { settingsViewModel.setExportDirUri(null) },
+ onExportFormatChanged = { format -> settingsViewModel.setExportFormat(format) },
+ onExportQualityChanged = { quality -> settingsViewModel.setExportQuality(quality) },
+ onDefaultFileNameStyleChanged = settingsViewModel::setDefaultFilenameStyle,
+ navigation = nav,
+ )
+ }
+
+ @Composable
+ private fun CollectAboutEvents(
+ context: Context,
+ aboutViewModel: AboutViewModel,
+ imageRepository: ImageRepository,
+ ) {
+ val clipboard = LocalClipboard.current
+ val msgCopiedLogs = stringResource(R.string.copied_logs)
+ LaunchedEffect(Unit) {
+ aboutViewModel.events.collect { event ->
+ when (event) {
+ is AboutEvent.CopyLogs -> {
+ clipboard.setClipEntry(
+ ClipData.newPlainText("FairScan logs", event.logs).toClipEntry()
+ )
+ showToast(msgCopiedLogs)
+ }
+ is AboutEvent.PrepareEmailWithLastImage -> {
+ val file = imageRepository.lastAddedSourceFile()
+ if (file != null) {
+ startActivity(createEmailWithImageIntent(context, file))
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @Composable
+ private fun CollectExportEvents(
+ context: Context,
+ exportViewModel: ExportViewModel,
+ onFinished: () -> Unit,
+ ) {
+ val storagePermissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { isGranted ->
+ if (isGranted) {
+ exportViewModel.onRequestSave(context, exportViewModel.pendingRequest)
+ } else {
+ showToast(this.getString(R.string.storage_permission_denied))
+ }
+ }
+ LaunchedEffect(Unit) {
+ exportViewModel.events.collect { event ->
+ when (event) {
+ is ExportEvent.RequestSave -> {
+ checkPermissionThen(storagePermissionLauncher) {
+ exportViewModel.onRequestSave(context, event)
+ }
+ }
+ is ExportEvent.Share -> {
+ share(event.result)
+ }
+ ExportEvent.Finished -> onFinished()
+ }
+ }
+ }
+ }
+
+ @Composable
+ private fun CollectCameraEvents(
+ cameraViewModel: CameraViewModel,
+ viewModel: MainViewModel,
+ ) {
+ LaunchedEffect(Unit) {
+ cameraViewModel.events.collect { event ->
+ when (event) {
+ is CameraEvent.ImageCaptured -> viewModel.handleImageCaptured(event.page)
+ }
+ }
+ }
+ }
+
+ private fun share(result: SavedBundle) {
+ if (result.items.isEmpty()) return
+
+ val uris = result.items.map { it.uri }
+ val intent = Intent().apply {
+ action = if (uris.size == 1) Intent.ACTION_SEND else Intent.ACTION_SEND_MULTIPLE
+ type = result.items.map { it.format.mimeType }.distinct().singleOrNull() ?: "*/*"
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ clipData = ClipData.newUri(contentResolver, getString(R.string.share_document), uris.first()).apply {
+ uris.drop(1).forEach { addItem(ClipData.Item(it)) }
+ }
+
+ if (uris.size == 1) {
+ putExtra(Intent.EXTRA_STREAM, uris[0])
+ } else {
+ putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris))
+ }
+ }
+ val chooser = Intent.createChooser(intent, getString(R.string.share_document))
+
+ try {
+ startActivity(chooser)
+ } catch (_: ActivityNotFoundException) {
+ showToast(getString(R.string.error_no_app))
+ } catch (_: SecurityException) {
+ showToast(getString(R.string.error_occurred))
+ }
+ }
+
+ private fun sendActivityResult(result: ExportResult?) {
+ val pdf = result as? ExportResult.Pdf ?: return
+
+ val uri = uriForFile(pdf.file)
+ val resultIntent = Intent().apply {
+ data = uri
+ clipData = ClipData.newRawUri(null, uri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+
+ setResult(RESULT_OK, resultIntent)
+ }
+
+ private fun uriForFile(file: File): Uri {
+ return org.fairscan.app.ui.uriForFile(this, file)
+ }
+
+ private fun checkPermissionThen(
+ requestPermissionLauncher: ManagedActivityResultLauncher,
+ action: () -> Unit
+ ) {
+ val permission = Manifest.permission.WRITE_EXTERNAL_STORAGE
+ if (SDK_INT < Q && checkSelfPermission(this, permission) != PERMISSION_GRANTED
+ ) {
+ requestPermissionLauncher.launch(permission)
+ } else {
+ action()
+ }
+ }
+
+ private fun openUri(fileUri: Uri?, mimeType: String, logger: FileLogger) {
+ if (fileUri == null) return
+ val uriToOpen: Uri =
+ if (fileUri.scheme == ContentResolver.SCHEME_CONTENT) {
+ fileUri
+ } else {
+ uriForFile(fileUri.toFile())
+ }
+ try {
+ val openIntent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(uriToOpen, mimeType)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ clipData = ClipData.newUri(contentResolver, "file", uriToOpen)
+ }
+ val chooser = Intent.createChooser(openIntent, getString(R.string.open_file)).apply {
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ startActivity(chooser)
+ } catch (_: ActivityNotFoundException) {
+ showToast(getString(R.string.error_no_app))
+ } catch (e: Exception) {
+ val errorMessage =
+ "Failed to open URI, scheme=${uriToOpen.scheme}, authority=${uriToOpen.authority}"
+ logger.e("OpenUri", errorMessage, e)
+ showToast(getString(R.string.error_occurred))
+ }
+ }
+
+ private fun initLibraries() {
+ com.tom_roush.pdfbox.android.PDFBoxResourceLoader.init(applicationContext)
+
+ if (!OpenCVLoader.initLocal()) {
+ Log.e("OpenCV", "Initialization failed")
+ } else {
+ Log.d("OpenCV", "Initialization successful")
+ }
+ }
+
+ private fun navigation(viewModel: MainViewModel, launchMode: LaunchMode): Navigation = Navigation(
+ toCameraScreen = { viewModel.navigateTo(Screen.Main.Camera) },
+ toEditImageScreen = { viewModel.navigateTo(Screen.Main.EditImage) },
+ toDocumentScreen = { viewModel.navigateTo(Screen.Main.Document()) },
+ toExportScreen = { viewModel.navigateTo(Screen.Main.Export) },
+ toAboutScreen = { viewModel.navigateTo(Screen.Overlay.About) },
+ toLibrariesScreen = { viewModel.navigateTo(Screen.Overlay.Libraries) },
+ toSettingsScreen = if (launchMode == LaunchMode.EXTERNAL_SCAN_TO_PDF) null else {
+ {
+ viewModel.navigateTo(Screen.Overlay.Settings)
+ }
+ },
+ toOcrLanguagesScreen = { viewModel.navigateTo(Screen.Overlay.OcrLanguages) },
+ back = {
+ val origin = viewModel.currentScreen.value
+ if (origin == Screen.Main.Camera && launchMode == LaunchMode.EXTERNAL_SCAN_TO_PDF) {
+ setResult(RESULT_CANCELED)
+ finish()
+ } else {
+ viewModel.navigateBack()
+ }
+ },
+ shouldDisplayBackButton = {
+ viewModel.currentScreen.value !is Screen.Main.Home
+ }
+ )
+
+ override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
+ if (keyCode == KeyEvent.KEYCODE_VOLUME_UP ||
+ keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
+ if (viewModel.currentScreen.value is Screen.Main.Camera) {
+ cameraViewModel.onVolumeKeyPressed()
+ return true
+ }
+ }
+ return super.onKeyDown(keyCode, event)
+ }
+}
diff --git a/app/src/main/java/org/fairscan/app/MainViewModel.kt b/app/src/main/java/org/fairscan/app/MainViewModel.kt
index 962f84d8..d06cf4fe 100644
--- a/app/src/main/java/org/fairscan/app/MainViewModel.kt
+++ b/app/src/main/java/org/fairscan/app/MainViewModel.kt
@@ -54,7 +54,11 @@ import org.fairscan.imageprocessing.Quad
import kotlin.math.min
@OptIn(ExperimentalCoroutinesApi::class)
-class MainViewModel(val imageRepository: ImageRepository, logger: Logger): ViewModel() {
+class MainViewModel(
+ val imageRepository: ImageRepository,
+ logger: Logger,
+ private val launchMode: LaunchMode = LaunchMode.NORMAL,
+): ViewModel() {
private val _navigationState = MutableStateFlow(null)
val currentScreen: StateFlow = _navigationState.map { it?.current }
@@ -69,12 +73,9 @@ class MainViewModel(val imageRepository: ImageRepository, logger: Logger): ViewM
_pages.value = pages
- _navigationState.value =
- if (pages.isEmpty()) {
- NavigationState.initial()
- } else {
- NavigationState.initial().navigateTo(Screen.Main.ResumeScan)
- }
+ _navigationState.value = NavigationState.initial(
+ externalScan = launchMode == LaunchMode.EXTERNAL_SCAN_TO_PDF
+ )
}
}
@@ -142,6 +143,10 @@ class MainViewModel(val imageRepository: ImageRepository, logger: Logger): ViewM
_navigationState.update { stack -> stack?.navigateBack() }
}
+ fun openScanner() {
+ navigateTo(if (_pages.value.isEmpty()) Screen.Main.Camera else Screen.Main.ResumeScan)
+ }
+
fun rotateCurrentPage(clockwise: Boolean) {
viewModelScope.launch {
val pages = withContext(Dispatchers.IO) {
@@ -217,12 +222,14 @@ class MainViewModel(val imageRepository: ImageRepository, logger: Logger): ViewM
"No current page for index $index (${pages.size} pages)")
}
- fun startNewDocument() {
+ fun startNewDocument(onCleared: () -> Unit = {}) {
_pages.value = persistentListOf()
viewModelScope.launch {
withContext(Dispatchers.IO) {
imageRepository.clear()
}
+ _currentPageIndex.value = 0
+ onCleared()
}
}
From 4f6abb70076ae749f9e3bc3336b0dd96067f1b8f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Yakup=20Furkan=20Fidanc=C4=B1?=
<44572717+fidanci@users.noreply.github.com>
Date: Sun, 11 Oct 2026 03:44:39 +0300
Subject: [PATCH 03/11] Add the saved documents screen
---
app/src/main/java/org/fairscan/app/ui/Navigation.kt | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/app/src/main/java/org/fairscan/app/ui/Navigation.kt b/app/src/main/java/org/fairscan/app/ui/Navigation.kt
index b557e1e8..828bbc7a 100644
--- a/app/src/main/java/org/fairscan/app/ui/Navigation.kt
+++ b/app/src/main/java/org/fairscan/app/ui/Navigation.kt
@@ -16,9 +16,11 @@ package org.fairscan.app.ui
sealed class Screen {
sealed class Main : Screen() {
+ object Home : Main()
object Camera : Main()
object EditImage : Main()
data class Document(val initialPage: Int = 0) : Main()
+ data class LibraryDocument(val uri: String, val name: String, val mimeType: String) : Main()
object Export : Main()
object ResumeScan : Main()
}
@@ -47,8 +49,8 @@ data class Navigation(
data class NavigationState private constructor(val stack: List, val root: Screen.Main) {
companion object {
- fun initial(): NavigationState {
- val root = Screen.Main.Camera
+ fun initial(externalScan: Boolean = false): NavigationState {
+ val root = if (externalScan) Screen.Main.Camera else Screen.Main.Home
return NavigationState(listOf(root), root)
}
}
@@ -66,9 +68,11 @@ data class NavigationState private constructor(val stack: List, val root
fun navigateBack(): NavigationState {
return when (current) {
root -> this // Back handled by system
- is Screen.Main.ResumeScan -> this // Back handled by system
- is Screen.Main.Camera -> this // Back handled by system
+ is Screen.Main.Home -> this
+ is Screen.Main.ResumeScan -> copy(stack = listOf(root))
+ is Screen.Main.Camera -> copy(stack = listOf(root))
is Screen.Main.Document -> copy(stack = listOf(Screen.Main.Camera))
+ is Screen.Main.LibraryDocument -> copy(stack = listOf(root))
is Screen.Main.EditImage -> copy(stack = listOf(Screen.Main.Document()))
is Screen.Main.Export -> copy(stack = listOf(Screen.Main.Camera))
is Screen.Overlay -> copy(stack = stack.dropLast(1))
From 677c46e41bf9d80d958673bebf192d1f655c0107 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Yakup=20Furkan=20Fidanc=C4=B1?=
<44572717+fidanci@users.noreply.github.com>
Date: Sun, 11 Oct 2026 03:44:48 +0300
Subject: [PATCH 04/11] Save and share scans in the library
---
.../app/ui/screens/export/ExportScreen.kt | 1492 ++++++++---------
.../app/ui/screens/export/ExportViewModel.kt | 902 +++++-----
2 files changed, 1166 insertions(+), 1228 deletions(-)
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 86d1dfbf..75344298 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
@@ -1,751 +1,741 @@
-/*
- * Copyright 2025-2026 The FairScan authors
- *
- * This program is free software: you can redistribute it and/or modify it
- * under the terms of the GNU General Public License as published by the Free
- * Software Foundation, either version 3 of the License, or (at your option)
- * any later version.
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
- * more details.
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
-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.Image
-import androidx.compose.foundation.border
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ColumnScope
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxHeight
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.heightIn
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.layout.widthIn
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.OpenInNew
-import androidx.compose.material.icons.filled.CheckCircle
-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
-import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.LinearProgressIndicator
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.saveable.rememberSaveable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
-import androidx.compose.ui.focus.FocusRequester
-import androidx.compose.ui.focus.focusRequester
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.asImageBitmap
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.platform.LocalConfiguration
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalResources
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.font.FontStyle
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import androidx.core.net.toUri
-import kotlinx.collections.immutable.persistentListOf
-import org.fairscan.app.R
-import org.fairscan.app.THUMBNAIL_SIZE_DP
-import org.fairscan.app.ui.Navigation
-import org.fairscan.app.ui.components.AppOverflowMenu
-import org.fairscan.app.ui.components.BackButton
-import org.fairscan.app.ui.components.MainActionButton
-import org.fairscan.app.ui.components.NewDocumentDialog
-import org.fairscan.app.ui.components.isLandscape
-import org.fairscan.app.ui.components.pageCountText
-import org.fairscan.app.ui.dummyNavigation
-import org.fairscan.app.ui.fakeDocument
-import org.fairscan.app.ui.screens.settings.ExportFormat.PDF
-import org.fairscan.app.ui.state.DocumentUiModel
-import org.fairscan.app.ui.theme.FairScanTheme
-import java.io.File
-import java.io.IOException
-
-@Composable
-fun ExportScreenWrapper(
- navigation: Navigation,
- uiState: ExportUiState,
- currentDocument: DocumentUiModel,
- exportActions: ExportActions,
- onCloseScan: () -> Unit,
-) {
- BackHandler { navigation.back() }
-
- val showConfirmationDialog = rememberSaveable { mutableStateOf(false) }
-
- LaunchedEffect(Unit) {
- exportActions.prepareExportIfNeeded()
- }
- DisposableEffect(Unit) {
- onDispose {
- exportActions.cancelPreparationJob()
- }
- }
-
- val onFilenameChange = { newName:String ->
- exportActions.setFilename(newName)
- }
-
- ExportScreen(
- onFilenameChange = onFilenameChange,
- uiState = uiState,
- currentDocument = currentDocument,
- navigation = navigation,
- onShare = {
- if (!uiState.isSaving) {
- exportActions.share()
- }
- },
- onSave = {
- if (!uiState.isSaving) {
- exportActions.save()
- }
- },
- onOpen = exportActions.open,
- onCloseScan = {
- if (!uiState.isSaving) {
- if (uiState.hasSavedOrShared)
- onCloseScan()
- else
- showConfirmationDialog.value = true
- }
- },
- )
-
- if (showConfirmationDialog.value) {
- NewDocumentDialog(onCloseScan, showConfirmationDialog, stringResource(R.string.scan_new))
- }
-}
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun ExportScreen(
- onFilenameChange: (String) -> Unit,
- uiState: ExportUiState,
- currentDocument: DocumentUiModel,
- navigation: Navigation,
- onShare: () -> Unit,
- onSave: () -> Unit,
- onOpen: (SavedItem) -> Unit,
- onCloseScan: () -> Unit,
-) {
- Scaffold(
- topBar = {
- TopAppBar(
- title = { Text(stringResource(R.string.export_as, uiState.format)) },
- navigationIcon = { BackButton(navigation.back) },
- actions = {
- AppOverflowMenu(navigation)
- }
- )
- }
- ) { innerPadding ->
- val containerModifier = Modifier
- .padding(innerPadding)
- .padding(horizontal = 16.dp)
- val onThumbnailClick = navigation.toDocumentScreen
- if (!isLandscape(LocalConfiguration.current)) {
- Column(
- modifier = containerModifier.fillMaxSize().padding(vertical = 16.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- PdfInfosAndResultBar(uiState, currentDocument, onOpen, onThumbnailClick)
- Spacer(Modifier.weight(1f)) // push buttons down
- MainActions(onFilenameChange, uiState, onShare, onSave, onCloseScan)
- }
- } else {
- Row(
- modifier = containerModifier.fillMaxHeight(),
- horizontalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- Column(
- modifier = Modifier.weight(1f)
- .verticalScroll(rememberScrollState()),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- PdfInfosAndResultBar(uiState, currentDocument, onOpen, onThumbnailClick)
- }
- Column(modifier = Modifier.weight(1f)) {
- MainActions(onFilenameChange, uiState, onShare, onSave, onCloseScan)
- }
- }
-
- }
- }
-}
-
-@Composable
-private fun PdfInfosAndResultBar(
- uiState: ExportUiState,
- currentDocument: DocumentUiModel,
- onOpen: (SavedItem) -> Unit,
- onThumbnailClick: () -> Unit,
-) {
- Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
-
- PdfInfoCard {
- PdfInfos(uiState, currentDocument, onThumbnailClick)
- SaveStatusBar(uiState, onOpen)
- }
-
- uiState.error?.let {
- ErrorBar(it)
- }
- }
-
-}
-
-@Composable
-private fun PdfInfoCard(
- content: @Composable ColumnScope.() -> Unit
-) {
- Surface(
- shape = RoundedCornerShape(12.dp),
- color = MaterialTheme.colorScheme.surface,
- tonalElevation = 1.dp,
- modifier = Modifier.fillMaxWidth()
- ) {
- Column(
- verticalArrangement = Arrangement.spacedBy(8.dp),
- modifier = Modifier.padding(12.dp)
- ) {
- content()
- }
- }
-}
-
-@Composable
-private fun PdfInfos(
- uiState: ExportUiState,
- currentDocument: DocumentUiModel,
- onThumbnailClick: () -> Unit,
-) {
- val result = uiState.result
-
- Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
- val thumbnail = currentDocument.thumbnail(0)
- thumbnail?.let {
- Card(
- elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
- shape = RoundedCornerShape(6.dp),
- modifier = Modifier
- .padding(4.dp)
- .heightIn(max = THUMBNAIL_SIZE_DP.dp)
- .widthIn(max = THUMBNAIL_SIZE_DP.dp)
- ) {
- Image(
- bitmap = thumbnail.asImageBitmap(),
- contentDescription = null,
- modifier = Modifier.clickable { onThumbnailClick() }
- )
- }
- }
- // PDF infos
- Column(
- verticalArrangement = Arrangement.spacedBy(4.dp)
- ) {
- val pageCount = result?.pageCount ?: uiState.progress?.totalPages
- pageCount?.let { count -> Text(text = pageCountText(count)) }
-
- uiState.ocrActivation?.let { activated ->
- Text(
- if (activated)
- stringResource(R.string.text_recognition_enabled)
- else
- stringResource(R.string.text_recognition_disabled),
- style = MaterialTheme.typography.bodySmall,
- )
- }
-
- if (uiState.blackAndWhiteAsJpeg) {
- Text(
- stringResource(R.string.black_and_white_jpeg_hint),
- style = MaterialTheme.typography.bodySmall,
- )
- }
-
- if (uiState.isGenerating) {
- Text(
- text = stringResource(R.string.creating_export),
- fontStyle = FontStyle.Italic,
- modifier = Modifier.padding(top = 8.dp),
- )
- uiState.progress?.let{ p ->
- if (p.totalPages == 1) {
- LinearProgressIndicator()
- } else {
- LinearProgressIndicator({ p.progress })
- }
- }
- } else if (result != null) {
- val context = LocalContext.current
- val formattedFileSize = formatFileSize(result.sizeInBytes, context)
- val sizeMessageKey =
- if (result.files.size == 1) R.string.file_size else R.string.file_size_total
- Text(
- text = stringResource(sizeMessageKey, formattedFileSize),
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
- )
- }
- }
- }
-}
-
-@Composable
-private fun SaveStatusBar(
- uiState: ExportUiState,
- onOpen: (SavedItem) -> Unit,
-) {
- when {
- uiState.isSaving -> {
- Row(verticalAlignment = Alignment.CenterVertically) {
- CircularProgressIndicator(
- modifier = Modifier.size(16.dp),
- strokeWidth = 2.dp
- )
- }
- }
- uiState.savedBundle != null -> {
- SaveInfoBar(uiState.savedBundle, onOpen)
- }
- }
-}
-
-@Composable
-private fun FilenameTextField(
- filename: String,
- onFilenameChange: (String) -> Unit,
-) {
- val focusRequester = remember { FocusRequester() }
- OutlinedTextField(
- value = filename,
- onValueChange = onFilenameChange,
- label = { Text(stringResource(R.string.filename)) },
- singleLine = true,
- modifier = Modifier
- .fillMaxWidth()
- .focusRequester(focusRequester),
- trailingIcon = {
- if (filename.isNotEmpty()) {
- IconButton(onClick = {
- onFilenameChange("")
- focusRequester.requestFocus()
- }) {
- Icon(Icons.Default.Clear, stringResource(R.string.clear_text))
- }
- }
- },
- )
-}
-
-@Composable
-private fun MainActions(
- onFilenameChange: (String) -> Unit,
- uiState: ExportUiState,
- onShare: () -> Unit,
- onSave: () -> Unit,
- onCloseScan: () -> Unit,
-) {
- Column(
- verticalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- ActionSurface {
- FilenameTextField(uiState.filename, onFilenameChange)
-
- Row(
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- modifier = Modifier.fillMaxWidth()
- ) {
- ExportButton(
- onClick = onShare,
- enabled = uiState.result != null,
- isPrimary = !uiState.hasSavedOrShared,
- icon = Icons.Default.Share,
- text = stringResource(R.string.share),
- modifier = Modifier.weight(1f)
- )
- ExportButton(
- onClick = onSave,
- enabled = uiState.result != null,
- isPrimary = !uiState.hasSavedOrShared,
- icon = Icons.Default.Download,
- text = stringResource(R.string.save),
- modifier = Modifier.weight(1f)
- .alpha(if (uiState.isSaving) 0.6f else 1f)
- )
- }
- }
- ExportButton(
- icon = Icons.Default.Done,
- text = stringResource(R.string.scan_new),
- onClick = onCloseScan,
- modifier = Modifier.fillMaxWidth(),
- isPrimary = uiState.hasSavedOrShared,
- )
- }
-}
-
-@Composable
-private fun ActionSurface(
- modifier: Modifier = Modifier,
- content: @Composable ColumnScope.() -> Unit
-) {
- Surface(
- shape = RoundedCornerShape(12.dp),
- color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
- tonalElevation = 1.dp,
- modifier = modifier.fillMaxWidth()
- ) {
- Column(
- verticalArrangement = Arrangement.spacedBy(12.dp),
- modifier = Modifier.padding(12.dp)
- ) {
- content()
- }
- }
-}
-
-
-@Composable
-fun ExportButton(
- icon: ImageVector,
- text: String,
- isPrimary: Boolean,
- onClick: () -> Unit,
- modifier: Modifier = Modifier,
- enabled: Boolean = true,
-) {
- val containerColor by animateColorAsState(
- targetValue = if (isPrimary) MaterialTheme.colorScheme.primary
- else Color.Transparent
- )
- val contentColor by animateColorAsState(
- targetValue = if (isPrimary) MaterialTheme.colorScheme.onPrimary
- else MaterialTheme.colorScheme.primary
- )
- val borderColor by animateColorAsState(
- targetValue = if (isPrimary) Color.Transparent
- else MaterialTheme.colorScheme.primary
- )
-
- Button(
- onClick = onClick,
- colors = ButtonDefaults.buttonColors(
- containerColor = containerColor,
- contentColor = contentColor
- ),
- border = BorderStroke(1.dp, borderColor),
- enabled = enabled,
- modifier = modifier
- ) {
- Icon(icon, contentDescription = null)
- Spacer(Modifier.width(4.dp))
- Text(text)
- }
-}
-
-@Composable
-private fun SaveInfoBar(
- savedBundle: SavedBundle,
- onOpen: (SavedItem) -> Unit,
-) {
- val dirName = savedBundle.saveDir?.name ?: stringResource(R.string.download_dirname)
- val items = savedBundle.items
- val nbFiles = items.size
- val firstFileName = items[0].fileName
-
- Surface(
- shape = RoundedCornerShape(10.dp),
- color = MaterialTheme.colorScheme.surfaceVariant,
- tonalElevation = 0.dp,
- modifier = Modifier.fillMaxWidth()
- ) {
- Column(
- verticalArrangement = Arrangement.spacedBy(8.dp),
- modifier = Modifier.padding(12.dp)
- ) {
-
- Row(verticalAlignment = Alignment.CenterVertically) {
- Icon(
- imageVector = Icons.Default.CheckCircle,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.primary,
- )
- Spacer(Modifier.width(8.dp))
- Text(
- text = LocalResources.current.getQuantityString(
- R.plurals.files_saved_to,
- nbFiles,
- nbFiles, firstFileName, dirName
- ),
- style = MaterialTheme.typography.bodyMedium,
- modifier = Modifier.weight(1f)
- )
- }
-
- if (nbFiles == 1) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.End
- ) {
- MainActionButton(
- onClick = { onOpen(items[0]) },
- text = stringResource(R.string.open),
- icon = Icons.AutoMirrored.Filled.OpenInNew,
- )
- }
- }
- }
- }
-}
-
-@Composable
-private fun ErrorBar(error: ExportError) {
- val (summary, details) = error.toDisplayText()
- val context = LocalContext.current
-
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .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.OnPrepareOrShare -> {
- 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 formatFileSize(sizeInBytes: Long?, context: Context): String {
- return if (sizeInBytes == null) context.getString(R.string.unknown_size)
- else Formatter.formatShortFileSize(context, sizeInBytes)
-}
-
-@Preview
-@Composable
-fun PreviewExportScreenDuringGeneration() {
- ExportPreviewToCustomize(
- uiState = ExportUiState(
- isGenerating = true,
- progress = ExportProgress(1, 3),
- ocrActivation = true,
- )
- )
-}
-
-@Preview
-@Composable
-fun PreviewExportScreenAfterGeneration() {
- val file = File("fake.pdf")
- ExportPreviewToCustomize(
- uiState = ExportUiState(
- result = ExportResult.Pdf(file, 442897L, 3),
- ocrActivation = false,
- ),
- )
-}
-
-@Preview
-@Composable
-fun PreviewExportScreenAfterSave() {
- val file = File("fake.pdf")
- ExportPreviewToCustomize(
- uiState = ExportUiState(
- result = ExportResult.Pdf(file, 442897L, 3),
- savedBundle = SavedBundle(
- listOf(SavedItem(file.toUri(), "12345.pdf", PDF))
- ),
- ),
- )
-}
-
-@Preview
-@Composable
-fun ExportScreenPreviewWithError() {
- ExportPreviewToCustomize(
- ExportUiState(error =
- ExportError.OnPrepareOrShare("PDF generation failed", IOException("Boom")))
- )
-}
-
-@Preview(showBackground = true, widthDp = 720, heightDp = 360)
-@Composable
-fun PreviewExportScreenAfterSaveHorizontal() {
- val file = File("fake.pdf")
- ExportPreviewToCustomize(
- uiState = ExportUiState(
- result = ExportResult.Pdf(file, 442897L, 3),
- savedBundle = SavedBundle(
- listOf(SavedItem(file.toUri(), "my_file.pdf", PDF)),
- SaveDir("fil:///root/dir".toUri() ,"MyVeryVeryLongDirectoryName")),
- ),
- )
-}
-
-@Composable
-fun ExportPreviewToCustomize(uiState: ExportUiState) {
- FairScanTheme {
- ExportScreen(
- onFilenameChange = {_->},
- uiState = uiState,
- currentDocument = fakeDocument(
- persistentListOf("gallica.bnf.fr-bpt6k5530456s-1"),
- LocalContext.current
- ),
- navigation = dummyNavigation(),
- onShare = {},
- onSave = {},
- onOpen = {},
- onCloseScan = {},
- )
- }
-}
+/*
+ * Copyright 2025-2026 The FairScan authors
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation, either version 3 of the License, or (at your option)
+ * any later version.
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+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.Image
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.OpenInNew
+import androidx.compose.material.icons.filled.CheckCircle
+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
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalResources
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.core.net.toUri
+import kotlinx.collections.immutable.persistentListOf
+import org.fairscan.app.R
+import org.fairscan.app.THUMBNAIL_SIZE_DP
+import org.fairscan.app.ui.Navigation
+import org.fairscan.app.ui.components.AppOverflowMenu
+import org.fairscan.app.ui.components.BackButton
+import org.fairscan.app.ui.components.MainActionButton
+import org.fairscan.app.ui.components.isLandscape
+import org.fairscan.app.ui.components.pageCountText
+import org.fairscan.app.ui.dummyNavigation
+import org.fairscan.app.ui.fakeDocument
+import org.fairscan.app.ui.screens.settings.ExportFormat.PDF
+import org.fairscan.app.ui.state.DocumentUiModel
+import org.fairscan.app.ui.theme.FairScanTheme
+import java.io.File
+import java.io.IOException
+
+@Composable
+fun ExportScreenWrapper(
+ navigation: Navigation,
+ uiState: ExportUiState,
+ currentDocument: DocumentUiModel,
+ exportActions: ExportActions,
+ onCloseScan: () -> Unit,
+) {
+ BackHandler { if (!uiState.isSaving) navigation.back() }
+
+ LaunchedEffect(Unit) {
+ exportActions.prepareExportIfNeeded()
+ }
+ DisposableEffect(Unit) {
+ onDispose {
+ exportActions.cancelPreparationJob()
+ }
+ }
+
+ val onFilenameChange = { newName:String ->
+ exportActions.setFilename(newName)
+ }
+
+ ExportScreen(
+ onFilenameChange = onFilenameChange,
+ uiState = uiState,
+ currentDocument = currentDocument,
+ navigation = navigation,
+ onShare = {
+ if (!uiState.isSaving) {
+ exportActions.share()
+ }
+ },
+ onSave = {
+ if (!uiState.isSaving) {
+ exportActions.save()
+ }
+ },
+ onOpen = exportActions.open,
+ onCloseScan = {
+ if (!uiState.isSaving) {
+ onCloseScan()
+ }
+ },
+ )
+
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ExportScreen(
+ onFilenameChange: (String) -> Unit,
+ uiState: ExportUiState,
+ currentDocument: DocumentUiModel,
+ navigation: Navigation,
+ onShare: () -> Unit,
+ onSave: () -> Unit,
+ onOpen: (SavedItem) -> Unit,
+ onCloseScan: () -> Unit,
+) {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.export_as, uiState.format)) },
+ navigationIcon = { BackButton(onClick = { if (!uiState.isSaving) navigation.back() }) },
+ actions = {
+ if (!uiState.isSaving) AppOverflowMenu(navigation)
+ }
+ )
+ }
+ ) { innerPadding ->
+ val containerModifier = Modifier
+ .padding(innerPadding)
+ .padding(horizontal = 16.dp)
+ val onThumbnailClick = navigation.toDocumentScreen
+ if (!isLandscape(LocalConfiguration.current)) {
+ Column(
+ modifier = containerModifier.fillMaxSize().padding(vertical = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ PdfInfosAndResultBar(uiState, currentDocument, onOpen, onThumbnailClick)
+ Spacer(Modifier.weight(1f)) // push buttons down
+ MainActions(onFilenameChange, uiState, onShare, onSave, onCloseScan)
+ }
+ } else {
+ Row(
+ modifier = containerModifier.fillMaxHeight(),
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Column(
+ modifier = Modifier.weight(1f)
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ PdfInfosAndResultBar(uiState, currentDocument, onOpen, onThumbnailClick)
+ }
+ Column(modifier = Modifier.weight(1f)) {
+ MainActions(onFilenameChange, uiState, onShare, onSave, onCloseScan)
+ }
+ }
+
+ }
+ }
+}
+
+@Composable
+private fun PdfInfosAndResultBar(
+ uiState: ExportUiState,
+ currentDocument: DocumentUiModel,
+ onOpen: (SavedItem) -> Unit,
+ onThumbnailClick: () -> Unit,
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+
+ PdfInfoCard {
+ PdfInfos(uiState, currentDocument, onThumbnailClick)
+ SaveStatusBar(uiState, onOpen)
+ }
+
+ uiState.error?.let {
+ ErrorBar(it)
+ }
+ }
+
+}
+
+@Composable
+private fun PdfInfoCard(
+ content: @Composable ColumnScope.() -> Unit
+) {
+ Surface(
+ shape = RoundedCornerShape(12.dp),
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 1.dp,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.padding(12.dp)
+ ) {
+ content()
+ }
+ }
+}
+
+@Composable
+private fun PdfInfos(
+ uiState: ExportUiState,
+ currentDocument: DocumentUiModel,
+ onThumbnailClick: () -> Unit,
+) {
+ val result = uiState.result
+
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ val thumbnail = currentDocument.thumbnail(0)
+ thumbnail?.let {
+ Card(
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
+ shape = RoundedCornerShape(6.dp),
+ modifier = Modifier
+ .padding(4.dp)
+ .heightIn(max = THUMBNAIL_SIZE_DP.dp)
+ .widthIn(max = THUMBNAIL_SIZE_DP.dp)
+ ) {
+ Image(
+ bitmap = thumbnail.asImageBitmap(),
+ contentDescription = null,
+ modifier = Modifier.clickable { onThumbnailClick() }
+ )
+ }
+ }
+ // PDF infos
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ val pageCount = result?.pageCount ?: uiState.progress?.totalPages
+ pageCount?.let { count -> Text(text = pageCountText(count)) }
+
+ uiState.ocrActivation?.let { activated ->
+ Text(
+ if (activated)
+ stringResource(R.string.text_recognition_enabled)
+ else
+ stringResource(R.string.text_recognition_disabled),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+
+ if (uiState.blackAndWhiteAsJpeg) {
+ Text(
+ stringResource(R.string.black_and_white_jpeg_hint),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+
+ if (uiState.isGenerating) {
+ Text(
+ text = stringResource(R.string.creating_export),
+ fontStyle = FontStyle.Italic,
+ modifier = Modifier.padding(top = 8.dp),
+ )
+ uiState.progress?.let{ p ->
+ if (p.totalPages == 1) {
+ LinearProgressIndicator()
+ } else {
+ LinearProgressIndicator({ p.progress })
+ }
+ }
+ } else if (result != null) {
+ val context = LocalContext.current
+ val formattedFileSize = formatFileSize(result.sizeInBytes, context)
+ val sizeMessageKey =
+ if (result.files.size == 1) R.string.file_size else R.string.file_size_total
+ Text(
+ text = stringResource(sizeMessageKey, formattedFileSize),
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun SaveStatusBar(
+ uiState: ExportUiState,
+ onOpen: (SavedItem) -> Unit,
+) {
+ when {
+ uiState.isSaving -> {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp),
+ strokeWidth = 2.dp
+ )
+ }
+ }
+ uiState.savedBundle != null -> {
+ SaveInfoBar(uiState.savedBundle, onOpen)
+ }
+ }
+}
+
+@Composable
+private fun FilenameTextField(
+ filename: String,
+ onFilenameChange: (String) -> Unit,
+) {
+ val focusRequester = remember { FocusRequester() }
+ OutlinedTextField(
+ value = filename,
+ onValueChange = onFilenameChange,
+ label = { Text(stringResource(R.string.filename)) },
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester),
+ trailingIcon = {
+ if (filename.isNotEmpty()) {
+ IconButton(onClick = {
+ onFilenameChange("")
+ focusRequester.requestFocus()
+ }) {
+ Icon(Icons.Default.Clear, stringResource(R.string.clear_text))
+ }
+ }
+ },
+ )
+}
+
+@Composable
+private fun MainActions(
+ onFilenameChange: (String) -> Unit,
+ uiState: ExportUiState,
+ onShare: () -> Unit,
+ onSave: () -> Unit,
+ onCloseScan: () -> Unit,
+) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ ActionSurface {
+ FilenameTextField(uiState.filename, onFilenameChange)
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ ExportButton(
+ onClick = onShare,
+ enabled = uiState.result != null && !uiState.isGenerating && !uiState.isSaving,
+ isPrimary = !uiState.hasSavedOrShared,
+ icon = Icons.Default.Share,
+ text = stringResource(R.string.share),
+ modifier = Modifier.weight(1f)
+ )
+ ExportButton(
+ onClick = onSave,
+ enabled = uiState.result != null && !uiState.isGenerating && !uiState.isSaving,
+ isPrimary = !uiState.hasSavedOrShared,
+ icon = Icons.Default.Download,
+ text = stringResource(R.string.save),
+ modifier = Modifier.weight(1f)
+ .alpha(if (uiState.isSaving) 0.6f else 1f)
+ )
+ }
+ }
+ ExportButton(
+ icon = Icons.Default.Done,
+ text = stringResource(R.string.library_finish),
+ enabled = uiState.result != null && !uiState.isGenerating && !uiState.isSaving,
+ onClick = onCloseScan,
+ modifier = Modifier.fillMaxWidth(),
+ isPrimary = uiState.hasSavedOrShared,
+ )
+ }
+}
+
+@Composable
+private fun ActionSurface(
+ modifier: Modifier = Modifier,
+ content: @Composable ColumnScope.() -> Unit
+) {
+ Surface(
+ shape = RoundedCornerShape(12.dp),
+ color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
+ tonalElevation = 1.dp,
+ modifier = modifier.fillMaxWidth()
+ ) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.padding(12.dp)
+ ) {
+ content()
+ }
+ }
+}
+
+
+@Composable
+fun ExportButton(
+ icon: ImageVector,
+ text: String,
+ isPrimary: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+) {
+ val containerColor by animateColorAsState(
+ targetValue = if (isPrimary) MaterialTheme.colorScheme.primary
+ else Color.Transparent
+ )
+ val contentColor by animateColorAsState(
+ targetValue = if (isPrimary) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.primary
+ )
+ val borderColor by animateColorAsState(
+ targetValue = if (isPrimary) Color.Transparent
+ else MaterialTheme.colorScheme.primary
+ )
+
+ Button(
+ onClick = onClick,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = containerColor,
+ contentColor = contentColor
+ ),
+ border = BorderStroke(1.dp, borderColor),
+ enabled = enabled,
+ modifier = modifier
+ ) {
+ Icon(icon, contentDescription = null)
+ Spacer(Modifier.width(4.dp))
+ Text(text)
+ }
+}
+
+@Composable
+private fun SaveInfoBar(
+ savedBundle: SavedBundle,
+ onOpen: (SavedItem) -> Unit,
+) {
+ val dirName = savedBundle.saveDir?.name ?: stringResource(R.string.library_folder_name)
+ val items = savedBundle.items
+ val nbFiles = items.size
+ val firstFileName = items[0].fileName
+
+ Surface(
+ shape = RoundedCornerShape(10.dp),
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ tonalElevation = 0.dp,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.padding(12.dp)
+ ) {
+
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = Icons.Default.CheckCircle,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(
+ text = LocalResources.current.getQuantityString(
+ R.plurals.files_saved_to,
+ nbFiles,
+ nbFiles, firstFileName, dirName
+ ),
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.weight(1f)
+ )
+ }
+
+ if (nbFiles == 1) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End
+ ) {
+ MainActionButton(
+ onClick = { onOpen(items[0]) },
+ text = stringResource(R.string.open),
+ icon = Icons.AutoMirrored.Filled.OpenInNew,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ErrorBar(error: ExportError) {
+ val (summary, details) = error.toDisplayText()
+ val context = LocalContext.current
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .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.OnPrepareOrShare -> {
+ 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.library_folder_name)
+
+ 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 formatFileSize(sizeInBytes: Long?, context: Context): String {
+ return if (sizeInBytes == null) context.getString(R.string.unknown_size)
+ else Formatter.formatShortFileSize(context, sizeInBytes)
+}
+
+@Preview
+@Composable
+fun PreviewExportScreenDuringGeneration() {
+ ExportPreviewToCustomize(
+ uiState = ExportUiState(
+ isGenerating = true,
+ progress = ExportProgress(1, 3),
+ ocrActivation = true,
+ )
+ )
+}
+
+@Preview
+@Composable
+fun PreviewExportScreenAfterGeneration() {
+ val file = File("fake.pdf")
+ ExportPreviewToCustomize(
+ uiState = ExportUiState(
+ result = ExportResult.Pdf(file, 442897L, 3),
+ ocrActivation = false,
+ ),
+ )
+}
+
+@Preview
+@Composable
+fun PreviewExportScreenAfterSave() {
+ val file = File("fake.pdf")
+ ExportPreviewToCustomize(
+ uiState = ExportUiState(
+ result = ExportResult.Pdf(file, 442897L, 3),
+ savedBundle = SavedBundle(
+ listOf(SavedItem(file.toUri(), "12345.pdf", PDF))
+ ),
+ ),
+ )
+}
+
+@Preview
+@Composable
+fun ExportScreenPreviewWithError() {
+ ExportPreviewToCustomize(
+ ExportUiState(error =
+ ExportError.OnPrepareOrShare("PDF generation failed", IOException("Boom")))
+ )
+}
+
+@Preview(showBackground = true, widthDp = 720, heightDp = 360)
+@Composable
+fun PreviewExportScreenAfterSaveHorizontal() {
+ val file = File("fake.pdf")
+ ExportPreviewToCustomize(
+ uiState = ExportUiState(
+ result = ExportResult.Pdf(file, 442897L, 3),
+ savedBundle = SavedBundle(
+ listOf(SavedItem(file.toUri(), "my_file.pdf", PDF)),
+ SaveDir("fil:///root/dir".toUri() ,"MyVeryVeryLongDirectoryName")),
+ ),
+ )
+}
+
+@Composable
+fun ExportPreviewToCustomize(uiState: ExportUiState) {
+ FairScanTheme {
+ ExportScreen(
+ onFilenameChange = {_->},
+ uiState = uiState,
+ currentDocument = fakeDocument(
+ persistentListOf("gallica.bnf.fr-bpt6k5530456s-1"),
+ LocalContext.current
+ ),
+ navigation = dummyNavigation(),
+ onShare = {},
+ onSave = {},
+ onOpen = {},
+ onCloseScan = {},
+ )
+ }
+}
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 174d3d5e..362b3fba 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
@@ -1,477 +1,425 @@
-/*
- * Copyright 2025-2026 The FairScan authors
- *
- * This program is free software: you can redistribute it and/or modify it
- * under the terms of the GNU General Public License as published by the Free
- * Software Foundation, either version 3 of the License, or (at your option)
- * any later version.
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
- * more details.
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
-package org.fairscan.app.ui.screens.export
-
-import android.content.ContentValues
-import android.content.Context
-import android.media.MediaScannerConnection
-import android.net.Uri
-import android.os.Build
-import android.os.Environment
-import android.provider.MediaStore
-import android.util.Log
-import androidx.annotation.RequiresApi
-import androidx.core.net.toUri
-import androidx.documentfile.provider.DocumentFile
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import kotlinx.collections.immutable.ImmutableList
-import kotlinx.collections.immutable.toImmutableList
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.ensureActive
-import kotlinx.coroutines.flow.MutableSharedFlow
-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.withContext
-import org.fairscan.app.AppContainer
-import org.fairscan.app.R
-import org.fairscan.app.data.FileManager
-import org.fairscan.app.data.ImageRepository
-import org.fairscan.app.domain.ExportQuality
-import org.fairscan.app.domain.PageViewKey
-import org.fairscan.app.domain.jpegsToExport
-import org.fairscan.app.domain.pagesToExport
-import org.fairscan.app.ui.screens.settings.ExportFormat
-import org.fairscan.app.ui.screens.settings.ExportFormat.PDF
-import org.fairscan.imageprocessing.ColorMode
-import java.io.File
-import java.io.FileInputStream
-import java.io.IOException
-import java.util.concurrent.CancellationException
-import kotlin.coroutines.resume
-import kotlin.coroutines.suspendCoroutine
-
-sealed interface ExportEvent {
- data object RequestSave : ExportEvent
- data class Share(val result: ExportResult) : ExportEvent
-}
-
-class ExportViewModel(container: AppContainer, val imageRepository: ImageRepository): ViewModel() {
-
- private val preparationDir = container.preparationDir
- private val fileManager = container.fileManager
- private val settingsRepository = container.settingsRepository
- private val ocrService = container.ocrService
- private val logger = container.logger
-
- private val _events = MutableSharedFlow()
- val events = _events.asSharedFlow()
-
- private suspend fun generatePdf(
- exportQuality: ExportQuality,
- disableOcr: Boolean,
- onPageCompleted: (Int) -> Unit,
- ): ExportResult.Pdf = withContext(Dispatchers.IO) {
- val pageToExports = pagesToExport(imageRepository, exportQuality)
- val pdf = fileManager.generatePdf(pageToExports, disableOcr, onPageCompleted)
- return@withContext ExportResult.Pdf(pdf.file, pdf.sizeInBytes, pdf.pageCount)
- }
-
- suspend fun generatePdfForExternalCall(): ExportResult.Pdf {
- val pdf = generatePdf(ExportQuality.BALANCED, true) {}
- val sourceFile = pdf.file
- val targetFile = File(sourceFile.parentFile, defaultFilename() + ".pdf")
- if (sourceFile.absolutePath == targetFile.absolutePath) return pdf
- if (targetFile.exists() || !sourceFile.renameTo(targetFile)) return pdf
- return pdf.copy(file = targetFile)
- }
-
- private val _uiState = MutableStateFlow(ExportUiState())
- val uiState: StateFlow = _uiState.asStateFlow()
-
- private var lastPreparationKey: ExportPreparationKey? = null
- private var preparationJob: Job? = null
-
- fun setFilename(name: String) {
- _uiState.update {
- it.copy(filename = name, isUserDefinedFilename = true)
- }
- }
-
- fun resetFilename() {
- setFilename("")
- }
-
- private fun defaultFilename(): String {
- return settingsRepository.defaultFilenameStyle.value.filename()
- }
-
- private fun ensureValidFilename() {
- _uiState.update {
- val normalized = it.filename.trim()
- if (normalized.isEmpty() || !it.isUserDefinedFilename) {
- it.copy(filename = defaultFilename(), isUserDefinedFilename = false)
- } else if (normalized != it.filename) {
- it.copy(filename = normalized)
- } else it
- }
- }
-
- private suspend fun currentPageKeys(): ImmutableList =
- imageRepository.pages().map { it.key() }.toImmutableList()
-
- fun cancelPreparationJob() {
- preparationJob?.let {
- // keep result if job is not active
- if (it.isActive) {
- _uiState.update { ExportUiState() }
- }
- it.cancel()
- }
- }
-
- fun prepareExportIfNeeded() {
- ensureValidFilename()
-
- viewModelScope.launch {
- val exportQuality = settingsRepository.exportQuality.first()
- val exportFormat = settingsRepository.exportFormat.first()
- val ocrLanguageString = ocrService.languageString()
-
- val currentPageKeys = currentPageKeys()
- val key = ExportPreparationKey(
- currentPageKeys, exportFormat, exportQuality, ocrLanguageString)
- if (key == lastPreparationKey && _uiState.value.result != null) {
- return@launch
- }
- val pageCount = currentPageKeys.size
-
- lastPreparationKey = key
- preparationJob?.cancel()
- preparationJob?.join()
-
- preparationJob = launch {
- val ocrActivation = if (exportFormat == PDF) ocrLanguageString.isNotEmpty() else null
- val blackAndWhiteAsJpeg = exportFormat == ExportFormat.JPEG &&
- currentPageKeys.any { it.colorMode == ColorMode.BLACK_AND_WHITE }
- _uiState.update {
- ExportUiState(
- filename = it.filename,
- format = exportFormat,
- isGenerating = true,
- progress = ExportProgress(0, pageCount),
- ocrActivation = ocrActivation,
- blackAndWhiteAsJpeg = blackAndWhiteAsJpeg,
- )
- }
- val onPageCompleted: (Int) -> Unit = { completedPages ->
- ensureActive()
- _uiState.update {
- it.copy(progress = ExportProgress(completedPages, pageCount))
- }
- }
- try {
- val t1 = System.currentTimeMillis()
- val result = if (exportFormat == ExportFormat.JPEG) {
- generateJpegs(exportQuality, onPageCompleted)
- } else {
- generatePdf(exportQuality, false, onPageCompleted)
- }
- _uiState.update { it.copy(result = result) }
- val t2 = System.currentTimeMillis()
- Log.i("Export", "Generation: $pageCount pages, $exportQuality, ${t2 - t1} ms")
- } catch (e: CancellationException) {
- // Preparation cancelled: do nothing
- throw e
- } catch (e: Exception) {
- val message = "Failed to prepare $exportFormat export"
- logger.e("FairScan", message, e)
- _uiState.update {
- it.copy(error = ExportError.OnPrepareOrShare(message, e))
- }
- } finally {
- _uiState.update { it.copy(isGenerating = false) }
- }
- }
- }
- }
-
- private suspend fun generateJpegs(
- exportQuality: ExportQuality,
- onPageCompleted: (Int) -> Unit,
- ): ExportResult.Jpeg = withContext(Dispatchers.IO) {
- val jpegPages = jpegsToExport(imageRepository, exportQuality)
- val timestamp = System.currentTimeMillis()
- preparationDir.mkdirs()
- val files = jpegPages.mapIndexed { index, page ->
- val file = File(preparationDir, "$timestamp-${index + 1}.jpg")
- file.writeBytes(page.image.get().bytes)
- onPageCompleted(index + 1)
- file
- }.toList()
- val sizeInBytes = files.sumOf { it.length() }
- ExportResult.Jpeg(files, sizeInBytes)
- }
-
- private fun renameFile(source: File, target: File) {
- if (source.absolutePath == target.absolutePath) return
- if (target.exists() && !target.delete()) {
- throw IOException("Cannot delete existing file ${target.absolutePath}")
- }
- if (!source.renameTo(target)) {
- throw IOException("Failed to rename ${source.name} to ${target.name}")
- }
- }
-
- private fun applyRenaming(): ExportResult {
- val result = _uiState.value.result
- ?: throw IllegalStateException("Export result missing")
- ensureValidFilename()
- val filename = _uiState.value.filename
- val updated = when (result) {
- is ExportResult.Pdf -> {
- val fileName = FileManager.addPdfExtensionIfMissing(filename)
- val newFile = File(result.file.parentFile, fileName)
- renameFile(result.file, newFile)
- ExportResult.Pdf(newFile, result.sizeInBytes, result.pageCount)
- }
- is ExportResult.Jpeg -> {
- val base = filename.removeSuffix(".jpg")
- val files = result.files
- val renamedFiles = files.mapIndexed { index, file ->
- val indexSuffix = if (files.size == 1) "" else "_${index + 1}"
- val newFile = File(file.parentFile, "${base}${indexSuffix}.jpg")
- renameFile(file, newFile)
- newFile
- }
- result.copy(jpegFiles = renamedFiles)
- }
- }
- _uiState.update { it.copy(result = updated) }
- return updated
- }
-
- fun onShareClicked() {
- viewModelScope.launch {
- try {
- val result = applyRenaming()
- _events.emit(ExportEvent.Share(result))
- _uiState.update { it.copy(hasShared = true) }
- } catch (e: Exception) {
- val message = "Failed to prepare share"
- logger.e("FairScan", message, e)
- _uiState.update { it.copy(error = ExportError.OnPrepareOrShare(message, e)) }
- }
- }
- }
-
- fun onSaveClicked() {
- viewModelScope.launch {
- _events.emit(ExportEvent.RequestSave)
- }
- }
-
- fun onRequestSave(context: Context) {
- viewModelScope.launch {
- _uiState.update {it.copy(isSaving = true, error = null, savedBundle = null) }
- val exportFormat = uiState.value.format
- 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, saveDir, exportFormat)
- }
- } catch (e: MissingExportDirPermissionException) {
- logger.e("FairScan", "Missing export dir permission", e)
- _uiState.update {
- 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(error = ExportError.OnSave(R.string.error_save, saveDir, e))
- }
- } finally {
- _uiState.update { it.copy(isSaving = false) }
- }
- }
- }
-
- 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?, exportFormat: ExportFormat) {
- val result = applyRenaming()
- val savedItems = mutableListOf()
- val filesForMediaScan = mutableListOf()
-
- for (file in result.files) {
- 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
- val uri = saveViaMediaStore(context, file, exportFormat)
- SavedItem(uri, file.name, exportFormat)
- } else {
- // Android 8 and 9: use File API
- // (MediaStore doesn't allow to choose Downloads for Android<10)
- val out = fileManager.copyToExternalDir(file)
- filesForMediaScan.add(out)
- SavedItem(out.toUri(), out.name, exportFormat)
- }
- } else {
- // Use Storage Access Framework to save to the chosen directory
- if (!context.contentResolver.persistedUriPermissions.any { perm ->
- perm.uri == saveDir.uri && perm.isWritePermission
- }) {
- throw MissingExportDirPermissionException(saveDir.uri)
- }
- val safFile = saveViaSaf(context, file, saveDir.uri, exportFormat)
- SavedItem(safFile.uri, safFile.name ?: file.name, exportFormat)
- }
- savedItems += saved
- }
-
- val bundle = SavedBundle(savedItems, saveDir)
- _uiState.update { it.copy(savedBundle = bundle) }
-
- filesForMediaScan.forEach { f -> mediaScan(context, f, exportFormat.mimeType) }
- }
-
- private suspend fun mediaScan(
- context: Context,
- file: File,
- mimeType: String
- ): Uri? = suspendCoroutine { cont ->
- MediaScannerConnection.scanFile(
- context,
- arrayOf(file.absolutePath),
- arrayOf(mimeType)
- ) { _, uri ->
- cont.resume(uri)
- }
- }
-
- @RequiresApi(Build.VERSION_CODES.Q)
- private fun saveViaMediaStore(
- context: Context,
- source: File,
- format: ExportFormat
- ): Uri {
- val resolver = context.contentResolver
-
- val values = ContentValues().apply {
- put(MediaStore.MediaColumns.DISPLAY_NAME, source.name)
- put(MediaStore.MediaColumns.MIME_TYPE, format.mimeType)
- put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
- }
-
- val collection = MediaStore.Downloads.EXTERNAL_CONTENT_URI
- val uri = resolver.insert(collection, values)
- ?: throw IOException("Failed to create MediaStore entry")
-
- resolver.openOutputStream(uri)?.use { out ->
- source.inputStream().use { input ->
- input.copyTo(out)
- }
- } ?: throw IOException("Failed to open output stream")
-
- return uri
- }
-
- private fun saveViaSaf(
- context: Context,
- source: File,
- exportDirUri: Uri,
- exportFormat: ExportFormat,
- ): DocumentFile {
- 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(exportFormat.mimeType, 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
- }
-
- fun cleanUpOldPreparedFiles(thresholdInMillis: Int) {
- fileManager.cleanUpOldFiles(thresholdInMillis)
- }
-
- private fun resolveExportDirName(context: Context, exportDirUri: Uri?): String? {
- return if (exportDirUri == null) {
- null
- } else {
- DocumentFile.fromTreeUri(context, exportDirUri)?.name
- }
- }
-}
-
-data class ExportPreparationKey(
- val pages: ImmutableList,
- val format: ExportFormat,
- val quality: ExportQuality,
- val ocrLanguageString: String,
-)
-
-sealed class ExportResult {
- abstract val files: List
- abstract val sizeInBytes: Long
- abstract val pageCount: Int
- abstract val format: ExportFormat
-
- data class Pdf(
- val file: File,
- override val sizeInBytes: Long,
- override val pageCount: Int,
- ) : ExportResult() {
- override val files get() = listOf(file)
- override val format: ExportFormat = PDF
- }
-
- data class Jpeg(
- val jpegFiles: List,
- override val sizeInBytes: Long,
- ) : ExportResult() {
- override val files get() = jpegFiles
- override val pageCount get() = jpegFiles.size
- override val format: ExportFormat = ExportFormat.JPEG
- }
-}
-
-data class ExportActions(
- val prepareExportIfNeeded: () -> Unit,
- val setFilename: (String) -> Unit,
- val share: () -> Unit,
- val save: () -> Unit,
- val open: (SavedItem) -> Unit,
- val cancelPreparationJob: () -> Unit,
-)
-
-class MissingExportDirPermissionException(
- val uri: Uri
-) : IllegalStateException(
- "Missing persisted write permission for export dir: $uri"
-)
+/*
+ * Copyright 2025-2026 The FairScan authors
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation, either version 3 of the License, or (at your option)
+ * any later version.
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+package org.fairscan.app.ui.screens.export
+
+import android.content.Context
+import android.net.Uri
+import android.util.Log
+import androidx.core.net.toUri
+import androidx.documentfile.provider.DocumentFile
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.flow.MutableSharedFlow
+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.withContext
+import org.fairscan.app.AppContainer
+import org.fairscan.app.R
+import org.fairscan.app.data.safeDocumentName
+import org.fairscan.app.data.FileManager
+import org.fairscan.app.data.ImageRepository
+import org.fairscan.app.domain.ExportQuality
+import org.fairscan.app.domain.PageViewKey
+import org.fairscan.app.domain.jpegsToExport
+import org.fairscan.app.domain.pagesToExport
+import org.fairscan.app.ui.screens.settings.ExportFormat
+import org.fairscan.app.ui.screens.settings.ExportFormat.PDF
+import org.fairscan.imageprocessing.ColorMode
+import java.io.File
+import java.io.FileInputStream
+import java.io.IOException
+import java.util.concurrent.CancellationException
+
+sealed interface ExportEvent {
+ data class RequestSave(
+ val shareAfter: Boolean = false,
+ val closeAfter: Boolean = false,
+ val copyToCustomFolder: Boolean = false,
+ ) : ExportEvent
+ data class Share(val result: SavedBundle) : ExportEvent
+ data object Finished : ExportEvent
+}
+
+class ExportViewModel(container: AppContainer, val imageRepository: ImageRepository): ViewModel() {
+
+ private val preparationDir = container.preparationDir
+ private val fileManager = container.fileManager
+ private val documentLibrary = container.documentLibrary
+ private var archivedKey: Pair? = null
+ private var archivedBundle: SavedBundle? = null
+ var pendingRequest = ExportEvent.RequestSave()
+ private set
+ private val settingsRepository = container.settingsRepository
+ private val ocrService = container.ocrService
+ private val logger = container.logger
+
+ private val _events = MutableSharedFlow()
+ val events = _events.asSharedFlow()
+
+ private suspend fun generatePdf(
+ exportQuality: ExportQuality,
+ disableOcr: Boolean,
+ onPageCompleted: (Int) -> Unit,
+ ): ExportResult.Pdf = withContext(Dispatchers.IO) {
+ val pageToExports = pagesToExport(imageRepository, exportQuality)
+ val pdf = fileManager.generatePdf(pageToExports, disableOcr, onPageCompleted)
+ return@withContext ExportResult.Pdf(pdf.file, pdf.sizeInBytes, pdf.pageCount)
+ }
+
+ suspend fun generatePdfForExternalCall(): ExportResult.Pdf {
+ val pdf = generatePdf(ExportQuality.BALANCED, true) {}
+ val sourceFile = pdf.file
+ val targetFile = File(sourceFile.parentFile, defaultFilename() + ".pdf")
+ if (sourceFile.absolutePath == targetFile.absolutePath) return pdf
+ if (targetFile.exists() || !sourceFile.renameTo(targetFile)) return pdf
+ return pdf.copy(file = targetFile)
+ }
+
+ private val _uiState = MutableStateFlow(ExportUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private var lastPreparationKey: ExportPreparationKey? = null
+ private var preparationJob: Job? = null
+
+ fun setFilename(name: String) {
+ if (_uiState.value.isSaving) return
+ _uiState.update {
+ it.copy(filename = name, isUserDefinedFilename = true, savedBundle = null, hasShared = false)
+ }
+ }
+
+ fun resetFilename() {
+ _uiState.value = ExportUiState()
+ lastPreparationKey = null
+ archivedKey = null
+ archivedBundle = null
+ }
+
+ private fun defaultFilename(): String {
+ return settingsRepository.defaultFilenameStyle.value.filename()
+ }
+
+ private fun ensureValidFilename() {
+ _uiState.update {
+ val normalized = if (it.filename.isBlank()) "" else safeDocumentName(it.filename)
+ if (normalized.isEmpty() || !it.isUserDefinedFilename) {
+ it.copy(filename = defaultFilename(), isUserDefinedFilename = false)
+ } else if (normalized != it.filename) {
+ it.copy(filename = normalized)
+ } else it
+ }
+ }
+
+ private suspend fun currentPageKeys(): ImmutableList =
+ imageRepository.pages().map { it.key() }.toImmutableList()
+
+ fun cancelPreparationJob() {
+ preparationJob?.let {
+ // keep result if job is not active
+ if (it.isActive) {
+ _uiState.update { ExportUiState() }
+ }
+ it.cancel()
+ }
+ }
+
+ fun prepareExportIfNeeded() {
+ ensureValidFilename()
+
+ viewModelScope.launch {
+ val exportQuality = settingsRepository.exportQuality.first()
+ val exportFormat = settingsRepository.exportFormat.first()
+ val ocrLanguageString = ocrService.languageString()
+
+ val currentPageKeys = currentPageKeys()
+ val key = ExportPreparationKey(
+ currentPageKeys, exportFormat, exportQuality, ocrLanguageString)
+ if (key == lastPreparationKey && _uiState.value.result != null) {
+ return@launch
+ }
+ val pageCount = currentPageKeys.size
+
+ lastPreparationKey = key
+ preparationJob?.cancel()
+ preparationJob?.join()
+
+ preparationJob = launch {
+ val ocrActivation = if (exportFormat == PDF) ocrLanguageString.isNotEmpty() else null
+ val blackAndWhiteAsJpeg = exportFormat == ExportFormat.JPEG &&
+ currentPageKeys.any { it.colorMode == ColorMode.BLACK_AND_WHITE }
+ _uiState.update {
+ ExportUiState(
+ filename = it.filename,
+ format = exportFormat,
+ isGenerating = true,
+ progress = ExportProgress(0, pageCount),
+ ocrActivation = ocrActivation,
+ blackAndWhiteAsJpeg = blackAndWhiteAsJpeg,
+ )
+ }
+ val onPageCompleted: (Int) -> Unit = { completedPages ->
+ ensureActive()
+ _uiState.update {
+ it.copy(progress = ExportProgress(completedPages, pageCount))
+ }
+ }
+ try {
+ val t1 = System.currentTimeMillis()
+ val result = if (exportFormat == ExportFormat.JPEG) {
+ generateJpegs(exportQuality, onPageCompleted)
+ } else {
+ generatePdf(exportQuality, false, onPageCompleted)
+ }
+ _uiState.update { it.copy(result = result) }
+ val t2 = System.currentTimeMillis()
+ Log.i("Export", "Generation: $pageCount pages, $exportQuality, ${t2 - t1} ms")
+ } catch (e: CancellationException) {
+ // Preparation cancelled: do nothing
+ throw e
+ } catch (e: Exception) {
+ val message = "Failed to prepare $exportFormat export"
+ logger.e("FairScan", message, e)
+ _uiState.update {
+ it.copy(error = ExportError.OnPrepareOrShare(message, e))
+ }
+ } finally {
+ _uiState.update { it.copy(isGenerating = false) }
+ }
+ }
+ }
+ }
+
+ private suspend fun generateJpegs(
+ exportQuality: ExportQuality,
+ onPageCompleted: (Int) -> Unit,
+ ): ExportResult.Jpeg = withContext(Dispatchers.IO) {
+ val jpegPages = jpegsToExport(imageRepository, exportQuality)
+ val timestamp = System.currentTimeMillis()
+ preparationDir.mkdirs()
+ val files = jpegPages.mapIndexed { index, page ->
+ val file = File(preparationDir, "$timestamp-${index + 1}.jpg")
+ file.writeBytes(page.image.get().bytes)
+ onPageCompleted(index + 1)
+ file
+ }.toList()
+ val sizeInBytes = files.sumOf { it.length() }
+ ExportResult.Jpeg(files, sizeInBytes)
+ }
+
+ private fun renameFile(source: File, target: File) {
+ if (source.absolutePath == target.absolutePath) return
+ if (target.exists() && !target.delete()) {
+ throw IOException("Cannot delete existing file ${target.absolutePath}")
+ }
+ if (!source.renameTo(target)) {
+ throw IOException("Failed to rename ${source.name} to ${target.name}")
+ }
+ }
+
+ private fun applyRenaming(): ExportResult {
+ val result = _uiState.value.result
+ ?: throw IllegalStateException("Export result missing")
+ ensureValidFilename()
+ val filename = _uiState.value.filename
+ val updated = when (result) {
+ is ExportResult.Pdf -> {
+ val fileName = FileManager.addPdfExtensionIfMissing(filename)
+ val newFile = File(result.file.parentFile, fileName)
+ renameFile(result.file, newFile)
+ ExportResult.Pdf(newFile, result.sizeInBytes, result.pageCount)
+ }
+ is ExportResult.Jpeg -> {
+ val base = filename.removeSuffix(".jpg")
+ val files = result.files
+ val renamedFiles = files.mapIndexed { index, file ->
+ val indexSuffix = if (files.size == 1) "" else "_${index + 1}"
+ val newFile = File(file.parentFile, "${base}${indexSuffix}.jpg")
+ renameFile(file, newFile)
+ newFile
+ }
+ result.copy(jpegFiles = renamedFiles)
+ }
+ }
+ _uiState.update { it.copy(result = updated) }
+ return updated
+ }
+
+ private fun requestSave(request: ExportEvent.RequestSave) {
+ if (_uiState.value.isSaving || _uiState.value.isGenerating || _uiState.value.result == null) return
+ pendingRequest = request
+ viewModelScope.launch { _events.emit(request) }
+ }
+
+ fun onShareClicked() = requestSave(ExportEvent.RequestSave(shareAfter = true))
+
+ fun onSaveClicked() = requestSave(ExportEvent.RequestSave(copyToCustomFolder = true))
+
+ fun onFinishClicked() = requestSave(ExportEvent.RequestSave(closeAfter = true))
+
+ fun onRequestSave(context: Context, request: ExportEvent.RequestSave) {
+ if (_uiState.value.isSaving || _uiState.value.result == null) return
+ _uiState.update { it.copy(isSaving = true, error = null) }
+ viewModelScope.launch {
+ var destination: SaveDir? = null
+ try {
+ val libraryBundle = withContext(Dispatchers.IO) {
+ val result = applyRenaming()
+ val key = lastPreparationKey to _uiState.value.filename
+ val bundle = if (key == archivedKey && archivedBundle != null) {
+ archivedBundle!!
+ } else {
+ documentLibrary.save(result).also {
+ archivedKey = key
+ archivedBundle = it
+ }
+ }
+ // The library copy is always retained, including when exporting elsewhere.
+ _uiState.update { it.copy(savedBundle = bundle) }
+ if (request.copyToCustomFolder) {
+ destination = saveDir(context)
+ destination?.let { dir ->
+ if (!context.contentResolver.persistedUriPermissions.any {
+ it.uri == dir.uri && it.isWritePermission
+ }) throw MissingExportDirPermissionException(dir.uri)
+ val items = result.files.map { file ->
+ val target = saveViaSaf(context, file, dir.uri, result.format)
+ SavedItem(target.uri, target.name ?: file.name, result.format)
+ }
+ _uiState.update { it.copy(savedBundle = SavedBundle(items, dir)) }
+ }
+ }
+ bundle
+ }
+ if (request.shareAfter) {
+ _events.emit(ExportEvent.Share(libraryBundle))
+ _uiState.update { it.copy(hasShared = true) }
+ }
+ if (request.closeAfter) {
+ _uiState.update { it.copy(isSaving = false) }
+ _events.emit(ExportEvent.Finished)
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: MissingExportDirPermissionException) {
+ logger.e("FairScan", "Missing export dir permission", e)
+ _uiState.update { it.copy(error = ExportError.OnSave(
+ R.string.error_export_dir_permission_lost, destination, e)) }
+ } catch (e: Exception) {
+ logger.e("FairScan", "Failed to save document", e)
+ _uiState.update { it.copy(error = ExportError.OnSave(R.string.error_save, destination, e)) }
+ } finally {
+ _uiState.update { it.copy(isSaving = false) }
+ }
+ }
+ }
+
+ private suspend fun saveDir(context: Context): SaveDir? {
+ val uri = settingsRepository.exportDirUri.first()?.toUri() ?: return null
+ return SaveDir(uri, resolveExportDirName(context, uri))
+ }
+
+ private fun saveViaSaf(
+ context: Context,
+ source: File,
+ exportDirUri: Uri,
+ exportFormat: ExportFormat,
+ ): DocumentFile {
+ 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(exportFormat.mimeType, 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
+ }
+
+ fun cleanUpOldPreparedFiles(thresholdInMillis: Int) {
+ fileManager.cleanUpOldFiles(thresholdInMillis)
+ }
+
+ private fun resolveExportDirName(context: Context, exportDirUri: Uri?): String? {
+ return if (exportDirUri == null) {
+ null
+ } else {
+ DocumentFile.fromTreeUri(context, exportDirUri)?.name
+ }
+ }
+}
+
+data class ExportPreparationKey(
+ val pages: ImmutableList,
+ val format: ExportFormat,
+ val quality: ExportQuality,
+ val ocrLanguageString: String,
+)
+
+sealed class ExportResult {
+ abstract val files: List
+ abstract val sizeInBytes: Long
+ abstract val pageCount: Int
+ abstract val format: ExportFormat
+
+ data class Pdf(
+ val file: File,
+ override val sizeInBytes: Long,
+ override val pageCount: Int,
+ ) : ExportResult() {
+ override val files get() = listOf(file)
+ override val format: ExportFormat = PDF
+ }
+
+ data class Jpeg(
+ val jpegFiles: List,
+ override val sizeInBytes: Long,
+ ) : ExportResult() {
+ override val files get() = jpegFiles
+ override val pageCount get() = jpegFiles.size
+ override val format: ExportFormat = ExportFormat.JPEG
+ }
+}
+
+data class ExportActions(
+ val prepareExportIfNeeded: () -> Unit,
+ val setFilename: (String) -> Unit,
+ val share: () -> Unit,
+ val save: () -> Unit,
+ val open: (SavedItem) -> Unit,
+ val cancelPreparationJob: () -> Unit,
+)
+
+class MissingExportDirPermissionException(
+ val uri: Uri
+) : IllegalStateException(
+ "Missing persisted write permission for export dir: $uri"
+)
From 5f710992786ad2706b04ad4269cb351a76db7630 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Yakup=20Furkan=20Fidanc=C4=B1?=
<44572717+fidanci@users.noreply.github.com>
Date: Sun, 11 Oct 2026 03:45:04 +0300
Subject: [PATCH 05/11] Use the FairScan document folder by default
---
.../app/ui/screens/settings/SettingsScreen.kt | 754 +++++++++---------
1 file changed, 377 insertions(+), 377 deletions(-)
diff --git a/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsScreen.kt
index 9eed3428..cc880db7 100644
--- a/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsScreen.kt
+++ b/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsScreen.kt
@@ -1,377 +1,377 @@
-/*
- * Copyright 2025-2026 The FairScan authors
- *
- * This program is free software: you can redistribute it and/or modify it
- * under the terms of the GNU General Public License as published by the Free
- * Software Foundation, either version 3 of the License, or (at your option)
- * any later version.
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
- * more details.
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
-package org.fairscan.app.ui.screens.settings
-
-import androidx.activity.compose.BackHandler
-import androidx.compose.foundation.BorderStroke
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
-import androidx.compose.material.icons.filled.Folder
-import androidx.compose.material3.AlertDialog
-import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.HorizontalDivider
-import androidx.compose.material3.Icon
-import androidx.compose.material3.ListItem
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.RadioButton
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.material3.TopAppBar
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.saveable.rememberSaveable
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalResources
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.intl.Locale
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import org.fairscan.app.R
-import org.fairscan.app.data.OcrLanguage
-import org.fairscan.app.domain.ExportQuality
-import org.fairscan.app.ui.Navigation
-import org.fairscan.app.ui.components.BackButton
-import org.fairscan.app.ui.dummyNavigation
-import org.fairscan.app.ui.theme.FairScanTheme
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun SettingsScreen(
- uiState: SettingsUiState,
- onDefaultColorModeChanged: (DefaultColorMode) -> Unit,
- onChooseDirectoryClick: () -> Unit,
- onResetExportDirClick: () -> Unit,
- onExportFormatChanged: (ExportFormat) -> Unit,
- onExportQualityChanged: (ExportQuality) -> Unit,
- onDefaultFileNameStyleChanged: (DefaultFilenameStyle) -> Unit,
- navigation: Navigation,
-) {
- BackHandler { navigation.back() }
- Scaffold(
- topBar = {
- TopAppBar(
- title = { Text(stringResource(R.string.settings)) },
- navigationIcon = { BackButton(navigation.back) },
- )
- }
- ) { paddingValues ->
- SettingsContent(
- uiState,
- onDefaultColorModeChanged,
- onChooseDirectoryClick,
- onResetExportDirClick,
- onExportFormatChanged,
- onExportQualityChanged,
- onDefaultFileNameStyleChanged,
- navigation,
- modifier = Modifier.padding(paddingValues),
- )
- }
-}
-
-@Composable
-private fun SettingsContent(
- uiState: SettingsUiState,
- onDefaultColorModeChanged: (DefaultColorMode) -> Unit,
- onChooseDirectoryClick: () -> Unit,
- onResetExportDirClick: () -> Unit,
- onExportFormatChanged: (ExportFormat) -> Unit,
- onExportQualityChanged: (ExportQuality) -> Unit,
- onDefaultFileNameStyleChanged: (DefaultFilenameStyle) -> Unit,
- navigation: Navigation,
- modifier: Modifier = Modifier,
-) {
- val displayLocale = Locale.current.platformLocale
- val export = uiState.export
- val (folderLabel, folderLabelColor) = when {
- export.dirUri == null ->
- stringResource(R.string.download_dirname) to
- MaterialTheme.colorScheme.onSurface
-
- export.dirName != null ->
- export.dirName to
- MaterialTheme.colorScheme.onSurface
-
- else ->
- stringResource(R.string.export_folder_permission_lost) to
- MaterialTheme.colorScheme.error
- }
-
- Column(
- modifier
- .fillMaxSize()
- .padding(vertical = 8.dp, horizontal = 24.dp)
- .verticalScroll(rememberScrollState())
- ) {
- val context = LocalResources.current
-
- Text(stringResource(R.string.settings_section_scan), style = MaterialTheme.typography.titleLarge)
-
- SingleChoiceSetting(
- title = stringResource(R.string.color_mode_default),
- entries = DefaultColorMode.entries,
- onValueChanged = onDefaultColorModeChanged,
- label = { t -> context.getString(t.labelResource) },
- selectedValue = uiState.defaultColorMode,
- )
-
- Spacer(Modifier.height(16.dp))
- HorizontalDivider()
- Spacer(Modifier.height(16.dp))
-
- Text(stringResource(R.string.settings_section_export), style = MaterialTheme.typography.titleLarge)
- Spacer(Modifier.height(16.dp))
-
- DirectorySettingItem(
- label = stringResource(R.string.export_directory),
- folderLabel,
- folderLabelColor,
- onClick = onChooseDirectoryClick,
- )
-
- if (export.dirUri != null) {
- TextButton(
- onClick = onResetExportDirClick,
- modifier = Modifier.padding(start = 4.dp),
- ) {
- Text(stringResource(R.string.reset_to_default))
- }
- }
-
- Spacer(Modifier.height(8.dp))
-
- SingleChoiceSetting(
- title = stringResource(R.string.export_quality),
- entries = ExportQuality.entries.reversed(),
- selectedValue = export.quality,
- onValueChanged = onExportQualityChanged,
- label = { t -> context.getString(t.labelResource) },
- )
-
- SingleChoiceSetting(
- title = stringResource(R.string.export_format),
- entries = ExportFormat.entries,
- selectedValue = export.format,
- onValueChanged = onExportFormatChanged,
- label = { it.name },
- )
-
- SingleChoiceSetting(
- title = stringResource(R.string.settings_default_filename),
- entries = DefaultFilenameStyle.entries,
- selectedValue = export.filenameStyle,
- onValueChanged = onDefaultFileNameStyleChanged,
- label = { it.filename() },
- )
-
- Spacer(Modifier.height(16.dp))
- HorizontalDivider()
- Spacer(Modifier.height(16.dp))
- Text(
- stringResource(R.string.settings_section_ocr),
- style = MaterialTheme.typography.titleLarge
- )
-
- ListItem(
- headlineContent = { Text(stringResource(R.string.settings_ocr_languages)) },
- supportingContent = {
- Text(uiState.enabledOcrLanguages
- .map { OcrLanguage(it).displayName(displayLocale) }
- .sorted()
- .joinToString(" • ")
- .ifEmpty { stringResource(R.string.settings_ocr_languages_disabled) }
- )
- },
- trailingContent = {
- Icon(
- Icons.AutoMirrored.Filled.KeyboardArrowRight,
- contentDescription = null
- )
- },
- modifier = Modifier.clickable { navigation.toOcrLanguagesScreen() }
- )
- }
-}
-
-@Composable
-fun SingleChoiceSetting(
- title: String,
- entries: List,
- selectedValue: T,
- onValueChanged: (T) -> Unit,
- label: (T) -> String,
-) {
- var showDialog by rememberSaveable { mutableStateOf(false) }
-
- ListItem(
- headlineContent = {
- Text(title)
- },
- supportingContent = {
- Text(label(selectedValue))
- },
- trailingContent = {
- Icon(
- Icons.AutoMirrored.Filled.KeyboardArrowRight,
- contentDescription = null
- )
- },
- modifier = Modifier.clickable {
- showDialog = true
- }
- )
-
- if (showDialog) {
- AlertDialog(
- onDismissRequest = {
- showDialog = false
- },
- title = {
- Text(title)
- },
- text = {
- Column {
- entries.forEach { entry ->
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .clickable {
- onValueChanged(entry)
- showDialog = false
- }
- .padding(vertical = 8.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- RadioButton(
- selected = selectedValue == entry,
- onClick = {
- onValueChanged(entry)
- showDialog = false
- }
- )
-
- Text(
- text = label(entry),
- modifier = Modifier.padding(start = 8.dp)
- )
- }
- }
- }
- },
- confirmButton = {},
- dismissButton = {}
- )
- }
-}
-
-@Composable
-fun DirectorySettingItem(
- label: String,
- folderLabel: String,
- folderLabelColor: Color,
- onClick: () -> Unit,
-
- ) {
- Column (
- modifier = Modifier.padding(vertical = 0.dp, horizontal = 12.dp)
- ) {
- Text(
- text = label,
- style = MaterialTheme.typography.bodyLarge
- )
-
- Spacer(Modifier.height(8.dp))
-
- Card(
- modifier = Modifier
- .fillMaxWidth()
- .clickable(onClick = onClick),
- shape = MaterialTheme.shapes.medium,
- colors = CardDefaults.cardColors(containerColor = Color.Transparent),
- border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.onSurfaceVariant),
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 14.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = folderLabel,
- style = MaterialTheme.typography.bodyLarge,
- color = folderLabelColor,
- )
-
- Icon(
- Icons.Default.Folder,
- contentDescription = stringResource(R.string.change_directory),
- )
- }
- }
- }
-}
-
-@Preview
-@Preview(heightDp = 780)
-@Composable
-fun SettingsScreenPreviewWithoutDir() {
- SettingsScreenPreview(SettingsUiState(
- installedOcrLanguages = setOf("fra", "eng", "deu"),
- enabledOcrLanguages = setOf("fra", "eng")
- ))
-}
-
-@Preview
-@Composable
-fun SettingsScreenPreviewWithDir() {
- SettingsScreenPreview(
- SettingsUiState(export= ExportSettingsUiState(dirUri = "content://root/dir"))
- )
-}
-
-@Composable
-fun SettingsScreenPreview(uiState: SettingsUiState) {
- FairScanTheme {
- SettingsScreen(
- uiState,
- onDefaultColorModeChanged = {},
- onChooseDirectoryClick = {},
- onResetExportDirClick = {},
- onExportFormatChanged = {},
- onExportQualityChanged = {},
- onDefaultFileNameStyleChanged = {},
- navigation = dummyNavigation(),
- )
- }
-}
+/*
+ * Copyright 2025-2026 The FairScan authors
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation, either version 3 of the License, or (at your option)
+ * any later version.
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+package org.fairscan.app.ui.screens.settings
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
+import androidx.compose.material.icons.filled.Folder
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalResources
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.intl.Locale
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.fairscan.app.R
+import org.fairscan.app.data.OcrLanguage
+import org.fairscan.app.domain.ExportQuality
+import org.fairscan.app.ui.Navigation
+import org.fairscan.app.ui.components.BackButton
+import org.fairscan.app.ui.dummyNavigation
+import org.fairscan.app.ui.theme.FairScanTheme
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SettingsScreen(
+ uiState: SettingsUiState,
+ onDefaultColorModeChanged: (DefaultColorMode) -> Unit,
+ onChooseDirectoryClick: () -> Unit,
+ onResetExportDirClick: () -> Unit,
+ onExportFormatChanged: (ExportFormat) -> Unit,
+ onExportQualityChanged: (ExportQuality) -> Unit,
+ onDefaultFileNameStyleChanged: (DefaultFilenameStyle) -> Unit,
+ navigation: Navigation,
+) {
+ BackHandler { navigation.back() }
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.settings)) },
+ navigationIcon = { BackButton(navigation.back) },
+ )
+ }
+ ) { paddingValues ->
+ SettingsContent(
+ uiState,
+ onDefaultColorModeChanged,
+ onChooseDirectoryClick,
+ onResetExportDirClick,
+ onExportFormatChanged,
+ onExportQualityChanged,
+ onDefaultFileNameStyleChanged,
+ navigation,
+ modifier = Modifier.padding(paddingValues),
+ )
+ }
+}
+
+@Composable
+private fun SettingsContent(
+ uiState: SettingsUiState,
+ onDefaultColorModeChanged: (DefaultColorMode) -> Unit,
+ onChooseDirectoryClick: () -> Unit,
+ onResetExportDirClick: () -> Unit,
+ onExportFormatChanged: (ExportFormat) -> Unit,
+ onExportQualityChanged: (ExportQuality) -> Unit,
+ onDefaultFileNameStyleChanged: (DefaultFilenameStyle) -> Unit,
+ navigation: Navigation,
+ modifier: Modifier = Modifier,
+) {
+ val displayLocale = Locale.current.platformLocale
+ val export = uiState.export
+ val (folderLabel, folderLabelColor) = when {
+ export.dirUri == null ->
+ stringResource(R.string.library_folder_name) to
+ MaterialTheme.colorScheme.onSurface
+
+ export.dirName != null ->
+ export.dirName to
+ MaterialTheme.colorScheme.onSurface
+
+ else ->
+ stringResource(R.string.export_folder_permission_lost) to
+ MaterialTheme.colorScheme.error
+ }
+
+ Column(
+ modifier
+ .fillMaxSize()
+ .padding(vertical = 8.dp, horizontal = 24.dp)
+ .verticalScroll(rememberScrollState())
+ ) {
+ val context = LocalResources.current
+
+ Text(stringResource(R.string.settings_section_scan), style = MaterialTheme.typography.titleLarge)
+
+ SingleChoiceSetting(
+ title = stringResource(R.string.color_mode_default),
+ entries = DefaultColorMode.entries,
+ onValueChanged = onDefaultColorModeChanged,
+ label = { t -> context.getString(t.labelResource) },
+ selectedValue = uiState.defaultColorMode,
+ )
+
+ Spacer(Modifier.height(16.dp))
+ HorizontalDivider()
+ Spacer(Modifier.height(16.dp))
+
+ Text(stringResource(R.string.settings_section_export), style = MaterialTheme.typography.titleLarge)
+ Spacer(Modifier.height(16.dp))
+
+ DirectorySettingItem(
+ label = stringResource(R.string.export_directory),
+ folderLabel,
+ folderLabelColor,
+ onClick = onChooseDirectoryClick,
+ )
+
+ if (export.dirUri != null) {
+ TextButton(
+ onClick = onResetExportDirClick,
+ modifier = Modifier.padding(start = 4.dp),
+ ) {
+ Text(stringResource(R.string.reset_to_default))
+ }
+ }
+
+ Spacer(Modifier.height(8.dp))
+
+ SingleChoiceSetting(
+ title = stringResource(R.string.export_quality),
+ entries = ExportQuality.entries.reversed(),
+ selectedValue = export.quality,
+ onValueChanged = onExportQualityChanged,
+ label = { t -> context.getString(t.labelResource) },
+ )
+
+ SingleChoiceSetting(
+ title = stringResource(R.string.export_format),
+ entries = ExportFormat.entries,
+ selectedValue = export.format,
+ onValueChanged = onExportFormatChanged,
+ label = { it.name },
+ )
+
+ SingleChoiceSetting(
+ title = stringResource(R.string.settings_default_filename),
+ entries = DefaultFilenameStyle.entries,
+ selectedValue = export.filenameStyle,
+ onValueChanged = onDefaultFileNameStyleChanged,
+ label = { it.filename() },
+ )
+
+ Spacer(Modifier.height(16.dp))
+ HorizontalDivider()
+ Spacer(Modifier.height(16.dp))
+ Text(
+ stringResource(R.string.settings_section_ocr),
+ style = MaterialTheme.typography.titleLarge
+ )
+
+ ListItem(
+ headlineContent = { Text(stringResource(R.string.settings_ocr_languages)) },
+ supportingContent = {
+ Text(uiState.enabledOcrLanguages
+ .map { OcrLanguage(it).displayName(displayLocale) }
+ .sorted()
+ .joinToString(" • ")
+ .ifEmpty { stringResource(R.string.settings_ocr_languages_disabled) }
+ )
+ },
+ trailingContent = {
+ Icon(
+ Icons.AutoMirrored.Filled.KeyboardArrowRight,
+ contentDescription = null
+ )
+ },
+ modifier = Modifier.clickable { navigation.toOcrLanguagesScreen() }
+ )
+ }
+}
+
+@Composable
+fun SingleChoiceSetting(
+ title: String,
+ entries: List,
+ selectedValue: T,
+ onValueChanged: (T) -> Unit,
+ label: (T) -> String,
+) {
+ var showDialog by rememberSaveable { mutableStateOf(false) }
+
+ ListItem(
+ headlineContent = {
+ Text(title)
+ },
+ supportingContent = {
+ Text(label(selectedValue))
+ },
+ trailingContent = {
+ Icon(
+ Icons.AutoMirrored.Filled.KeyboardArrowRight,
+ contentDescription = null
+ )
+ },
+ modifier = Modifier.clickable {
+ showDialog = true
+ }
+ )
+
+ if (showDialog) {
+ AlertDialog(
+ onDismissRequest = {
+ showDialog = false
+ },
+ title = {
+ Text(title)
+ },
+ text = {
+ Column {
+ entries.forEach { entry ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ onValueChanged(entry)
+ showDialog = false
+ }
+ .padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ RadioButton(
+ selected = selectedValue == entry,
+ onClick = {
+ onValueChanged(entry)
+ showDialog = false
+ }
+ )
+
+ Text(
+ text = label(entry),
+ modifier = Modifier.padding(start = 8.dp)
+ )
+ }
+ }
+ }
+ },
+ confirmButton = {},
+ dismissButton = {}
+ )
+ }
+}
+
+@Composable
+fun DirectorySettingItem(
+ label: String,
+ folderLabel: String,
+ folderLabelColor: Color,
+ onClick: () -> Unit,
+
+ ) {
+ Column (
+ modifier = Modifier.padding(vertical = 0.dp, horizontal = 12.dp)
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodyLarge
+ )
+
+ Spacer(Modifier.height(8.dp))
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick),
+ shape = MaterialTheme.shapes.medium,
+ colors = CardDefaults.cardColors(containerColor = Color.Transparent),
+ border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.onSurfaceVariant),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = folderLabel,
+ style = MaterialTheme.typography.bodyLarge,
+ color = folderLabelColor,
+ )
+
+ Icon(
+ Icons.Default.Folder,
+ contentDescription = stringResource(R.string.change_directory),
+ )
+ }
+ }
+ }
+}
+
+@Preview
+@Preview(heightDp = 780)
+@Composable
+fun SettingsScreenPreviewWithoutDir() {
+ SettingsScreenPreview(SettingsUiState(
+ installedOcrLanguages = setOf("fra", "eng", "deu"),
+ enabledOcrLanguages = setOf("fra", "eng")
+ ))
+}
+
+@Preview
+@Composable
+fun SettingsScreenPreviewWithDir() {
+ SettingsScreenPreview(
+ SettingsUiState(export= ExportSettingsUiState(dirUri = "content://root/dir"))
+ )
+}
+
+@Composable
+fun SettingsScreenPreview(uiState: SettingsUiState) {
+ FairScanTheme {
+ SettingsScreen(
+ uiState,
+ onDefaultColorModeChanged = {},
+ onChooseDirectoryClick = {},
+ onResetExportDirClick = {},
+ onExportFormatChanged = {},
+ onExportQualityChanged = {},
+ onDefaultFileNameStyleChanged = {},
+ navigation = dummyNavigation(),
+ )
+ }
+}
From f21e90f513d4fabb048ff5f88861659e4991eb81 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Yakup=20Furkan=20Fidanc=C4=B1?=
<44572717+fidanci@users.noreply.github.com>
Date: Sun, 11 Oct 2026 03:45:56 +0300
Subject: [PATCH 06/11] Add document management and an in-app PDF reader
---
.../screens/library/LibraryDocumentViewer.kt | 417 ++++++++++++++++++
.../app/ui/screens/library/LibraryScreen.kt | 232 ++++++++++
.../ui/screens/library/LibraryViewModel.kt | 93 ++++
.../app/ui/screens/library/PdfViewport.kt | 27 ++
4 files changed, 769 insertions(+)
create mode 100644 app/src/main/java/org/fairscan/app/ui/screens/library/LibraryDocumentViewer.kt
create mode 100644 app/src/main/java/org/fairscan/app/ui/screens/library/LibraryScreen.kt
create mode 100644 app/src/main/java/org/fairscan/app/ui/screens/library/LibraryViewModel.kt
create mode 100644 app/src/main/java/org/fairscan/app/ui/screens/library/PdfViewport.kt
diff --git a/app/src/main/java/org/fairscan/app/ui/screens/library/LibraryDocumentViewer.kt b/app/src/main/java/org/fairscan/app/ui/screens/library/LibraryDocumentViewer.kt
new file mode 100644
index 00000000..355f8196
--- /dev/null
+++ b/app/src/main/java/org/fairscan/app/ui/screens/library/LibraryDocumentViewer.kt
@@ -0,0 +1,417 @@
+/*
+ * Copyright 2025-2026 The FairScan authors
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation, either version 3 of the License, or (at your option)
+ * any later version.
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+package org.fairscan.app.ui.screens.library
+
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.gestures.detectTransformGestures
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.filled.FitScreen
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.graphics.FilterQuality
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntSize
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.pdf.PdfRenderer
+import android.net.Uri
+import android.os.ParcelFileDescriptor
+import android.util.Log
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Description
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Remove
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.fairscan.app.R
+import org.fairscan.app.platform.loadBitmapFromUri
+import java.io.Closeable
+import java.io.File
+import kotlin.math.min
+import kotlin.math.sqrt
+import kotlin.math.roundToInt
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun LibraryDocumentViewer(
+ uri: Uri,
+ name: String,
+ mimeType: String,
+ onBack: () -> Unit,
+) {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(name, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
+ }
+ },
+ )
+ },
+ ) { padding ->
+ if (mimeType == "application/pdf") {
+ PdfDocumentContent(uri, name, Modifier.padding(padding))
+ } else {
+ ImageDocumentContent(uri, name, Modifier.padding(padding))
+ }
+ }
+}
+
+@Composable
+private fun ImageDocumentContent(uri: Uri, name: String, modifier: Modifier = Modifier) {
+ val context = LocalContext.current
+ val result by produceState?>(null, uri) {
+ value = withContext(Dispatchers.IO) {
+ runCatching { loadBitmapFromUri(context.contentResolver, uri) }
+ .onFailure { Log.e("LibraryViewer", "Could not decode image $uri", it) }
+ }
+ }
+ when {
+ result == null -> LoadingDocument(modifier)
+ result!!.isFailure -> DocumentError(modifier)
+ else -> Box(
+ modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceContainerLow),
+ contentAlignment = Alignment.TopCenter,
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Top,
+ ) {
+ Image(
+ bitmap = result!!.getOrThrow().asImageBitmap(),
+ contentDescription = name,
+ modifier = Modifier.fillMaxWidth().padding(12.dp),
+ )
+ }
+ }
+ }
+}
+
+
+@Composable
+private fun PdfDocumentContent(uri: Uri, name: String, modifier: Modifier = Modifier) {
+ val context = LocalContext.current
+ val pdfState by produceState?>(null, uri) {
+ value = withContext(Dispatchers.IO) {
+ runCatching { PdfPageSource(context, uri) }
+ .onFailure { Log.e("LibraryViewer", "Could not open PDF $uri", it) }
+ }
+ }
+ val source = pdfState?.getOrNull()
+ DisposableEffect(source) { onDispose { source?.close() } }
+ when {
+ pdfState == null -> LoadingDocument(modifier)
+ pdfState!!.isFailure -> DocumentError(modifier, pdfState!!.exceptionOrNull())
+ else -> BoxWithConstraints(modifier.fillMaxSize().clipToBounds()
+ .background(MaterialTheme.colorScheme.surfaceContainerLow)) {
+ val density = LocalDensity.current
+ val width = with(density) { maxWidth.toPx() }
+ val height = with(density) { maxHeight.toPx() }
+ val gap = with(density) { 8.dp.toPx() }
+ if (width > gap * 2 && height > 0) {
+ key(source, width, height) {
+ PdfReadingSurface(checkNotNull(source), name, width, height, gap)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun PdfReadingSurface(pdf: PdfPageSource, name: String, width: Float, height: Float, gap: Float) {
+ val pageWidth = width - gap * 2
+ val heights = remember(pdf, width) { pdf.pageRatios.map { pageWidth * it } }
+ val tops = remember(heights) {
+ var top = gap
+ heights.map { pageHeight -> top.also { top += pageHeight + gap } }
+ }
+ val documentHeight = tops.last() + heights.last() + gap
+ var viewport by remember { mutableStateOf(PdfViewport().bounded(width, height, documentHeight)) }
+ // Keep the last bitmap while fingers move; refine it once the zoom settles.
+ val renderZoom by produceState(1.5f, viewport.zoom) {
+ kotlinx.coroutines.delay(180)
+ value = maxOf(0.5f, viewport.zoom * 1.5f)
+ }
+ var showPageDialog by remember { mutableStateOf(false) }
+ var pageInput by remember { mutableStateOf("") }
+ val currentPage = tops.indices.maxByOrNull { index ->
+ val top = tops[index] * viewport.zoom + viewport.y
+ val bottom = top + heights[index] * viewport.zoom
+ (minOf(bottom, height) - maxOf(top, 0f)).coerceAtLeast(0f)
+ } ?: 0
+ fun changeZoom(target: Float, focus: Offset = Offset(width / 2, height / 2)) {
+ viewport = viewport.transform(target / viewport.zoom, focus.x, focus.y, 0f, 0f,
+ width, height, documentHeight)
+ }
+ fun goToPage(index: Int, fit: Boolean = false) {
+ val zoom = if (fit) minOf(1f, (height - gap * 12).coerceAtLeast(1f) / heights[index])
+ .coerceAtLeast(0.25f) else viewport.zoom
+ viewport = PdfViewport(zoom, (width - width * zoom) / 2,
+ gap - tops[index] * zoom).bounded(width, height, documentHeight)
+ }
+ Box(Modifier.fillMaxSize()) {
+ // Gestures are measured in the fixed viewport, never inside a scaled page.
+ Box(Modifier.fillMaxSize()
+ .pointerInput(pdf) {
+ detectTapGestures(onDoubleTap = { focus ->
+ changeZoom(if (viewport.zoom >= 2f) 1f else 2.5f, focus)
+ })
+ }
+ .pointerInput(pdf) {
+ detectTransformGestures { centroid, pan, factor, _ ->
+ viewport = viewport.transform(factor, centroid.x, centroid.y, pan.x, pan.y,
+ width, height, documentHeight)
+ }
+ }) {
+ for (index in tops.indices) {
+ val pageTop = tops[index] * viewport.zoom + viewport.y
+ val pageHeight = heights[index] * viewport.zoom
+ if (pageTop > height + height / 2 || pageTop + pageHeight < -height / 2) continue
+ key(index) {
+ PdfRenderedPage(pdf, index, (pageWidth * renderZoom).roundToInt(),
+ Rect(gap * viewport.zoom + viewport.x, pageTop,
+ (gap + pageWidth) * viewport.zoom + viewport.x, pageTop + pageHeight),
+ name)
+ }
+ }
+ }
+ Surface(
+ modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 16.dp),
+ shape = RoundedCornerShape(28.dp),
+ shadowElevation = 8.dp,
+ color = MaterialTheme.colorScheme.surfaceContainerHigh,
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(horizontal = 4.dp)) {
+ IconButton(onClick = { changeZoom(viewport.zoom / 1.25f) }) {
+ Icon(Icons.Default.Remove, stringResource(R.string.library_zoom_out))
+ }
+ TextButton(onClick = { changeZoom(1f) }) {
+ Text("${(viewport.zoom * 100).roundToInt()}%")
+ }
+ IconButton(onClick = { changeZoom(viewport.zoom * 1.25f) }) {
+ Icon(Icons.Default.Add, stringResource(R.string.library_zoom_in))
+ }
+ IconButton(onClick = { goToPage(currentPage, fit = true) }) {
+ Icon(Icons.Default.FitScreen, stringResource(R.string.library_fit_page))
+ }
+ TextButton(onClick = {
+ pageInput = (currentPage + 1).toString()
+ showPageDialog = true
+ }) {
+ Text(stringResource(R.string.library_page_count, currentPage + 1, pdf.pageCount))
+ }
+ }
+ }
+ }
+ if (showPageDialog) {
+ val chosenPage = pageInput.toIntOrNull()
+ AlertDialog(
+ onDismissRequest = { showPageDialog = false },
+ title = { Text(stringResource(R.string.library_go_to_page)) },
+ text = {
+ OutlinedTextField(value = pageInput, onValueChange = { pageInput = it },
+ singleLine = true,
+ label = { Text(stringResource(R.string.library_page_count, currentPage + 1, pdf.pageCount)) },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
+ },
+ confirmButton = {
+ TextButton(enabled = chosenPage != null && chosenPage in 1..pdf.pageCount,
+ onClick = { goToPage(checkNotNull(chosenPage) - 1); showPageDialog = false }) {
+ Text(stringResource(R.string.open))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showPageDialog = false }) { Text(stringResource(R.string.cancel)) }
+ },
+ )
+ }
+}
+
+@Composable
+private fun PdfRenderedPage(source: PdfPageSource, index: Int, renderWidth: Int, bounds: Rect, name: String) {
+ val result by produceState?>(null, source, index, renderWidth) {
+ value = withContext(Dispatchers.IO) {
+ runCatching { source.renderPage(index, renderWidth) }
+ .onFailure { Log.e("LibraryViewer", "Could not render PDF page ${index + 1}", it) }
+ }
+ }
+ val bitmap = result?.getOrNull()
+ val image = remember(bitmap) { bitmap?.asImageBitmap() }
+ androidx.compose.foundation.Canvas(Modifier.fillMaxSize()
+ .semantics { contentDescription = "$name, ${index + 1}" }) {
+ drawRect(androidx.compose.ui.graphics.Color.White, bounds.topLeft, bounds.size)
+ if (image != null) {
+ drawImage(image,
+ dstOffset = IntOffset(bounds.left.roundToInt(), bounds.top.roundToInt()),
+ dstSize = IntSize(bounds.width.roundToInt().coerceAtLeast(1),
+ bounds.height.roundToInt().coerceAtLeast(1)),
+ filterQuality = FilterQuality.High)
+ }
+ }
+ if (result == null) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
+ } else if (result!!.isFailure) {
+ DocumentError(Modifier.fillMaxSize(), result!!.exceptionOrNull())
+ }
+}
+
+@Composable
+private fun LoadingDocument(modifier: Modifier = Modifier) {
+ Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+}
+
+@Composable
+private fun DocumentError(modifier: Modifier = Modifier, error: Throwable? = null) {
+ Column(
+ modifier = modifier.fillMaxSize().padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Icon(Icons.Default.Description, contentDescription = null, tint = MaterialTheme.colorScheme.error)
+ Text(stringResource(R.string.library_view_error), color = MaterialTheme.colorScheme.error)
+ error?.rootCause()?.let { cause ->
+ val detail = buildString {
+ append(cause.javaClass.simpleName)
+ cause.message?.takeIf(String::isNotBlank)?.let { append(": ").append(it) }
+ }.take(180)
+ Text(detail, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+}
+
+private fun Throwable.rootCause(): Throwable {
+ var cause = this
+ while (cause.cause != null && cause.cause !== cause) cause = cause.cause!!
+ return cause
+}
+
+private class PdfPageSource(context: Context, uri: Uri) : Closeable {
+ private val cachedFile = File.createTempFile("fairscan-view-", ".pdf", context.cacheDir)
+ val pageCount: Int
+ val pageRatios: List
+ private var closed = false
+
+ init {
+ var openedRenderer: PdfRenderer? = null
+ var openedDescriptor: ParcelFileDescriptor? = null
+ try {
+ val input = context.contentResolver.openInputStream(uri) ?: error("Cannot open PDF")
+ input.use { source -> cachedFile.outputStream().use { source.copyTo(it) } }
+ val fileDescriptor = ParcelFileDescriptor.open(cachedFile, ParcelFileDescriptor.MODE_READ_ONLY)
+ openedDescriptor = fileDescriptor
+ val pdfRenderer = PdfRenderer(fileDescriptor)
+ openedRenderer = pdfRenderer
+ pageCount = pdfRenderer.pageCount
+ require(pageCount > 0) { "PDF has no pages" }
+ pageRatios = (0 until pageCount).map { index ->
+ pdfRenderer.openPage(index).use { it.height.toFloat() / it.width }
+ }
+ pdfRenderer.close()
+ fileDescriptor.close()
+ } catch (error: Throwable) {
+ runCatching { openedRenderer?.close() }
+ runCatching { openedDescriptor?.close() }
+ cachedFile.delete()
+ throw error
+ }
+ }
+
+ @Synchronized
+ fun renderPage(index: Int, requestedWidth: Int): Bitmap {
+ check(!closed) { "PDF viewer is closed" }
+ ParcelFileDescriptor.open(cachedFile, ParcelFileDescriptor.MODE_READ_ONLY).use { descriptor ->
+ PdfRenderer(descriptor).use { renderer ->
+ renderer.openPage(index).use { page ->
+ val requestedScale = requestedWidth.toFloat() / page.width
+ val memoryScale = sqrt(MAX_PAGE_PIXELS.toFloat() / (page.width.toFloat() * page.height))
+ val scale = min(requestedScale, memoryScale)
+ val width = (page.width * scale).toInt().coerceAtLeast(1)
+ val height = (page.height * scale).toInt().coerceAtLeast(1)
+ val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+ Canvas(bitmap).drawColor(Color.WHITE)
+ page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
+ return bitmap
+ }
+ }
+ }
+ }
+
+ @Synchronized
+ override fun close() {
+ if (closed) return
+ closed = true
+ cachedFile.delete()
+ }
+
+ private companion object {
+ const val MAX_PAGE_PIXELS = 6_000_000
+ }
+}
diff --git a/app/src/main/java/org/fairscan/app/ui/screens/library/LibraryScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/library/LibraryScreen.kt
new file mode 100644
index 00000000..f1c0c864
--- /dev/null
+++ b/app/src/main/java/org/fairscan/app/ui/screens/library/LibraryScreen.kt
@@ -0,0 +1,232 @@
+/*
+ * Copyright 2025-2026 The FairScan authors
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation, either version 3 of the License, or (at your option)
+ * any later version.
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+package org.fairscan.app.ui.screens.library
+
+import android.text.format.DateFormat
+import android.text.format.Formatter
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CameraAlt
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Description
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.FolderOpen
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Card
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import org.fairscan.app.R
+import org.fairscan.app.data.LibraryDocument
+import java.util.Date
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun LibraryScreen(
+ state: LibraryUiState,
+ hasDraft: Boolean,
+ onScan: () -> Unit,
+ onResumeDraft: () -> Unit,
+ onOpen: (LibraryDocument) -> Unit,
+ onShare: (LibraryDocument) -> Unit,
+ onAddDocuments: () -> Unit,
+ onRename: (LibraryDocument, String) -> Unit,
+ onDelete: (LibraryDocument) -> Unit,
+ onRetry: () -> Unit,
+) {
+ val context = LocalContext.current
+ var deletingDocument by remember { mutableStateOf(null) }
+ var renamingDocument by remember { mutableStateOf(null) }
+ var editedName by remember { mutableStateOf("") }
+
+ Scaffold(
+ topBar = { TopAppBar(
+ title = { Text(stringResource(R.string.library_title)) },
+ actions = {
+ IconButton(onClick = onAddDocuments) {
+ Icon(Icons.Default.Add, stringResource(R.string.library_add_documents))
+ }
+ },
+ ) },
+ floatingActionButton = {
+ FloatingActionButton(onClick = onScan) {
+ Icon(Icons.Default.CameraAlt, stringResource(R.string.scan_new))
+ }
+ },
+ ) { padding ->
+ Column(Modifier.fillMaxSize().padding(padding)) {
+ Text(stringResource(R.string.library_location),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp))
+ if (hasDraft) {
+ Card(onClick = onResumeDraft, modifier = Modifier.fillMaxWidth().padding(16.dp)) {
+ Column(Modifier.padding(16.dp)) {
+ Text(stringResource(R.string.library_draft), style = MaterialTheme.typography.titleMedium)
+ Text(stringResource(R.string.library_resume), style = MaterialTheme.typography.bodyMedium)
+ }
+ }
+ }
+ when {
+ state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ state.failed -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(24.dp)) {
+ Text(stringResource(R.string.library_load_error))
+ TextButton(onClick = onRetry) { Text(stringResource(R.string.library_retry)) }
+ }
+ }
+ state.documents.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(32.dp)) {
+ Icon(Icons.Default.FolderOpen, null, Modifier.size(64.dp), tint = MaterialTheme.colorScheme.primary)
+ Text(stringResource(R.string.library_empty), style = MaterialTheme.typography.titleLarge)
+ Text(stringResource(R.string.library_empty_hint), style = MaterialTheme.typography.bodyMedium)
+ }
+ }
+ else -> LazyColumn(contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 96.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ items(state.documents, key = { it.uri.toString() }) { document ->
+ var menuExpanded by remember(document.uri) { mutableStateOf(false) }
+ Card(onClick = {
+ onOpen(document)
+ }, modifier = Modifier.fillMaxWidth()) {
+ Row(Modifier.padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ Icon(Icons.Default.Description, null, tint = MaterialTheme.colorScheme.primary)
+ Column(Modifier.weight(1f)) {
+ Text(document.name, maxLines = 2, overflow = TextOverflow.Ellipsis,
+ style = MaterialTheme.typography.titleMedium)
+ Text("${DateFormat.getDateFormat(context).format(Date(document.modifiedAt))} · " +
+ Formatter.formatShortFileSize(context, document.size),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ Box {
+ IconButton(onClick = { menuExpanded = true }) {
+ Icon(Icons.Default.MoreVert, stringResource(R.string.library_document_menu, document.name))
+ }
+ DropdownMenu(
+ expanded = menuExpanded,
+ onDismissRequest = { menuExpanded = false },
+ ) {
+ DropdownMenuItem(
+ leadingIcon = { Icon(Icons.Default.Share, null) },
+ text = { Text(stringResource(R.string.share)) },
+ onClick = { menuExpanded = false; onShare(document) },
+ )
+ DropdownMenuItem(
+ leadingIcon = { Icon(Icons.Default.Edit, null) },
+ text = { Text(stringResource(R.string.library_rename_document)) },
+ onClick = {
+ menuExpanded = false
+ editedName = document.name
+ renamingDocument = document
+ },
+ )
+ DropdownMenuItem(
+ leadingIcon = { Icon(Icons.Default.Delete, null) },
+ text = { Text(stringResource(R.string.library_delete_selected)) },
+ onClick = { menuExpanded = false; deletingDocument = document },
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ deletingDocument?.let { document ->
+ AlertDialog(
+ onDismissRequest = { deletingDocument = null },
+ title = { Text(stringResource(R.string.library_delete_title)) },
+ text = { Text(stringResource(R.string.library_delete_message, 1)) },
+ confirmButton = {
+ TextButton(onClick = {
+ deletingDocument = null
+ onDelete(document)
+ }) { Text(stringResource(R.string.library_delete_confirm), color = MaterialTheme.colorScheme.error) }
+ },
+ dismissButton = {
+ TextButton(onClick = { deletingDocument = null }) {
+ Text(stringResource(R.string.cancel))
+ }
+ },
+ )
+ }
+
+ renamingDocument?.let { document ->
+ AlertDialog(
+ onDismissRequest = { renamingDocument = null },
+ title = { Text(stringResource(R.string.library_rename_document)) },
+ text = {
+ TextField(
+ value = editedName,
+ onValueChange = { editedName = it },
+ singleLine = true,
+ label = { Text(stringResource(R.string.library_rename_name)) },
+ )
+ },
+ confirmButton = {
+ TextButton(onClick = {
+ renamingDocument = null
+ onRename(document, editedName)
+ }) { Text(stringResource(R.string.library_rename_confirm)) }
+ },
+ dismissButton = {
+ TextButton(onClick = { renamingDocument = null }) { Text(stringResource(R.string.cancel)) }
+ },
+ )
+ }
+}
diff --git a/app/src/main/java/org/fairscan/app/ui/screens/library/LibraryViewModel.kt b/app/src/main/java/org/fairscan/app/ui/screens/library/LibraryViewModel.kt
new file mode 100644
index 00000000..02479b60
--- /dev/null
+++ b/app/src/main/java/org/fairscan/app/ui/screens/library/LibraryViewModel.kt
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2025-2026 The FairScan authors
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation, either version 3 of the License, or (at your option)
+ * any later version.
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+package org.fairscan.app.ui.screens.library
+
+import android.net.Uri
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import org.fairscan.app.data.DocumentLibrary
+import org.fairscan.app.data.LibraryDocument
+import org.fairscan.app.data.Logger
+
+data class LibraryUiState(
+ val documents: List = emptyList(),
+ val loading: Boolean = true,
+ val failed: Boolean = false,
+)
+
+class LibraryViewModel(private val library: DocumentLibrary, private val logger: Logger) : ViewModel() {
+ private val _uiState = MutableStateFlow(LibraryUiState())
+ val uiState = _uiState.asStateFlow()
+ private var refreshJob: Job? = null
+
+ fun refresh() {
+ refreshJob?.cancel()
+ refreshJob = viewModelScope.launch {
+ _uiState.update { it.copy(loading = true, failed = false) }
+ try {
+ _uiState.value = LibraryUiState(documents = library.documents(), loading = false)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ logger.e("Library", "Cannot load saved documents", e)
+ _uiState.update { it.copy(loading = false, failed = true) }
+ }
+ }
+ }
+
+ fun importDocuments(uris: List) {
+ if (uris.isEmpty()) return
+ performLibraryAction("Cannot import selected documents") {
+ library.importDocuments(uris)
+ }
+ }
+
+ fun deleteDocuments(documents: List) {
+ if (documents.isEmpty()) return
+ performLibraryAction("Cannot delete selected documents") {
+ library.deleteDocuments(documents)
+ }
+ }
+
+ fun deleteDocument(document: LibraryDocument) = deleteDocuments(listOf(document))
+
+ fun renameDocument(document: LibraryDocument, name: String) {
+ performLibraryAction("Cannot rename document") {
+ library.renameDocument(document, name)
+ }
+ }
+
+ private fun performLibraryAction(message: String, action: suspend () -> Unit) {
+ refreshJob?.cancel()
+ refreshJob = viewModelScope.launch {
+ _uiState.update { it.copy(loading = true, failed = false) }
+ try {
+ action()
+ _uiState.value = LibraryUiState(documents = library.documents(), loading = false)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ logger.e("Library", message, e)
+ _uiState.update { it.copy(loading = false, failed = true) }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/fairscan/app/ui/screens/library/PdfViewport.kt b/app/src/main/java/org/fairscan/app/ui/screens/library/PdfViewport.kt
new file mode 100644
index 00000000..b4fb4b60
--- /dev/null
+++ b/app/src/main/java/org/fairscan/app/ui/screens/library/PdfViewport.kt
@@ -0,0 +1,27 @@
+package org.fairscan.app.ui.screens.library
+
+/** Document-space to viewport-space transform, shared by every page. */
+internal data class PdfViewport(val zoom: Float = 1f, val x: Float = 0f, val y: Float = 0f) {
+ fun transform(
+ factor: Float, focusX: Float, focusY: Float, panX: Float, panY: Float,
+ viewportWidth: Float, viewportHeight: Float, documentHeight: Float,
+ ): PdfViewport {
+ val nextZoom = (zoom * factor).coerceIn(0.25f, 8f)
+ val ratio = nextZoom / zoom
+ return PdfViewport(nextZoom,
+ focusX - (focusX - x) * ratio + panX,
+ focusY - (focusY - y) * ratio + panY,
+ ).bounded(viewportWidth, viewportHeight, documentHeight)
+ }
+
+ fun bounded(width: Float, height: Float, documentHeight: Float): PdfViewport {
+ val scaledWidth = width * zoom
+ val scaledHeight = documentHeight * zoom
+ return copy(
+ x = if (scaledWidth <= width) (width - scaledWidth) / 2f
+ else x.coerceIn(width - scaledWidth, 0f),
+ y = if (scaledHeight <= height) (height - scaledHeight) / 2f
+ else y.coerceIn(height - scaledHeight, 0f),
+ )
+ }
+}
From 07f8306dadcc31149c6b46f474378df17ceb4525 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Yakup=20Furkan=20Fidanc=C4=B1?=
<44572717+fidanci@users.noreply.github.com>
Date: Sun, 11 Oct 2026 03:46:13 +0300
Subject: [PATCH 07/11] Add library interface strings
---
app/src/main/res/values/library_strings.xml | 35 +++++++++++++++++++++
1 file changed, 35 insertions(+)
create mode 100644 app/src/main/res/values/library_strings.xml
diff --git a/app/src/main/res/values/library_strings.xml b/app/src/main/res/values/library_strings.xml
new file mode 100644
index 00000000..cfed227a
--- /dev/null
+++ b/app/src/main/res/values/library_strings.xml
@@ -0,0 +1,35 @@
+
+
+
+ My documents
+ Documents/FairScan
+ Saved in Documents/FairScan on your phone
+ No saved documents yet
+ Tap the camera to scan your first document. Completed and shared scans will appear here.
+ Unfinished scan
+ Tap to continue scanning
+ Could not load your documents. Check storage access and try again.
+ Try again
+ Share %1$s
+ Save and return to documents
+ This document could not be opened.
+ Add documents
+ Select documents
+ %1$d selected
+ Cancel
+ Share selected documents
+ Delete selected documents
+ Delete documents?
+ Delete %1$d selected document(s)? This cannot be undone.
+ Delete
+ Options for %1$s
+ Rename
+ Save
+ File name
+ %1$d / %2$d
+ Zoom in
+ Zoom out
+ Fit page
+ Go to page
+
From 12234e882fc0ef75f61d66505a33a6ad82c821e7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Yakup=20Furkan=20Fidanc=C4=B1?=
<44572717+fidanci@users.noreply.github.com>
Date: Sun, 11 Oct 2026 03:46:28 +0300
Subject: [PATCH 08/11] Add Turkish library interface strings
---
.../main/res/values-tr/library_strings.xml | 33 +++++++++++++++++++
1 file changed, 33 insertions(+)
create mode 100644 app/src/main/res/values-tr/library_strings.xml
diff --git a/app/src/main/res/values-tr/library_strings.xml b/app/src/main/res/values-tr/library_strings.xml
new file mode 100644
index 00000000..1d1d85dc
--- /dev/null
+++ b/app/src/main/res/values-tr/library_strings.xml
@@ -0,0 +1,33 @@
+
+
+ Belgelerim
+ Belgeler/FairScan
+ Telefonunuzdaki Belgeler/FairScan klasörüne kaydedilir
+ Henüz kayıtlı belge yok
+ İlk belgenizi taramak için kamera düğmesine dokunun. Tamamladığınız ve paylaştığınız taramalar burada görünür.
+ Yarım kalan tarama
+ Taramaya devam etmek için dokunun
+ Belgeler yüklenemedi. Depolama erişimini kontrol edip tekrar deneyin.
+ Tekrar dene
+ %1$s belgesini paylaş
+ Kaydet ve belgelere dön
+ Bu belge açılamadı.
+ Belge ekle
+ Belgeleri seç
+ %1$d seçildi
+ Vazgeç
+ Seçilen belgeleri paylaş
+ Seçilen belgeleri sil
+ Belgeler silinsin mi?
+ Seçilen %1$d belge silinsin mi? Bu işlem geri alınamaz.
+ Sil
+ %1$s için seçenekler
+ Yeniden adlandır
+ Kaydet
+ Dosya adı
+ %1$d / %2$d
+ Yakınlaştır
+ Uzaklaştır
+ Sayfayı ekrana sığdır
+ Sayfaya git
+
From b6e9c783718fb3852bd42268e0031b583adf89db Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Yakup=20Furkan=20Fidanc=C4=B1?=
<44572717+fidanci@users.noreply.github.com>
Date: Sun, 11 Oct 2026 03:46:40 +0300
Subject: [PATCH 09/11] Test document filename handling
---
.../app/data/DocumentFilenamesTest.kt | 54 +++++++++++++++++++
1 file changed, 54 insertions(+)
create mode 100644 app/src/test/java/org/fairscan/app/data/DocumentFilenamesTest.kt
diff --git a/app/src/test/java/org/fairscan/app/data/DocumentFilenamesTest.kt b/app/src/test/java/org/fairscan/app/data/DocumentFilenamesTest.kt
new file mode 100644
index 00000000..3639f843
--- /dev/null
+++ b/app/src/test/java/org/fairscan/app/data/DocumentFilenamesTest.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2025-2026 The FairScan authors
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation, either version 3 of the License, or (at your option)
+ * any later version.
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+package org.fairscan.app.data
+
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+
+class DocumentFilenamesTest {
+ @get:Rule val temp = TemporaryFolder()
+
+ @Test fun sameNameNeverOverwritesAnExistingDocument() {
+ val original = temp.newFile("scan.pdf").apply { writeText("original document") }
+ val second = availableDocumentFile(temp.root, "scan.pdf").apply { writeText("second document") }
+ val third = availableDocumentFile(temp.root, "scan.pdf")
+ assertThat(second.name).isEqualTo("scan (1).pdf")
+ assertThat(third.name).isEqualTo("scan (2).pdf")
+ assertThat(original).hasContent("original document")
+ assertThat(second).hasContent("second document")
+ }
+
+ @Test fun filenameCannotEscapeTheLibraryFolder() {
+ for (name in listOf("../../secret.pdf", "..\\..\\secret.pdf", "/outside.pdf", "C:\\outside.pdf")) {
+ assertThat(availableDocumentFile(temp.root, name).canonicalFile.parentFile)
+ .isEqualTo(temp.root.canonicalFile)
+ }
+ }
+
+ @Test fun emptyAndHiddenNamesHaveAVisibleFallback() {
+ assertThat(safeDocumentName(" ... ")).isEqualTo("FairScan")
+ assertThat(safeDocumentName(" ")).isEqualTo("FairScan")
+ }
+
+ @Test fun unicodeNamesKeepTheirExtensionAndFitTheFilesystemLimit() {
+ val name = safeDocumentName("\uD83D\uDCC4".repeat(100) + ".pdf")
+ assertThat(name).endsWith(".pdf")
+ assertThat(name.toByteArray(Charsets.UTF_8).size).isLessThan(255)
+ assertThat(safeDocumentName(name)).isEqualTo(name)
+ assertThat(name).doesNotContain("\uFFFD")
+ }
+}
From 72b4cf497d2fef1e00be4662a908023fa18b8b8b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Yakup=20Furkan=20Fidanc=C4=B1?=
<44572717+fidanci@users.noreply.github.com>
Date: Sun, 11 Oct 2026 03:46:50 +0300
Subject: [PATCH 10/11] Test saved document navigation
---
.../org/fairscan/app/ui/NavigationTest.kt | 22 ++++++++++++++-----
1 file changed, 17 insertions(+), 5 deletions(-)
diff --git a/app/src/test/java/org/fairscan/app/ui/NavigationTest.kt b/app/src/test/java/org/fairscan/app/ui/NavigationTest.kt
index 302532af..911379ce 100644
--- a/app/src/test/java/org/fairscan/app/ui/NavigationTest.kt
+++ b/app/src/test/java/org/fairscan/app/ui/NavigationTest.kt
@@ -16,6 +16,7 @@ package org.fairscan.app.ui
import org.assertj.core.api.Assertions.assertThat
import org.fairscan.app.ui.Screen.Main.Camera
+import org.fairscan.app.ui.Screen.Main.Home
import org.fairscan.app.ui.Screen.Main.Document
import org.fairscan.app.ui.Screen.Main.Export
import org.fairscan.app.ui.Screen.Overlay.About
@@ -27,13 +28,14 @@ class NavigationTest {
@Test
fun empty_ScreenStack() {
val empty = NavigationState.initial()
- assertThat(empty.current).isEqualTo(Camera)
+ assertThat(empty.current).isEqualTo(Home)
assertThat(empty.navigateBack()).isEqualTo(empty)
}
@Test
fun navigate_between_fixed_screens() {
- val atCamera = NavigationState.initial()
+ val home = NavigationState.initial()
+ val atCamera = home.navigateTo(Camera)
val atDocument = atCamera.navigateTo(Document())
val atExport = atCamera.navigateTo(Export)
@@ -45,7 +47,7 @@ class NavigationTest {
assertThat(atDocument.navigateTo(Export)).isEqualTo(atExport)
assertThat(atDocument.navigateTo(Camera)).isEqualTo(atCamera)
- assertThat(atCamera.navigateBack()).isEqualTo(atCamera)
+ assertThat(atCamera.navigateBack()).isEqualTo(home)
assertThat(atDocument.navigateBack()).isEqualTo(atCamera)
assertThat(atExport.navigateBack()).isEqualTo(atCamera)
}
@@ -70,8 +72,18 @@ class NavigationTest {
@Test
fun external_call() {
- val initial = NavigationState.initial()
+ val initial = NavigationState.initial(externalScan = true)
assertThat(initial.current).isEqualTo(Camera)
assertThat(initial.navigateBack().current).isEqualTo(Camera)
}
-}
\ No newline at end of file
+
+ @Test
+ fun library_document_returns_to_home() {
+ val home = NavigationState.initial()
+ val viewer = Screen.Main.LibraryDocument("content://document/1", "scan.pdf", "application/pdf")
+ val atViewer = home.navigateTo(viewer)
+
+ assertThat(atViewer.current).isEqualTo(viewer)
+ assertThat(atViewer.navigateBack()).isEqualTo(home)
+ }
+}
From dc0a1ba2e21c35eecec3aed96a2c8bdd4c316e28 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Yakup=20Furkan=20Fidanc=C4=B1?=
<44572717+fidanci@users.noreply.github.com>
Date: Sun, 11 Oct 2026 03:47:01 +0300
Subject: [PATCH 11/11] Test PDF viewport zoom and panning
---
.../app/ui/screens/library/PdfViewportTest.kt | 40 +++++++++++++++++++
1 file changed, 40 insertions(+)
create mode 100644 app/src/test/java/org/fairscan/app/ui/screens/library/PdfViewportTest.kt
diff --git a/app/src/test/java/org/fairscan/app/ui/screens/library/PdfViewportTest.kt b/app/src/test/java/org/fairscan/app/ui/screens/library/PdfViewportTest.kt
new file mode 100644
index 00000000..d03acd74
--- /dev/null
+++ b/app/src/test/java/org/fairscan/app/ui/screens/library/PdfViewportTest.kt
@@ -0,0 +1,40 @@
+package org.fairscan.app.ui.screens.library
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class PdfViewportTest {
+ @Test fun pinchKeepsTheDocumentPointUnderTheFingers() {
+ val before = PdfViewport(2f, -200f, -800f)
+ val after = before.transform(1.4f, 130f, 270f, 0f, 0f, 500f, 800f, 3000f)
+ assertEquals((130f - before.x) / before.zoom, (130f - after.x) / after.zoom, 0.001f)
+ assertEquals((270f - before.y) / before.zoom, (270f - after.y) / after.zoom, 0.001f)
+ }
+
+ @Test fun oneFingerMovesBothAxesWithoutChangingScale() {
+ val after = PdfViewport(2f, -200f, -800f)
+ .transform(1f, 100f, 100f, 35f, -70f, 500f, 800f, 3000f)
+ assertEquals(-165f, after.x, 0.001f)
+ assertEquals(-870f, after.y, 0.001f)
+ assertEquals(2f, after.zoom, 0.001f)
+ }
+
+ @Test fun dragCanReachTheBottomOfAMultiPageDocument() {
+ val after = PdfViewport(2f).transform(1f, 0f, 0f, -9000f, -9000f, 500f, 800f, 3000f)
+ assertEquals(-500f, after.x, 0.001f)
+ assertEquals(-5200f, after.y, 0.001f)
+ }
+
+ @Test fun smallDocumentIsCenteredAndWidthFitResetsHorizontalOffset() {
+ val after = PdfViewport(1f, -200f, -400f).bounded(500f, 800f, 600f)
+ assertEquals(0f, after.x, 0.001f)
+ assertEquals(100f, after.y, 0.001f)
+ }
+
+ @Test fun movingPinchCenterAlsoPansTheAnchoredPoint() {
+ val before = PdfViewport(2f, -200f, -800f)
+ val after = before.transform(1.5f, 150f, 250f, 20f, 30f, 500f, 800f, 3000f)
+ assertEquals((150f - before.x) / before.zoom, (170f - after.x) / after.zoom, 0.001f)
+ assertEquals((250f - before.y) / before.zoom, (280f - after.y) / after.zoom, 0.001f)
+ }
+}