From 696395eb686508e870137941ba4e07791e80e169 Mon Sep 17 00:00:00 2001 From: m Date: Sun, 13 Sep 2026 22:31:22 +0200 Subject: [PATCH] fix scanner + pdf creator --- .../mobile/data/repository/FileRepository.kt | 6 +- .../mobile/ui/pdfbuilder/PdfBuilderEngine.kt | 33 +++++++ .../mobile/ui/pdfbuilder/PdfBuilderItem.kt | 12 ++- .../mobile/ui/pdfbuilder/PdfBuilderScreen.kt | 10 ++ .../vaultdrop/mobile/ui/scan/ScanViewModel.kt | 96 +++++++++++++++++++ .../vaultdrop/mobile/ui/scan/SessionStage.kt | 9 ++ .../app/src/main/res/values/strings.xml | 1 + 7 files changed, 164 insertions(+), 3 deletions(-) diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt index ff46ba1..bd10280 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FileRepository.kt @@ -101,7 +101,9 @@ class FileRepository @Inject constructor( lastModified = input.lastModified, ownerId = ownerId ?: existing?.ownerId, syncStatus = existing?.syncStatus ?: input.syncStatus ?: FileStatus.LOCAL, - processed = existing?.processed ?: false, + // Une ligne existante liée reste à son état ; une nouvelle ligne est + // « traitée » si la source l'a demandé (ex. scan), sinon à traiter. + processed = existing?.processed ?: input.processed, addedAt = existing?.addedAt ?: now, updatedAt = now, ) @@ -198,4 +200,6 @@ data class SaveFileInput( val resourceId: String? = null, /** Fallback de sync_status pour une nouvelle ligne (défaut local). */ val syncStatus: String? = null, + /** Le fichier arrive déjà « traité » (ex. scan) ou à traiter dans la review. */ + val processed: Boolean = false, ) \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderEngine.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderEngine.kt index 3e6fbac..a0a770a 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderEngine.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderEngine.kt @@ -55,6 +55,7 @@ class PdfBuilderEngine @Inject constructor() { val ok = when (item) { is PdfBuilderItem.FileItem -> renderFileItem(context, document, item.file) is PdfBuilderItem.NoteItem -> renderNoteItem(document, item.body) + is PdfBuilderItem.FilePathItem -> appendImageFile(document, item.file) } if (!ok) failed += item.id onProgress((index + 1).toFloat() / total) @@ -162,6 +163,38 @@ class PdfBuilderEngine @Inject constructor() { return true } + /** Page image brute sur disque (ex. JPEG scanné), A4 selon l'orientation. */ + private fun appendImageFile( + document: PdfDocument, + file: File, + ): Boolean { + val sample = runCatching { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(file.absolutePath, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return false + var sampler = 1 + while (bounds.outWidth / sampler > MAX_IMAGE_PX || + bounds.outHeight / sampler > MAX_IMAGE_PX + ) { + sampler *= 2 + } + sampler + }.getOrNull() ?: return false + + val bitmap = runCatching { + val options = BitmapFactory.Options().apply { inSampleSize = sample } + BitmapFactory.decodeFile(file.absolutePath, options) + }.getOrNull() ?: return false + + val landscape = bitmap.width > bitmap.height + val pageWidth = if (landscape) A4_H.toInt() else A4_W.toInt() + val pageHeight = if (landscape) A4_W.toInt() else A4_H.toInt() + val dst = fitRect(bitmap.width, bitmap.height, pageWidth.toFloat(), pageHeight.toFloat()) + appendBitmapPage(document, bitmap, pageWidth, pageHeight, dst) + bitmap.recycle() + return true + } + // ------------------------------------------------------------------ Texte private suspend fun appendTextFile( diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderItem.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderItem.kt index 6c35eee..8e38053 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderItem.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderItem.kt @@ -3,10 +3,12 @@ package com.vaultdrop.mobile.ui.pdfbuilder import com.vaultdrop.mobile.data.local.entity.FileEntity import com.vaultdrop.mobile.domain.FileCategory import com.vaultdrop.mobile.ui.components.categoryValue +import java.io.File /** - * Item de l'assemblage PDF : un fichier lisible localement (PDF / image / texte) - * ou une note de texte libre. L'ordre de la liste est l'ordre du PDF final. + * Item de l'assemblage PDF : un fichier lisible localement (PDF / image / texte), + * une note de texte libre, ou une image brute sur disque (pages scannées). + * L'ordre de la liste est l'ordre du PDF final. */ sealed interface PdfBuilderItem { val id: String @@ -22,6 +24,12 @@ sealed interface PdfBuilderItem { override val id: String, val body: String, ) : PdfBuilderItem + + /** Image JPEG sur disque (ex. une page du scanner) déjà mise à l'endroit. */ + data class FilePathItem( + val file: File, + override val id: String, + ) : PdfBuilderItem } /** Fichier pouvant entrer dans un assemblage : copie locale + catégorie lisible. */ diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderScreen.kt index 568f574..d05b15b 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/pdfbuilder/PdfBuilderScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.MergeType import androidx.compose.material.icons.filled.TextSnippet import androidx.compose.material3.AlertDialog @@ -411,6 +412,12 @@ private fun BuilderItemRow( tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(24.dp), ) + is PdfBuilderItem.FilePathItem -> Icon( + imageVector = Icons.Filled.Image, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) } Spacer(Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { @@ -419,6 +426,7 @@ private fun BuilderItemRow( text = when (item) { is PdfBuilderItem.FileItem -> item.file.name is PdfBuilderItem.NoteItem -> stringResource(R.string.pdf_builder_note) + is PdfBuilderItem.FilePathItem -> item.file.name }, style = MaterialTheme.typography.bodyLarge, maxLines = 1, @@ -437,6 +445,8 @@ private fun BuilderItemRow( is PdfBuilderItem.NoteItem -> item.body .replace('\n', ' ') .take(80) + + is PdfBuilderItem.FilePathItem -> formatSize(item.file.length()) }, style = MaterialTheme.typography.labelMedium, color = if (isFailed) MaterialTheme.colorScheme.error diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/scan/ScanViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/scan/ScanViewModel.kt index 167c35d..db1d790 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/scan/ScanViewModel.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/scan/ScanViewModel.kt @@ -24,6 +24,8 @@ import com.vaultdrop.mobile.features.scan.CornerGeometry import com.vaultdrop.mobile.features.scan.ScanImageProcessor import com.vaultdrop.mobile.features.scan.ScanQuad import com.vaultdrop.mobile.features.scan.ScanRenderMode +import com.vaultdrop.mobile.ui.pdfbuilder.PdfBuilderEngine +import com.vaultdrop.mobile.ui.pdfbuilder.PdfBuilderItem import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope @@ -64,6 +66,7 @@ class ScanViewModel @Inject constructor( private val folderRepository: FolderRepository, private val defaultRootStore: DefaultRootStore, private val imageProcessor: ScanImageProcessor, + private val pdfEngine: PdfBuilderEngine, ) : ViewModel() { private val _session = MutableStateFlow(null) @@ -286,6 +289,8 @@ class ScanViewModel @Inject constructor( mimeType = "image/jpeg", lastModified = file.lastModified(), exists = true, + // Déjà traité au scan : ne pas remonter dans la review. + processed = true, ), folderResourceId = folderId, ) @@ -305,6 +310,97 @@ class ScanViewModel @Inject constructor( } } + /** + * Exporte toutes les pages en une seule PDF dans le dossier racine + * (PdfBuilderEngine + SAF), ré-importée dans Room comme fichier déjà traité. + * Équivaut à l'export JPEG qui marque pages + session terminées. + */ + fun exportPdf() { + val currentSession = _session.value ?: return + viewModelScope.launch(Dispatchers.IO) { + if (_busy.value) return@launch + _busy.value = true + try { + val rootFolderId = currentSession.rootFolderId ?: defaultRootStore.get() + ?: error("no default root") + val rootFolder = folderRepository.getFolder(rootFolderId) + ?: error("default root not found: $rootFolderId") + val rootUri = SafUris.toDocumentUri(rootFolder.uri) + ?: error("default root has no uri") + + val pages = scanRepository.getPages(currentSession.id) + if (pages.isEmpty()) return@launch + + val stamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val displayName = "scan_$stamp.pdf" + val cacheFile = File(context.cacheDir, displayName) + val items = pages.map { page -> + PdfBuilderItem.FilePathItem( + file = File(page.tempUri), + id = page.resourceId, + ) + } + val result = pdfEngine.buildPdf( + context = context, + items = items, + outputFile = cacheFile, + onProgress = {}, + ) + if (cacheFile.length() == 0L) error("pdf build produced empty output") + + val createdUri = SafWriter.createDocument( + resolver = context.contentResolver, + treeUri = rootUri.toString(), + mimeType = "application/pdf", + displayName = displayName, + ) ?: error("cannot create document in default root") + SafWriter.copyInto(createdUri, cacheFile, context.contentResolver) + runCatching { + context.contentResolver.takePersistableUriPermission( + createdUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + }.onFailure { Timber.w(it, "persistable uri permission absent") } + + val folderId = SafWriter.resolveTargetFolder(folderRepository, createdUri) + ?: rootFolderId + val name = SafWriter.displayName(context.contentResolver, createdUri) + ?: displayName + fileRepository.saveLocalFile( + input = SaveFileInput( + uri = createdUri.toString(), + name = name, + extension = "pdf", + size = cacheFile.length(), + mimeType = "application/pdf", + lastModified = System.currentTimeMillis(), + exists = true, + processed = true, + ), + folderResourceId = folderId, + ) + cacheFile.delete() + + if (result.failedIds.isNotEmpty()) { + Timber.w("scan pdf: %d page(s) not embedded", result.failedIds.size) + } + pages.forEach { page -> + scanRepository.markPageExported(page.copy(status = ScanPageStatus.EXPORTED)) + } + scanRepository.finishSession(currentSession.id) + _exported.value = true + } catch (e: Exception) { + Timber.e(e, "scan pdf export failed") + _message.value = when (e) { + is SecurityException -> context.getString(R.string.pdf_builder_save_error_permission) + else -> context.getString(R.string.scan_error_export) + } + } finally { + _busy.value = false + } + } + } + fun onExportedHandled() { _exported.value = false } diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/scan/SessionStage.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/scan/SessionStage.kt index fc6416f..eab19f4 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/scan/SessionStage.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/scan/SessionStage.kt @@ -16,6 +16,7 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.PictureAsPdf import androidx.compose.material.icons.filled.Save import androidx.compose.material3.Button import androidx.compose.material3.Card @@ -100,6 +101,14 @@ fun SessionStage( Icon(imageVector = Icons.Filled.Add, contentDescription = null) Text(stringResource(R.string.scan_add_page), modifier = Modifier.padding(start = 4.dp)) } + OutlinedButton( + onClick = viewModel::exportPdf, + modifier = Modifier.weight(1f), + enabled = pages.isNotEmpty(), + ) { + Icon(imageVector = Icons.Filled.PictureAsPdf, contentDescription = null) + Text(stringResource(R.string.scan_export_pdf), modifier = Modifier.padding(start = 4.dp)) + } Button( onClick = viewModel::export, modifier = Modifier.weight(1f), diff --git a/mobile-kotlin/app/src/main/res/values/strings.xml b/mobile-kotlin/app/src/main/res/values/strings.xml index 96ac86b..b7b8660 100644 --- a/mobile-kotlin/app/src/main/res/values/strings.xml +++ b/mobile-kotlin/app/src/main/res/values/strings.xml @@ -201,6 +201,7 @@ Descendre Aucune page pour le moment.\nCapture ta première page. Exporter vers le dossier VaultDrop + Sauvegarder en PDF Export en cours… Abandonner Abandonner le scan ?