fix scanner + pdf creator
This commit is contained in:
+5
-1
@@ -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,
|
||||
)
|
||||
+33
@@ -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(
|
||||
|
||||
+10
-2
@@ -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. */
|
||||
|
||||
+10
@@ -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
|
||||
|
||||
@@ -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<ScanSessionEntity?>(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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -201,6 +201,7 @@
|
||||
<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_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_abandon">Abandonner</string>
|
||||
<string name="scan_abandon_title">Abandonner le scan ?</string>
|
||||
|
||||
Reference in New Issue
Block a user