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) + } +}