fix scanner + pdf creator

This commit is contained in:
m
2026-09-13 22:31:22 +02:00
parent 8a4cd44ef8
commit 696395eb68
7 changed files with 164 additions and 3 deletions
@@ -101,7 +101,9 @@ class FileRepository @Inject constructor(
lastModified = input.lastModified, lastModified = input.lastModified,
ownerId = ownerId ?: existing?.ownerId, ownerId = ownerId ?: existing?.ownerId,
syncStatus = existing?.syncStatus ?: input.syncStatus ?: FileStatus.LOCAL, 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, addedAt = existing?.addedAt ?: now,
updatedAt = now, updatedAt = now,
) )
@@ -198,4 +200,6 @@ data class SaveFileInput(
val resourceId: String? = null, val resourceId: String? = null,
/** Fallback de sync_status pour une nouvelle ligne (défaut local). */ /** Fallback de sync_status pour une nouvelle ligne (défaut local). */
val syncStatus: String? = null, val syncStatus: String? = null,
/** Le fichier arrive déjà « traité » (ex. scan) ou à traiter dans la review. */
val processed: Boolean = false,
) )
@@ -55,6 +55,7 @@ class PdfBuilderEngine @Inject constructor() {
val ok = when (item) { val ok = when (item) {
is PdfBuilderItem.FileItem -> renderFileItem(context, document, item.file) is PdfBuilderItem.FileItem -> renderFileItem(context, document, item.file)
is PdfBuilderItem.NoteItem -> renderNoteItem(document, item.body) is PdfBuilderItem.NoteItem -> renderNoteItem(document, item.body)
is PdfBuilderItem.FilePathItem -> appendImageFile(document, item.file)
} }
if (!ok) failed += item.id if (!ok) failed += item.id
onProgress((index + 1).toFloat() / total) onProgress((index + 1).toFloat() / total)
@@ -162,6 +163,38 @@ class PdfBuilderEngine @Inject constructor() {
return true 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 // ------------------------------------------------------------------ Texte
private suspend fun appendTextFile( private suspend fun appendTextFile(
@@ -3,10 +3,12 @@ package com.vaultdrop.mobile.ui.pdfbuilder
import com.vaultdrop.mobile.data.local.entity.FileEntity import com.vaultdrop.mobile.data.local.entity.FileEntity
import com.vaultdrop.mobile.domain.FileCategory import com.vaultdrop.mobile.domain.FileCategory
import com.vaultdrop.mobile.ui.components.categoryValue import com.vaultdrop.mobile.ui.components.categoryValue
import java.io.File
/** /**
* Item de l'assemblage PDF : un fichier lisible localement (PDF / image / texte) * 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. * 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 { sealed interface PdfBuilderItem {
val id: String val id: String
@@ -22,6 +24,12 @@ sealed interface PdfBuilderItem {
override val id: String, override val id: String,
val body: String, val body: String,
) : PdfBuilderItem ) : 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. */ /** Fichier pouvant entrer dans un assemblage : copie locale + catégorie lisible. */
@@ -22,6 +22,7 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp 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.MergeType
import androidx.compose.material.icons.filled.TextSnippet import androidx.compose.material.icons.filled.TextSnippet
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@@ -411,6 +412,12 @@ private fun BuilderItemRow(
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp), 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)) Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
@@ -419,6 +426,7 @@ private fun BuilderItemRow(
text = when (item) { text = when (item) {
is PdfBuilderItem.FileItem -> item.file.name is PdfBuilderItem.FileItem -> item.file.name
is PdfBuilderItem.NoteItem -> stringResource(R.string.pdf_builder_note) is PdfBuilderItem.NoteItem -> stringResource(R.string.pdf_builder_note)
is PdfBuilderItem.FilePathItem -> item.file.name
}, },
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
maxLines = 1, maxLines = 1,
@@ -437,6 +445,8 @@ private fun BuilderItemRow(
is PdfBuilderItem.NoteItem -> item.body is PdfBuilderItem.NoteItem -> item.body
.replace('\n', ' ') .replace('\n', ' ')
.take(80) .take(80)
is PdfBuilderItem.FilePathItem -> formatSize(item.file.length())
}, },
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = if (isFailed) MaterialTheme.colorScheme.error color = if (isFailed) MaterialTheme.colorScheme.error
@@ -24,6 +24,8 @@ import com.vaultdrop.mobile.features.scan.CornerGeometry
import com.vaultdrop.mobile.features.scan.ScanImageProcessor import com.vaultdrop.mobile.features.scan.ScanImageProcessor
import com.vaultdrop.mobile.features.scan.ScanQuad import com.vaultdrop.mobile.features.scan.ScanQuad
import com.vaultdrop.mobile.features.scan.ScanRenderMode 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.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -64,6 +66,7 @@ class ScanViewModel @Inject constructor(
private val folderRepository: FolderRepository, private val folderRepository: FolderRepository,
private val defaultRootStore: DefaultRootStore, private val defaultRootStore: DefaultRootStore,
private val imageProcessor: ScanImageProcessor, private val imageProcessor: ScanImageProcessor,
private val pdfEngine: PdfBuilderEngine,
) : ViewModel() { ) : ViewModel() {
private val _session = MutableStateFlow<ScanSessionEntity?>(null) private val _session = MutableStateFlow<ScanSessionEntity?>(null)
@@ -286,6 +289,8 @@ class ScanViewModel @Inject constructor(
mimeType = "image/jpeg", mimeType = "image/jpeg",
lastModified = file.lastModified(), lastModified = file.lastModified(),
exists = true, exists = true,
// Déjà traité au scan : ne pas remonter dans la review.
processed = true,
), ),
folderResourceId = folderId, 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() { fun onExportedHandled() {
_exported.value = false _exported.value = false
} }
@@ -16,6 +16,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.PictureAsPdf
import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -100,6 +101,14 @@ fun SessionStage(
Icon(imageVector = Icons.Filled.Add, contentDescription = null) Icon(imageVector = Icons.Filled.Add, contentDescription = null)
Text(stringResource(R.string.scan_add_page), modifier = Modifier.padding(start = 4.dp)) 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( Button(
onClick = viewModel::export, onClick = viewModel::export,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -201,6 +201,7 @@
<string name="scan_move_down">Descendre</string> <string name="scan_move_down">Descendre</string>
<string name="scan_empty">Aucune page pour le moment.\nCapture ta première page.</string> <string name="scan_empty">Aucune page pour le moment.\nCapture ta première page.</string>
<string name="scan_export">Exporter vers le dossier VaultDrop</string> <string name="scan_export">Exporter vers le dossier VaultDrop</string>
<string name="scan_export_pdf">Sauvegarder en PDF</string>
<string name="scan_exporting">Export en cours…</string> <string name="scan_exporting">Export en cours…</string>
<string name="scan_abandon">Abandonner</string> <string name="scan_abandon">Abandonner</string>
<string name="scan_abandon_title">Abandonner le scan ?</string> <string name="scan_abandon_title">Abandonner le scan ?</string>