fix scanner + pdf creator
This commit is contained in:
+5
-1
@@ -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,
|
||||||
)
|
)
|
||||||
+33
@@ -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(
|
||||||
|
|||||||
+10
-2
@@ -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. */
|
||||||
|
|||||||
+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.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>
|
||||||
|
|||||||
Reference in New Issue
Block a user