add document scanner

This commit is contained in:
m
2026-09-13 21:48:49 +02:00
parent 3ef6ac7dcb
commit 612e3b0eff
14 changed files with 2182 additions and 0 deletions
@@ -0,0 +1,64 @@
package com.vaultdrop.mobile.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import com.vaultdrop.mobile.data.local.entity.ScanPageEntity
import com.vaultdrop.mobile.data.local.entity.ScanSessionEntity
import kotlinx.coroutines.flow.Flow
/** Persistance des sessions de scan (appareil photo) — local uniquement. */
@Dao
interface ScanDao {
@Insert
suspend fun insertSession(session: ScanSessionEntity): Long
@Query("SELECT * FROM scan_sessions WHERE id = :sessionId")
suspend fun getSession(sessionId: Long): ScanSessionEntity?
@Query("SELECT * FROM scan_sessions WHERE status = 'active' ORDER BY id DESC LIMIT 1")
suspend fun getActiveSession(): ScanSessionEntity?
@Query("UPDATE scan_sessions SET status = :status, updated_at = :now WHERE id = :sessionId")
suspend fun updateStatus(sessionId: Long, status: String, now: Long)
@Query("UPDATE scan_sessions SET root_folder_id = :rootFolderId, updated_at = :now WHERE id = :sessionId")
suspend fun updateRootFolder(sessionId: Long, rootFolderId: String, now: Long)
@Insert
suspend fun insertPage(page: ScanPageEntity): Long
@Update
suspend fun updatePage(page: ScanPageEntity)
@Query("SELECT * FROM scan_pages WHERE session_id = :sessionId ORDER BY sort_order ASC")
fun observePages(sessionId: Long): Flow<List<ScanPageEntity>>
@Query("SELECT * FROM scan_pages WHERE session_id = :sessionId ORDER BY sort_order ASC")
suspend fun getPages(sessionId: Long): List<ScanPageEntity>
@Query("SELECT * FROM scan_pages WHERE id = :pageId")
suspend fun getPage(pageId: Long): ScanPageEntity?
@Query("DELETE FROM scan_pages WHERE id = :pageId AND session_id = :sessionId")
suspend fun deletePage(pageId: Long, sessionId: Long)
@Query(
"""
UPDATE scan_pages SET sort_order = CASE id
WHEN :firstId THEN :secondOrder
WHEN :secondId THEN :firstOrder
ELSE sort_order END
WHERE session_id = :sessionId
""",
)
suspend fun swapOrder(sessionId: Long, firstId: Long, secondId: Long, firstOrder: Int, secondOrder: Int)
@Query("SELECT COUNT(*) FROM scan_pages WHERE session_id = :sessionId")
fun observePageCount(sessionId: Long): Flow<Int>
@Query("SELECT COUNT(*) FROM scan_pages WHERE session_id = :sessionId")
suspend fun pageCount(sessionId: Long): Int
}
@@ -0,0 +1,59 @@
package com.vaultdrop.mobile.data.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Page capturée et validée d'une session de scan.
*
* `temp_uri` = chemin du fichier JPEG final (croppé + redressé) dans
* `filesDir/scan_sessions/{session.id}/` ; `corners_json` mémorise le quadrilatère
* pour ré-éditer la page. L'export SAF se fait à la validation de session.
*/
@Entity(
tableName = "scan_pages",
foreignKeys = [
ForeignKey(
entity = ScanSessionEntity::class,
parentColumns = ["id"],
childColumns = ["session_id"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [
Index(value = ["resource_id"], unique = true),
Index(value = ["session_id"]),
Index(value = ["sort_order"]),
],
)
data class ScanPageEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0L,
@ColumnInfo(name = "resource_id")
val resourceId: String,
@ColumnInfo(name = "session_id")
val sessionId: Long,
@ColumnInfo(name = "temp_uri")
val tempUri: String,
@ColumnInfo(name = "corners_json")
val cornersJson: String,
@ColumnInfo(name = "width")
val width: Int,
@ColumnInfo(name = "height")
val height: Int,
@ColumnInfo(name = "sort_order")
val sortOrder: Int,
@ColumnInfo(name = "status")
val status: String = ScanPageStatus.PENDING,
@ColumnInfo(name = "created_at")
val createdAt: Long,
)
/** Statuts d'une page de scan. */
object ScanPageStatus {
const val PENDING = "pending"
const val EXPORTED = "exported"
}
@@ -0,0 +1,41 @@
package com.vaultdrop.mobile.data.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Session de scan multi-pages (appareil photo). Persistée pour survivre au
* process death : les pages validées sont enregistrées avant export.
*
* `root_folder_id` = dossier racine SAF cible de l'export (optionnel, NULL si
* aucun root n'est encore défini au moment du scan).
*/
@Entity(
tableName = "scan_sessions",
indices = [
Index(value = ["resource_id"], unique = true),
],
)
data class ScanSessionEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0L,
@ColumnInfo(name = "resource_id")
val resourceId: String,
@ColumnInfo(name = "root_folder_id")
val rootFolderId: String? = null,
@ColumnInfo(name = "status")
val status: String = ScanSessionStatus.ACTIVE,
@ColumnInfo(name = "created_at")
val createdAt: Long,
@ColumnInfo(name = "updated_at")
val updatedAt: Long,
)
/** Statuts d'une session de scan. */
object ScanSessionStatus {
const val ACTIVE = "active"
const val DONE = "done"
const val ABANDONED = "abandoned"
}
@@ -0,0 +1,108 @@
package com.vaultdrop.mobile.data.repository
import androidx.room.withTransaction
import com.vaultdrop.mobile.data.local.AppDatabase
import com.vaultdrop.mobile.data.local.dao.ScanDao
import com.vaultdrop.mobile.data.local.entity.ScanPageEntity
import com.vaultdrop.mobile.data.local.entity.ScanPageStatus
import com.vaultdrop.mobile.data.local.entity.ScanSessionEntity
import com.vaultdrop.mobile.data.local.entity.ScanSessionStatus
import com.vaultdrop.mobile.domain.GenerateId
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Persistance Room des sessions de scan (appareil photo). Une seule session
* active à la fois : on reprend la dernière si une session a été interrompue
* (process death), sinon on en crée une nouvelle.
*/
@Singleton
class ScanRepository @Inject constructor(
private val appDatabase: AppDatabase,
private val scanDao: ScanDao,
private val generateId: GenerateId,
) {
suspend fun getOrCreateActiveSession(rootFolderId: String?): ScanSessionEntity {
scanDao.getActiveSession()?.let { return it }
val now = System.currentTimeMillis()
val sessionId = scanDao.insertSession(
ScanSessionEntity(
resourceId = generateId.newResourceId(),
rootFolderId = rootFolderId,
status = ScanSessionStatus.ACTIVE,
createdAt = now,
updatedAt = now,
),
)
return checkNotNull(scanDao.getSession(sessionId))
}
suspend fun setRootFolder(sessionId: Long, rootFolderId: String) {
appDatabase.withTransaction {
scanDao.updateRootFolder(sessionId, rootFolderId, System.currentTimeMillis())
}
}
fun observePages(sessionId: Long): Flow<List<ScanPageEntity>> = scanDao.observePages(sessionId)
fun observePageCount(sessionId: Long): Flow<Int> = scanDao.observePageCount(sessionId)
suspend fun getPages(sessionId: Long): List<ScanPageEntity> = scanDao.getPages(sessionId)
suspend fun pageCount(sessionId: Long): Int = scanDao.pageCount(sessionId)
suspend fun addPage(
session: ScanSessionEntity,
tempUri: String,
cornersJson: String,
width: Int,
height: Int,
): ScanPageEntity {
val now = System.currentTimeMillis()
val order = scanDao.pageCount(session.id)
val page = ScanPageEntity(
resourceId = generateId.newResourceId(),
sessionId = session.id,
tempUri = tempUri,
cornersJson = cornersJson,
width = width,
height = height,
sortOrder = order,
status = ScanPageStatus.PENDING,
createdAt = now,
)
scanDao.insertPage(page)
return page
}
suspend fun deletePage(pageId: Long, sessionId: Long) {
appDatabase.withTransaction {
scanDao.deletePage(pageId, sessionId)
}
}
suspend fun movePage(sessionId: Long, pages: List<ScanPageEntity>, pageId: Long, delta: Int) {
val index = pages.indexOfFirst { it.id == pageId }
val target = index + delta
if (index < 0 || target !in pages.indices) return
val first = pages[index]
val second = pages[target]
appDatabase.withTransaction {
scanDao.swapOrder(sessionId, first.id, second.id, second.sortOrder, first.sortOrder)
}
}
suspend fun markPageExported(page: ScanPageEntity) {
scanDao.updatePage(page.copy(status = ScanPageStatus.EXPORTED))
}
suspend fun finishSession(sessionId: Long) {
scanDao.updateStatus(sessionId, ScanSessionStatus.DONE, System.currentTimeMillis())
}
suspend fun abandonSession(sessionId: Long) {
scanDao.updateStatus(sessionId, ScanSessionStatus.ABANDONED, System.currentTimeMillis())
}
}
@@ -0,0 +1,75 @@
package com.vaultdrop.mobile.features.saf
import android.content.ContentResolver
import android.net.Uri
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import com.vaultdrop.mobile.data.repository.FolderRepository
import java.io.File
/**
* Écriture de fichiers créés par l'app (PDF builder, scans) dans l'arborescence
* SAF importée — jamais MediaStore ni permission externe.
*/
object SafWriter {
/**
* Crée un document dans le dossier racine d'un arbre SAF. `treeUri` est
* l'URI *tree* telle que stockée (`folders.uri`) ; convertie en URI
* *document* avant `createDocument`.
*/
fun createDocument(
resolver: ContentResolver,
treeUri: String,
mimeType: String,
displayName: String,
): Uri? {
val documentUri = SafUris.toDocumentUri(treeUri) ?: return null
return DocumentsContract.createDocument(resolver, documentUri, mimeType, displayName)
}
fun copyInto(uri: Uri, source: File, resolver: ContentResolver) {
val target = resolver.openOutputStream(uri) ?: error("cannot open output stream")
target.use { out ->
source.inputStream().use { input ->
input.copyTo(out)
}
}
}
fun displayName(resolver: ContentResolver, uri: Uri): String? = runCatching {
resolver.query(
uri,
arrayOf(OpenableColumns.DISPLAY_NAME),
null,
null,
null,
)?.use { cursor ->
if (cursor.moveToFirst()) cursor.getString(0) else null
}
}.getOrNull()
/**
* Associe l'URI créé au dossier (racine ou sous-dossier) dont le documentId
* est le préfixe — plus long match gagne. Largest match; null si aucun dossier
* connu ne contient l'URI (préfixe de treeId).
*/
suspend fun resolveTargetFolder(folderRepository: FolderRepository, uri: Uri): String? {
val documentId = runCatching { DocumentsContract.getDocumentId(uri) }.getOrNull()
?: return null
return folderRepository.getAll()
.filter { it.uri != null }
.mapNotNull { folder ->
val treeDocId = runCatching {
DocumentsContract.getDocumentId(Uri.parse(folder.uri))
}.getOrNull()
if (treeDocId != null && documentId.startsWith("$treeDocId/")) {
folder.resourceId to treeDocId.length
} else {
null
}
}
.maxByOrNull { it.second }
?.first
}
}
@@ -0,0 +1,46 @@
package com.vaultdrop.mobile.features.scan
import android.graphics.PointF
import kotlin.math.abs
import kotlin.math.hypot
import kotlin.math.max
import kotlin.math.roundToInt
/**
* Géométrie pure-Kotlin du quadrilatère de scan (testable sans OpenCV) :
* ordre des 4 coins, aire, dimensions du document redressé.
*/
object CornerGeometry {
fun distance(a: PointF, b: PointF): Float =
hypot((a.x - b.x).toDouble(), (a.y - b.y).toDouble()).toFloat()
/** Ordonne un quad convexe quelconque en [TL, TR, BR, BL]. */
fun orderPoints(points: List<PointF>): List<PointF> {
require(points.size == 4) { "quadrilateral required" }
val tl = points.minByOrNull { it.x + it.y }!!
val br = points.maxByOrNull { it.x + it.y }!!
val tr = points.minByOrNull { it.y - it.x }!!
val bl = points.maxByOrNull { it.y - it.x }!!
return listOf(tl, tr, br, bl)
}
/** Aire du polygone (formule du lacet) — coins ordonnés ou non. */
fun area(points: List<PointF>): Float {
var sum = 0f
for (i in points.indices) {
val a = points[i]
val b = points[(i + 1) % points.size]
sum += a.x * b.y - b.x * a.y
}
return abs(sum) / 2f
}
/** Dimensions du document redressé (moyenne des côtés opposés). */
fun outputSize(points: List<PointF>): Pair<Int, Int> {
val (tl, tr, br, bl) = orderPoints(points)
val w = max(distance(tl, tr), distance(bl, br))
val h = max(distance(tl, bl), distance(tr, br))
return w.roundToInt() to h.roundToInt()
}
}
@@ -0,0 +1,223 @@
package com.vaultdrop.mobile.features.scan
import android.graphics.Bitmap
import android.graphics.PointF
import org.opencv.android.OpenCVLoader
import org.opencv.android.Utils
import org.opencv.core.Mat
import org.opencv.core.MatOfPoint
import org.opencv.core.MatOfPoint2f
import org.opencv.core.Point
import org.opencv.core.Size
import org.opencv.imgproc.Imgproc
import java.util.ArrayList
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
/** Quadrilatère détecté (4 coins [TL, TR, BR, BL]) en pixels de l'image source. */
data class ScanQuad(val points: List<PointF>)
/** Modes de rendu du document numérisé. */
enum class ScanRenderMode { COLOR, GRAYSCALE, HIGH_CONTRAST }
/**
* Pipeline de numérisation de document via OpenCV (AAR bundlé, hors GMS) :
* détection des bords (Canny) + quad convexe (approxPolyDP) et redressement
* perspective (`warpPerspective`) appliqué au moment du shutter seulement.
*
* La détection tourne sur une image réduite pour la performance ; le warp
* s'applique sur l'image capturée en pleine résolution.
*/
@Singleton
class ScanImageProcessor @Inject constructor() {
@Volatile
private var loaded = false
fun isAvailable(): Boolean {
if (!loaded) {
loaded = OpenCVLoader.initLocal()
}
return loaded
}
/**
* Détecte le plus grand quad document dans l'image. Retourne les 4 coins
* ordonnés dans les coordonnées de `bitmap` (NULL si aucun contour fiable).
*/
fun detectCorners(bitmap: Bitmap): ScanQuad? {
if (!isAvailable()) return null
val maxSide = max(bitmap.width, bitmap.height)
val scale = min(1f, MAX_DETECT_SIDE.toFloat() / maxSide)
val frame = if (scale < 1f) {
Bitmap.createScaledBitmap(
bitmap,
(bitmap.width * scale).roundToInt(),
(bitmap.height * scale).roundToInt(),
true,
)
} else {
bitmap
}
val src = Mat()
Utils.bitmapToMat(frame, src)
val toRelease = ArrayList<Mat>()
try {
val gray = Mat()
toRelease += gray
Imgproc.cvtColor(src, gray, Imgproc.COLOR_RGBA2GRAY)
val corners = findBestQuad(gray, frame.width, frame.height) ?: return null
val ordered = CornerGeometry.orderPoints(corners.map { PointF(it.x.toFloat(), it.y.toFloat()) })
return ScanQuad(ordered.map { PointF(it.x / scale, it.y / scale) })
} finally {
toRelease.add(src)
toRelease.forEach { it.release() }
if (scale < 1f) frame.recycle()
}
}
/**
* Redresse (`warpPerspective`) selon `quad` puis applique le mode de rendu.
* Sortie plafonnée à [MAX_OUTPUT_SIDE] px sur le grand côté.
*/
fun process(bitmap: Bitmap, quad: ScanQuad, mode: ScanRenderMode): Bitmap {
if (!isAvailable()) return bitmap
val (targetW, targetH) = CornerGeometry.outputSize(quad.points)
val maxSide = max(targetW, targetH)
val outScale = min(1f, MAX_OUTPUT_SIDE.toFloat() / maxSide)
val outW = (targetW * outScale).roundToInt().coerceAtLeast(1)
val outH = (targetH * outScale).roundToInt().coerceAtLeast(1)
val src = Mat()
Utils.bitmapToMat(bitmap, src)
val toRelease = ArrayList<Mat>()
try {
val srcPts = MatOfPoint2f()
srcPts.fromArray(*quad.points.map { Point(it.x.toDouble(), it.y.toDouble()) }.toTypedArray())
toRelease += srcPts
val dstPts = MatOfPoint2f(
Point(0.0, 0.0),
Point((outW - 1).toDouble(), 0.0),
Point((outW - 1).toDouble(), (outH - 1).toDouble()),
Point(0.0, (outH - 1).toDouble()),
)
toRelease += dstPts
val transform = Imgproc.getPerspectiveTransform(srcPts, dstPts)
toRelease += transform
val warped = Mat()
toRelease += warped
Imgproc.warpPerspective(src, warped, transform, Size(outW.toDouble(), outH.toDouble()))
val rendered = render(warped, mode, toRelease)
val out = Bitmap.createBitmap(outW, outH, Bitmap.Config.ARGB_8888)
Utils.matToBitmap(rendered, out)
return out
} finally {
toRelease.add(src)
toRelease.forEach { it.release() }
}
}
private fun findBestQuad(gray: Mat, width: Int, height: Int): List<Point>? {
val blur = Mat()
val edges = Mat()
val hierarchy = Mat()
val contours = ArrayList<MatOfPoint>()
var best: List<Point>? = null
var bestArea = 0.0
try {
Imgproc.GaussianBlur(gray, blur, Size(5.0, 5.0), 0.0)
Imgproc.Canny(blur, edges, 75.0, 200.0)
val kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(3.0, 3.0))
try {
Imgproc.dilate(edges, edges, kernel)
} finally {
kernel.release()
}
Imgproc.findContours(edges, contours, hierarchy, Imgproc.RETR_LIST, Imgproc.CHAIN_APPROX_SIMPLE)
val minArea = width * height * MIN_RELATIVE_AREA
for (c in contours) {
val approx = MatOfPoint2f()
try {
val c2f = MatOfPoint2f(*c.toArray())
try {
Imgproc.approxPolyDP(c2f, approx, APPROX_EPSILON * Imgproc.arcLength(c2f, true), true)
} finally {
c2f.release()
}
val pts = approx.toArray()
if (pts.size == 4) {
val convex = MatOfPoint(*pts)
try {
if (Imgproc.isContourConvex(convex)) {
val area = Imgproc.contourArea(convex)
if (area > minArea && area > bestArea) {
bestArea = area
best = pts.toList()
}
}
} finally {
convex.release()
}
}
} finally {
approx.release()
c.release()
}
}
return best
} finally {
blur.release()
edges.release()
hierarchy.release()
}
}
private fun render(warped: Mat, mode: ScanRenderMode, toRelease: MutableList<Mat>): Mat {
if (mode == ScanRenderMode.COLOR) return warped
val gray = Mat()
toRelease += gray
Imgproc.cvtColor(warped, gray, Imgproc.COLOR_RGBA2GRAY)
if (mode == ScanRenderMode.GRAYSCALE) return gray
// HIGH_CONTRAST : CLAHE puis seuil adaptatif — rendu « scanner » net.
val claheOut = Mat()
toRelease += claheOut
val clahe = Imgproc.createCLAHE(CLAHE_CLIP, Size(CLAHE_TILE, CLAHE_TILE))
try {
clahe.apply(gray, claheOut)
} finally {
clahe.clear()
}
val thresh = Mat()
toRelease += thresh
Imgproc.adaptiveThreshold(
claheOut,
thresh,
255.0,
Imgproc.ADAPTIVE_THRESH_GAUSSIAN_C,
Imgproc.THRESH_BINARY,
ADAPTIVE_BLOCK,
ADAPTIVE_C,
)
return thresh
}
private companion object {
const val MAX_DETECT_SIDE = 900
const val MAX_OUTPUT_SIDE = 2400
const val MIN_RELATIVE_AREA = 0.15f
const val APPROX_EPSILON = 0.02f
const val CLAHE_CLIP = 3.0
const val CLAHE_TILE = 8.0
const val ADAPTIVE_BLOCK = 15
const val ADAPTIVE_C = 10.0
}
}
@@ -0,0 +1,250 @@
package com.vaultdrop.mobile.ui.scan
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Matrix
import android.graphics.PointF
import android.util.Size
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.material.icons.Icons
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vaultdrop.mobile.R
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.Executors
/**
* Étape « viewfinder » : Preview CameraX + analyse continue pour l'overlay du
* document détecté, bouton de capture (full-res JPEG → [ScanViewModel.onCapture]).
*
* Liaison/déliaison CameraX une seule fois par entrée dans l'étape
* (DisposableEffect) ; l'analyse tourne sur un executor dédié et est coupée au
* dispose pour ne pas consummer la batterie quand on croppe.
*/
@Composable
fun CameraStage(
viewModel: ScanViewModel,
pageCount: Int,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val analysisSize by viewModel.analysisImageSize.collectAsStateWithLifecycle()
val liveQuad by viewModel.liveQuad.collectAsStateWithLifecycle()
var viewSize by remember { mutableStateOf(IntSize(0, 0)) }
var overlayQuad by remember { mutableStateOf<List<PointF>?>(null) }
LaunchedEffect(liveQuad, analysisSize, viewSize) {
val quad = liveQuad ?: return@LaunchedEffect
val (iw, ih) = analysisSize
if (iw <= 0 || ih <= 0 || viewSize.width <= 0 || viewSize.height <= 0) return@LaunchedEffect
overlayQuad = ScanGeometry.mapToVisibleView(quad.points, iw, ih, viewSize.width, viewSize.height)
}
var previewView by remember { mutableStateOf<PreviewView?>(null) }
val imageCapture = remember {
ImageCapture.Builder()
.setTargetResolution(Size(1920, 1080))
.build()
}
Box(modifier = modifier) {
AndroidView(
factory = { ctx ->
PreviewView(ctx).apply {
implementationMode = PreviewView.ImplementationMode.PERFORMANCE
}.also { view ->
view.addOnLayoutChangeListener { v, left, top, right, bottom, _, _, _, _ ->
viewSize = IntSize(right - left, bottom - top)
}
}
},
update = { previewView = it },
modifier = Modifier.fillMaxSize(),
)
val quad = overlayQuad
if (quad != null && quad.size == 4) {
Canvas(modifier = Modifier.fillMaxSize()) {
val path = Path().apply {
moveTo(quad[0].x, quad[0].y)
quad.drop(1).forEach { lineTo(it.x, it.y) }
close()
}
drawPath(path = path, color = Color.White, style = Stroke(width = 4.dp.toPx()))
quad.forEach { point ->
drawCircle(
color = Color.White,
radius = 7.dp.toPx(),
center = Offset(point.x, point.y),
style = Stroke(width = 3.dp.toPx()),
)
}
}
}
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(bottom = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (pageCount > 0) {
OutlinedButton(onClick = viewModel::openSession) {
Text(stringResource(R.string.scan_pages_count, pageCount))
}
}
FloatingActionButton(
onClick = { capture(context, imageCapture, viewModel) },
modifier = Modifier.padding(top = 16.dp),
) {
Icon(
imageVector = Icons.Filled.CameraAlt,
contentDescription = stringResource(R.string.scan_capture),
)
}
}
}
DisposableEffect(lifecycleOwner, previewView) {
val pv = previewView ?: return@DisposableEffect onDispose {}
val executor = ContextCompat.getMainExecutor(context)
val analysisExecutor = Executors.newSingleThreadExecutor()
val providerFuture = ProcessCameraProvider.getInstance(context)
val disposed = java.util.concurrent.atomic.AtomicBoolean(false)
val bindingListener = Runnable {
if (disposed.get()) return@Runnable
val provider = providerFuture.get()
val preview = Preview.Builder().build().also { it.setSurfaceProvider(pv.surfaceProvider) }
val analysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
analysis.setAnalyzer(analysisExecutor) { proxy ->
try {
val unrotated = proxy.toBitmap()
val degrees = proxy.imageInfo.rotationDegrees
val bitmap = if (degrees != 0) rotate(unrotated, degrees) else unrotated
try {
viewModel.processAnalysisFrame(bitmap.width, bitmap.height, bitmap)
} finally {
if (bitmap !== unrotated) unrotated.recycle()
bitmap.recycle()
}
} catch (_: Exception) {
// Une frame illisible ne doit pas stopper l'analyse.
} finally {
proxy.close()
}
}
provider.unbindAll()
provider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
analysis,
imageCapture,
)
}
providerFuture.addListener(bindingListener, executor)
onDispose {
disposed.set(true)
analysisExecutor.shutdown()
runCatching { providerFuture.get().unbindAll() }
}
}
}
private fun rotate(bitmap: Bitmap, degrees: Int): Bitmap {
val matrix = Matrix().apply {
when (degrees) {
90 -> setRotate(90f)
180 -> setRotate(180f)
270 -> setRotate(270f)
}
}
return bitmap.run {
Bitmap.createBitmap(this, 0, 0, width, height, matrix, true)
}
}
private fun capture(context: Context, imageCapture: ImageCapture, viewModel: ScanViewModel) {
val file = File(context.cacheDir, "scan_capture_${System.currentTimeMillis()}.jpg")
imageCapture.takePicture(
ContextCompat.getMainExecutor(context),
object : ImageCapture.OnImageCapturedCallback() {
override fun onCaptureSuccess(image: ImageProxy) {
try {
// toBitmap() renvoie les pixels dans le sens capteur :
// on tourne explicitement avec rotationDegrees (comme le
// viewfinder) pour obtenir un fichier « à l'endroit »,
// indépendamment de l'EXIF que ImageCapture n'écrit pas
// de façon fiable.
val raw = image.toBitmap()
val degrees = image.imageInfo.rotationDegrees
val rotated = if (degrees != 0) rotate(raw, degrees) else raw
if (rotated !== raw) raw.recycle()
FileOutputStream(file).use { out ->
rotated.compress(Bitmap.CompressFormat.JPEG, SCAN_JPEG_QUALITY, out)
}
rotated.recycle()
viewModel.onCapture(file)
} catch (e: Exception) {
file.delete()
viewModel.onCaptureError(context.getString(R.string.scan_error_capture))
} finally {
image.close()
}
}
override fun onError(exception: ImageCaptureException) {
file.delete()
viewModel.onCaptureError(context.getString(R.string.scan_error_capture))
}
},
)
}
private const val SCAN_JPEG_QUALITY = 92
@@ -0,0 +1,318 @@
package com.vaultdrop.mobile.ui.scan
import android.graphics.PointF
import android.graphics.RectF
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vaultdrop.mobile.R
import com.vaultdrop.mobile.features.scan.ScanQuad
import com.vaultdrop.mobile.features.scan.ScanRenderMode
import kotlin.math.hypot
import kotlin.math.roundToInt
/** Mode d'interaction pendant le drag du quad de recadrage. */
private sealed interface Interaction {
data object None : Interaction
data object Move : Interaction
data class Corner(val index: Int) : Interaction
}
/**
* Étape « recadrage » : affiche la capture zoomée sur la zone détectée (marge
* incluse) avec le quad superposé.
*
* Interactions :
* - glisser un coin (poignée) pour le déplacer ;
* - glisser à l'intérieur du quad pour déplacer la zone entière ;
* - si aucune zone n'a été détectée à la capture, un cadre par défaut est
* proposé avec ses 4 points libres.
*
* La validation lance le warp full-res via [ScanViewModel.confirmPage].
*/
@Composable
fun CropStage(
viewModel: ScanViewModel,
modifier: Modifier = Modifier,
) {
val draft by viewModel.draft.collectAsStateWithLifecycle()
val pageDraft = draft ?: return
val renderMode by viewModel.renderMode.collectAsStateWithLifecycle()
val bitmap = remember(pageDraft.bitmap) { pageDraft.bitmap }
val imageWidth = bitmap.width
val imageHeight = bitmap.height
var boxSize by remember { mutableStateOf(IntSize.Zero) }
var interaction by remember { mutableStateOf<Interaction>(Interaction.None) }
var moveStart by remember { mutableStateOf<MoveStart?>(null) }
val currentQuad = rememberUpdatedState(pageDraft.quad)
val touchRadiusPx = with(LocalDensity.current) { 28.dp.toPx() }
// Zoom initial : bounding box du quad détecté + marge, figé à l'entrée du
// crop (les gestes modifient le quad sans ré-zoomer derrière le doigt).
val viewport = remember(imageWidth, imageHeight, pageDraft.bitmap) {
initialViewport(pageDraft.quad, imageWidth, imageHeight)
}
fun clampToImage(p: PointF) =
PointF(
p.x.coerceIn(0f, imageWidth.toFloat()),
p.y.coerceIn(0f, imageHeight.toFloat()),
)
// Le bitmap du brouillon est dessiné par ce Canvas pendant toute la durée
// du crop. On ne le recycle pas dans le ViewModel (confirmPage/retake) :
// `recycle()` y provoquerait une course entre le thread IO et le draw
// Compose courant (`Canvas: trying to use a recycled bitmap` sur le device).
// Il est libéré quand CropStage quitte la composition — après que plus
// aucune frame ne peut le dessiner.
DisposableEffect(pageDraft.bitmap) {
onDispose { pageDraft.bitmap.recycle() }
}
Column(
modifier = modifier.fillMaxSize(),
) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.onSizeChanged { boxSize = IntSize(it.width, it.height) }
.pointerInput(viewport, boxSize) {
fun screenToImage(pos: Offset): PointF =
ScanGeometry.mapViewToRect(
PointF(pos.x, pos.y),
viewport,
boxSize.width,
boxSize.height,
)
detectDragGestures(
onDragStart = { offset ->
val screenPts = ScanGeometry.mapPointsToView(
currentQuad.value.points,
viewport,
boxSize.width,
boxSize.height,
)
interaction = hitTest(offset, screenPts, touchRadiusPx)
if (interaction is Interaction.Move) {
moveStart = MoveStart(currentQuad.value.points, screenToImage(offset))
} else {
moveStart = null
}
},
onDragEnd = {
interaction = Interaction.None
moveStart = null
},
onDragCancel = {
interaction = Interaction.None
moveStart = null
},
onDrag = { change, _ ->
val current = interaction
when (current) {
is Interaction.None -> Unit
is Interaction.Corner -> {
change.consume()
val img = clampToImage(screenToImage(change.position))
val pts = currentQuad.value.points.toMutableList()
pts[current.index] = img
viewModel.updateQuad(ScanQuad(pts))
}
is Interaction.Move -> {
moveStart?.let { start ->
change.consume()
val img = clampToImage(screenToImage(change.position))
val dx = img.x - start.imagePoint.x
val dy = img.y - start.imagePoint.y
viewModel.updateQuad(
ScanQuad(
start.quadAtStart.map { p ->
clampToImage(PointF(p.x + dx, p.y + dy))
},
),
)
}
}
}
},
)
},
) {
val quadScreen = remember(currentQuad.value, viewport, boxSize) {
ScanGeometry.mapPointsToView(
currentQuad.value.points,
viewport,
boxSize.width,
boxSize.height,
)
}
Canvas(modifier = Modifier.fillMaxSize()) {
val dst = ScanGeometry.viewportFit(viewport, boxSize.width, boxSize.height)
drawImage(
image = bitmap.asImageBitmap(),
srcOffset = IntOffset(viewport.left.roundToInt(), viewport.top.roundToInt()),
srcSize = IntSize(viewport.width().roundToInt(), viewport.height().roundToInt()),
dstOffset = IntOffset(dst.left.roundToInt(), dst.top.roundToInt()),
dstSize = IntSize(dst.width().roundToInt(), dst.height().roundToInt()),
)
if (quadScreen.size == 4) {
val path = Path().apply {
moveTo(quadScreen[0].x, quadScreen[0].y)
quadScreen.drop(1).forEach { lineTo(it.x, it.y) }
close()
}
drawPath(
path = path,
color = Color.White,
style = Stroke(width = 4.dp.toPx()),
)
quadScreen.forEach { point ->
drawCircle(
color = Color.White,
radius = HANDLE_RADIUS_DP.dp.toPx(),
center = Offset(point.x, point.y),
style = Stroke(width = 6.dp.toPx()),
)
drawCircle(
color = HANDLE_COLOR,
radius = HANDLE_RADIUS_DP.dp.toPx(),
center = Offset(point.x, point.y),
)
}
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
ScanRenderMode.entries.forEach { mode ->
FilterChip(
selected = renderMode == mode,
onClick = { viewModel.updateRenderMode(mode) },
label = { Text(renderModeLabel(mode)) },
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = viewModel::retake) {
Text(stringResource(R.string.scan_retake))
}
Button(onClick = viewModel::confirmPage) {
Text(stringResource(R.string.scan_confirm_page))
}
}
}
}
/** Snapshot pour « déplacer le quad entier » : points au départ + point image. */
private data class MoveStart(val quadAtStart: List<PointF>, val imagePoint: PointF)
/** Bounding box du quad + marge, clampée aux bornes de l'image. */
private fun initialViewport(quad: ScanQuad, imageWidth: Int, imageHeight: Int): RectF {
var minX = Float.MAX_VALUE
var minY = Float.MAX_VALUE
var maxX = -Float.MAX_VALUE
var maxY = -Float.MAX_VALUE
quad.points.forEach { p ->
minX = minOf(minX, p.x)
minY = minOf(minY, p.y)
maxX = maxOf(maxX, p.x)
maxY = maxOf(maxY, p.y)
}
val w = (maxX - minX).coerceAtLeast(1f)
val h = (maxY - minY).coerceAtLeast(1f)
val padX = w * VIEWPORT_MARGIN
val padY = h * VIEWPORT_MARGIN
return RectF(
(minX - padX).coerceIn(0f, imageWidth.toFloat()),
(minY - padY).coerceIn(0f, imageHeight.toFloat()),
(maxX + padX).coerceIn(0f, imageWidth.toFloat()),
(maxY + padY).coerceIn(0f, imageHeight.toFloat()),
)
}
private fun hitTest(offset: Offset, quadScreen: List<PointF>, touchRadiusPx: Float): Interaction {
if (quadScreen.size != 4) return Interaction.None
quadScreen.forEachIndexed { index, point ->
if (hypot(offset.x - point.x, offset.y - point.y) <= touchRadiusPx) {
return Interaction.Corner(index)
}
}
return if (pointInQuad(offset, quadScreen)) Interaction.Move else Interaction.None
}
/** Test point-dans-polygone convexe (coins ordonnés [TL, TR, BR, BL]). */
private fun pointInQuad(point: Offset, quad: List<PointF>): Boolean {
if (quad.size != 4) return false
var sign = 0f
for (i in 0 until 4) {
val a = quad[i]
val b = quad[(i + 1) % 4]
val cross = (b.x - a.x) * (point.y - a.y) - (b.y - a.y) * (point.x - a.x)
if (cross == 0f) continue
val s = if (cross > 0f) 1f else -1f
if (sign == 0f) sign = s
else if (s != sign) return false
}
return true
}
@Composable
private fun renderModeLabel(mode: ScanRenderMode): String = when (mode) {
ScanRenderMode.COLOR -> stringResource(R.string.scan_render_color)
ScanRenderMode.GRAYSCALE -> stringResource(R.string.scan_render_gray)
ScanRenderMode.HIGH_CONTRAST -> stringResource(R.string.scan_render_high)
}
private val HANDLE_COLOR = Color(0xFF1E88E5)
private const val VIEWPORT_MARGIN = 0.08f
private const val HANDLE_RADIUS_DP = 12f
@@ -0,0 +1,169 @@
package com.vaultdrop.mobile.ui.scan
import android.Manifest.permission.CAMERA
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts.RequestPermission
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.Text
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.TextAlign
import androidx.compose.material3.TextButton
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vaultdrop.mobile.R
/**
* Route unique du scanner — gère la permission caméra, le basculement entre
* les 3 étapes (CAMERA/CROP/SESSION) et le retour arrière contextuel.
*
* Les composables d'étape n'accèdent pas à la navigation ; elles ne modifient
* que l'état interne de [ScanViewModel].
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ScanFlowScreen(
onBack: () -> Unit,
) {
val viewModel: ScanViewModel = hiltViewModel()
val stage by viewModel.stage.collectAsStateWithLifecycle()
val busy by viewModel.busy.collectAsStateWithLifecycle()
val error by viewModel.message.collectAsStateWithLifecycle()
val exported by viewModel.exported.collectAsStateWithLifecycle()
val pages by viewModel.pages.collectAsStateWithLifecycle()
val context = LocalContext.current
var hasCameraPermission by remember {
mutableStateOf(ContextCompat.checkSelfPermission(context, CAMERA) == PackageManager.PERMISSION_GRANTED)
}
val launcher = rememberLauncherForActivityResult(RequestPermission()) { granted ->
hasCameraPermission = granted
}
// Retour écran précédent à l'export (observé une seule fois).
LaunchedEffect(exported) {
if (exported) {
viewModel.onExportedHandled()
onBack()
}
}
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(error) {
error?.let {
snackbarHostState.showSnackbar(it, duration = SnackbarDuration.Short)
viewModel.dismissMessage()
}
}
// Retour arrière contextuel (géré par BackHandler dans le content).
val title = when (stage) {
ScanStage.CAMERA -> stringResource(R.string.scan_title)
ScanStage.CROP -> stringResource(R.string.scan_crop_title)
ScanStage.SESSION -> stringResource(R.string.scan_session_title)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = {
when (stage) {
ScanStage.CAMERA -> { viewModel.abandon(); onBack() }
ScanStage.CROP -> viewModel.retake()
ScanStage.SESSION -> viewModel.openCamera()
}
}) {
Icon(
imageVector = androidx.compose.material.icons.Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back),
)
}
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
) {
when (stage) {
ScanStage.CAMERA -> {
if (hasCameraPermission) {
CameraStage(
viewModel = viewModel,
pageCount = pages.size,
modifier = Modifier.fillMaxSize(),
)
} else {
CameraRationale(
onGrant = { launcher.launch(CAMERA) },
modifier = Modifier.fillMaxSize(),
)
}
}
ScanStage.CROP -> CropStage(
viewModel = viewModel,
modifier = Modifier.fillMaxSize(),
)
ScanStage.SESSION -> SessionStage(
viewModel = viewModel,
pages = pages,
modifier = Modifier.fillMaxSize(),
)
}
if (busy) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center),
)
}
}
}
}
@Composable
private fun CameraRationale(
onGrant: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier, contentAlignment = Alignment.Center) {
androidx.compose.foundation.layout.Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = stringResource(R.string.scan_permission_rationale),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 32.dp),
)
TextButton(onClick = onGrant, modifier = Modifier.padding(top = 16.dp)) {
Text(stringResource(R.string.scan_permission_grant))
}
}
}
}
@@ -0,0 +1,142 @@
package com.vaultdrop.mobile.ui.scan
import android.graphics.PointF
import android.graphics.RectF
import kotlin.math.max
import kotlin.math.min
/**
* Projections coordonnées image ⇄ écran pour le scanner.
*
* - FIT (image complète) : toute l'image est visible (letterbox).
* - VIEWPORT (sous-région) : zoom sur un rectangle de l'image (zone détectée),
* affiché en FIT par rapport à la vue — utilisé pour le recadrage interactif.
* - VISIBLE (caméra) : région réellement affichée par une vue FILL_CENTER
* (crop centré) — overlay du viewfinder, qui suit le flux pixel à pixel.
*/
object ScanGeometry {
fun fitRect(imageWidth: Int, imageHeight: Int, viewWidth: Int, viewHeight: Int): RectF =
viewportFit(RectF(0f, 0f, imageWidth.toFloat(), imageHeight.toFloat()), viewWidth, viewHeight)
/** FIT d'une sous-région de l'image (`sourceRect`) dans la vue. */
fun viewportFit(sourceRect: RectF, viewWidth: Int, viewHeight: Int): RectF {
if (sourceRect.width() <= 0f || sourceRect.height() <= 0f) {
return RectF(0f, 0f, viewWidth.toFloat(), viewHeight.toFloat())
}
if (viewWidth <= 0 || viewHeight <= 0) {
return RectF(0f, 0f, viewWidth.toFloat(), viewHeight.toFloat())
}
val scale = min(viewWidth.toFloat() / sourceRect.width(), viewHeight.toFloat() / sourceRect.height())
val shownW = sourceRect.width() * scale
val shownH = sourceRect.height() * scale
return RectF(
(viewWidth - shownW) / 2f,
(viewHeight - shownH) / 2f,
(viewWidth + shownW) / 2f,
(viewHeight + shownH) / 2f,
)
}
/** Mappe un point *image* → coordonnées écran dans la région [sourceRect]. */
fun mapRectToView(
point: PointF,
sourceRect: RectF,
viewWidth: Int,
viewHeight: Int,
): PointF {
val dst = viewportFit(sourceRect, viewWidth, viewHeight)
if (dst.width() <= 0f || dst.height() <= 0f) return point
return PointF(
dst.left + (point.x - sourceRect.left) / sourceRect.width() * dst.width(),
dst.top + (point.y - sourceRect.top) / sourceRect.height() * dst.height(),
)
}
/** Inverse de [mapRectToView] : écran → point *image*. */
fun mapViewToRect(
point: PointF,
sourceRect: RectF,
viewWidth: Int,
viewHeight: Int,
): PointF {
val dst = viewportFit(sourceRect, viewWidth, viewHeight)
if (dst.width() <= 0f || dst.height() <= 0f) return point
return PointF(
sourceRect.left + (point.x - dst.left) / dst.width() * sourceRect.width(),
sourceRect.top + (point.y - dst.top) / dst.height() * sourceRect.height(),
)
}
/** Mappe des points image → coordonnées écran dans la région [sourceRect]. */
fun mapPointsToView(
points: List<PointF>,
sourceRect: RectF,
viewWidth: Int,
viewHeight: Int,
): List<PointF> = points.map { mapRectToView(it, sourceRect, viewWidth, viewHeight) }
/** Région *de l'image* visible dans une vue FILL_CENTER, en coordonnées image. */
fun visibleRect(imageWidth: Int, imageHeight: Int, viewWidth: Int, viewHeight: Int): RectF {
if (viewWidth <= 0 || viewHeight <= 0) {
return RectF(0f, 0f, imageWidth.toFloat(), imageHeight.toFloat())
}
val scale = max(viewWidth.toFloat() / imageWidth, viewHeight.toFloat() / imageHeight)
val shownW = viewWidth / scale
val shownH = viewHeight / scale
return RectF(
(imageWidth - shownW) / 2f,
(imageHeight - shownH) / 2f,
(imageWidth + shownW) / 2f,
(imageHeight + shownH) / 2f,
)
}
/** Mappe des points image → coordonnées écran dans la région VISIBLE (caméra). */
fun mapToVisibleView(
points: List<PointF>,
imageWidth: Int,
imageHeight: Int,
viewWidth: Int,
viewHeight: Int,
): List<PointF> {
val rect = visibleRect(imageWidth, imageHeight, viewWidth, viewHeight)
if (rect.width() <= 0f || rect.height() <= 0f) return points
return points.map { p ->
PointF(
(p.x - rect.left) / rect.width() * viewWidth,
(p.y - rect.top) / rect.height() * viewHeight,
)
}
}
// Raccourcis image-complète (tests et usage générique conservés).
fun mapToFitView(
points: List<PointF>,
imageWidth: Int,
imageHeight: Int,
viewWidth: Int,
viewHeight: Int,
): List<PointF> =
mapPointsToView(
points,
RectF(0f, 0f, imageWidth.toFloat(), imageHeight.toFloat()),
viewWidth,
viewHeight,
)
/** Inverse de la projection FIT (écran → image). */
fun mapViewToFitImage(
point: PointF,
imageWidth: Int,
imageHeight: Int,
viewWidth: Int,
viewHeight: Int,
): PointF =
mapViewToRect(
point,
RectF(0f, 0f, imageWidth.toFloat(), imageHeight.toFloat()),
viewWidth,
viewHeight,
)
}
@@ -0,0 +1,385 @@
package com.vaultdrop.mobile.ui.scan
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.graphics.PointF
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vaultdrop.mobile.R
import com.vaultdrop.mobile.data.local.entity.ScanPageEntity
import com.vaultdrop.mobile.data.local.entity.ScanPageStatus
import com.vaultdrop.mobile.data.local.entity.ScanSessionEntity
import com.vaultdrop.mobile.data.preferences.DefaultRootStore
import com.vaultdrop.mobile.data.repository.FileRepository
import com.vaultdrop.mobile.data.repository.FolderRepository
import com.vaultdrop.mobile.data.repository.SaveFileInput
import com.vaultdrop.mobile.data.repository.ScanRepository
import com.vaultdrop.mobile.features.saf.SafUris
import com.vaultdrop.mobile.features.saf.SafWriter
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 dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import javax.inject.Inject
import kotlin.math.max
import kotlin.math.min
/** Étapes de l'écran de scan — état interne (une seule route `scan`). */
enum class ScanStage { CAMERA, CROP, SESSION }
/** Brouillon d'une capture en cours de recadrage (mémoire uniquement). */
data class PageDraft(
val sourceFile: File,
val bitmap: Bitmap,
val quad: ScanQuad,
)
@HiltViewModel
class ScanViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val scanRepository: ScanRepository,
private val fileRepository: FileRepository,
private val folderRepository: FolderRepository,
private val defaultRootStore: DefaultRootStore,
private val imageProcessor: ScanImageProcessor,
) : ViewModel() {
private val _session = MutableStateFlow<ScanSessionEntity?>(null)
val session: StateFlow<ScanSessionEntity?> = _session.asStateFlow()
private val _stage = MutableStateFlow(ScanStage.CAMERA)
val stage: StateFlow<ScanStage> = _stage.asStateFlow()
private val _draft = MutableStateFlow<PageDraft?>(null)
val draft: StateFlow<PageDraft?> = _draft.asStateFlow()
/** Dernière détection en direct (overlay du viewfinder). */
private val _liveQuad = MutableStateFlow<ScanQuad?>(null)
val liveQuad: StateFlow<ScanQuad?> = _liveQuad.asStateFlow()
/** Dimensions (après rotation) de l'image analysée par le viewfinder — pour le mapping overlay. */
private val _analysisImageSize = MutableStateFlow(0 to 0)
val analysisImageSize: StateFlow<Pair<Int, Int>> = _analysisImageSize.asStateFlow()
private val _renderMode = MutableStateFlow(ScanRenderMode.COLOR)
val renderMode: StateFlow<ScanRenderMode> = _renderMode.asStateFlow()
private val _busy = MutableStateFlow(false)
val busy: StateFlow<Boolean> = _busy.asStateFlow()
private val _message = MutableStateFlow<String?>(null)
val message: StateFlow<String?> = _message.asStateFlow()
private val _exported = MutableStateFlow(false)
val exported: StateFlow<Boolean> = _exported.asStateFlow()
private val _pages = MutableStateFlow<List<ScanPageEntity>>(emptyList())
val pages: StateFlow<List<ScanPageEntity>> = _pages.asStateFlow()
init {
viewModelScope.launch {
val rootId = defaultRootStore.get()
val session = scanRepository.getOrCreateActiveSession(rootId)
_session.value = session
combine(
scanRepository.observePages(session.id),
scanRepository.observePageCount(session.id),
) { pageList, _ -> pageList }
.collect { _pages.value = it }
}
}
fun onDetected(quad: ScanQuad?) {
_liveQuad.value = quad
}
/** Exécuté sur le thread d'analyse : détection + publication pour l'overlay. */
fun processAnalysisFrame(width: Int, height: Int, bitmap: Bitmap) {
val quad = imageProcessor.detectCorners(bitmap)
_analysisImageSize.value = width to height
_liveQuad.value = quad
}
fun onCaptureError(message: String) {
_message.value = message
}
/** Capture terminée : décode, redétecte sur l'image pleine, passe au crop. */
fun onCapture(rawFile: File) {
val currentSession = _session.value
if (currentSession == null) {
rawFile.delete()
_message.value = context.getString(R.string.scan_error_session)
return
}
viewModelScope.launch(Dispatchers.IO) {
_busy.value = true
try {
val bitmap = decodeSampled(rawFile)
val detected = imageProcessor.detectCorners(bitmap)
?: ScanQuad(defaultQuad(bitmap.width, bitmap.height))
_draft.value = PageDraft(sourceFile = rawFile, bitmap = bitmap, quad = detected)
_stage.value = ScanStage.CROP
} catch (e: Exception) {
Timber.e(e, "capture processing failed")
rawFile.delete()
_message.value = context.getString(R.string.scan_error_capture)
} finally {
_busy.value = false
}
}
}
fun updateQuad(quad: ScanQuad) {
_draft.update { it?.copy(quad = quad) }
}
fun updateRenderMode(mode: ScanRenderMode) {
_renderMode.value = mode
}
/** Valide la page recadrée : warp + rendu → JPEG final + persistance Room. */
fun confirmPage() {
val currentSession = _session.value ?: return
val currentDraft = _draft.value ?: return
viewModelScope.launch(Dispatchers.IO) {
_busy.value = true
try {
val (targetW, targetH) = CornerGeometry.outputSize(currentDraft.quad.points)
val processed = imageProcessor.process(
currentDraft.bitmap,
currentDraft.quad,
_renderMode.value,
)
val order = scanRepository.pageCount(currentSession.id) + 1
val sessionDir = ensureSessionDir(currentSession.id)
val finalFile = File(sessionDir, "page_$order.jpg")
FileOutputStream(finalFile).use { out ->
processed.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out)
}
processed.recycle()
currentDraft.sourceFile.delete()
scanRepository.addPage(
session = currentSession,
tempUri = finalFile.absolutePath,
cornersJson = quadToString(currentDraft.quad),
width = targetW,
height = targetH,
)
_draft.value = null
_stage.value = ScanStage.CAMERA
} catch (e: Exception) {
Timber.e(e, "confirm page failed")
_message.value = context.getString(R.string.scan_error_page)
} finally {
_busy.value = false
}
}
}
/** Abandon du brouillon de capture (retour viewfinder). */
fun retake() {
_draft.value?.let { draft ->
draft.sourceFile.delete()
}
_draft.value = null
_stage.value = ScanStage.CAMERA
}
fun openSession() {
_stage.value = ScanStage.SESSION
}
fun openCamera() {
_stage.value = ScanStage.CAMERA
}
fun deletePage(page: ScanPageEntity) {
val currentSession = _session.value ?: return
viewModelScope.launch(Dispatchers.IO) {
File(page.tempUri).delete()
scanRepository.deletePage(page.id, currentSession.id)
}
}
fun movePage(pageId: Long, delta: Int) {
val currentSession = _session.value ?: return
viewModelScope.launch {
scanRepository.movePage(currentSession.id, _pages.value, pageId, delta)
}
}
/** Exporte toutes les pages dans le dossier racine, 1 fichier = 1 outbox. */
fun export() {
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)
val stamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
pages.forEachIndexed { index, page ->
val file = File(page.tempUri)
if (!file.exists()) return@forEachIndexed
val displayName = "scan_${stamp}_${(index + 1).toString().padStart(2, '0')}.jpg"
val createdUri = SafWriter.createDocument(
resolver = context.contentResolver,
treeUri = rootUri.toString(),
mimeType = "image/jpeg",
displayName = displayName,
) ?: error("cannot create document in default root")
SafWriter.copyInto(createdUri, file, 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 = "jpg",
size = file.length(),
mimeType = "image/jpeg",
lastModified = file.lastModified(),
exists = true,
),
folderResourceId = folderId,
)
scanRepository.markPageExported(page.copy(status = ScanPageStatus.EXPORTED))
}
scanRepository.finishSession(currentSession.id)
_exported.value = true
} catch (e: Exception) {
Timber.e(e, "scan 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
}
/** Annule la session : suppression des fichiers temp + fermeture. */
fun abandon() {
val currentSession = _session.value ?: return
viewModelScope.launch(Dispatchers.IO) {
scanRepository.getPages(currentSession.id).forEach { File(it.tempUri).delete() }
sessionDir(currentSession.id)?.delete()
_pages.value = emptyList()
scanRepository.abandonSession(currentSession.id)
_session.value = null
}
}
fun dismissMessage() {
_message.value = null
}
// ------------------------------------------------------------ helpers
private fun ensureSessionDir(sessionId: Long): File =
sessionDir(sessionId)?.apply { if (!exists()) mkdirs() } ?: sessionDir(sessionId)!!
private fun sessionDir(sessionId: Long): File? = runCatching {
File(context.filesDir, "$SCAN_DIR/$sessionId").also { it.mkdirs() }
}.getOrNull()
private fun defaultQuad(width: Int, height: Int): List<PointF> {
val inset = min(width, height) * 0.08f
return listOf(
PointF(inset, inset),
PointF(width - inset, inset),
PointF(width - inset, height - inset),
PointF(inset, height - inset),
)
}
private fun decodeSampled(file: File): Bitmap {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.absolutePath, bounds)
var sample = 1
while (max(bounds.outWidth, bounds.outHeight) / sample > MAX_EDIT_SIDE) {
sample *= 2
}
val options = BitmapFactory.Options().apply { inSampleSize = sample }
val decoded = BitmapFactory.decodeFile(file.absolutePath, options)
?: error("cannot decode capture")
return applyExifOrientation(file, decoded)
}
/**
* `BitmapFactory` ignore l'EXIF : une capture prise en portrait est stockée
* en paysage (sens capteur), et l'image serait affichée/détectée à l'envers.
* on la tourne donc selon `TAG_ORIENTATION` écrit par ImageCapture.
*/
private fun applyExifOrientation(file: File, bitmap: Bitmap): Bitmap {
val orientation = runCatching {
android.media.ExifInterface(file.absolutePath)
.getAttributeInt(
android.media.ExifInterface.TAG_ORIENTATION,
android.media.ExifInterface.ORIENTATION_NORMAL,
)
}.getOrDefault(android.media.ExifInterface.ORIENTATION_NORMAL)
val degrees = when (orientation) {
android.media.ExifInterface.ORIENTATION_ROTATE_90 -> 90
android.media.ExifInterface.ORIENTATION_ROTATE_180 -> 180
android.media.ExifInterface.ORIENTATION_ROTATE_270 -> 270
else -> 0
}
if (degrees == 0) return bitmap
val matrix = Matrix().apply { setRotate(degrees.toFloat()) }
val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
if (rotated !== bitmap) bitmap.recycle()
return rotated
}
private fun quadToString(quad: ScanQuad): String =
quad.points.joinToString(" ") { p -> "%.1f,%.1f".format(p.x, p.y) }
private companion object {
const val SCAN_DIR = "scan_sessions"
const val MAX_EDIT_SIDE = 2400
const val JPEG_QUALITY = 92
}
}
@@ -0,0 +1,175 @@
package com.vaultdrop.mobile.ui.scan
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
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.Save
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import com.vaultdrop.mobile.R
import com.vaultdrop.mobile.data.local.entity.ScanPageEntity
import java.io.File
/**
* Étape « session » : pages capturées (miniatures), réordonnancement,
* suppression, ajout d'une page et export vers le dossier VaultDrop
* (1 fichier JPEG = 1 `create_resource` outbox).
*/
@Composable
fun SessionStage(
viewModel: ScanViewModel,
pages: List<ScanPageEntity>,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxSize()) {
if (pages.isEmpty()) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.scan_empty),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
} else {
LazyColumn(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
itemsIndexed(pages, key = { _, page -> page.id }) { index, page ->
PageCard(
page = page,
index = index,
isFirst = index == 0,
isLast = index == pages.lastIndex,
onMoveUp = { viewModel.movePage(page.id, -1) },
onMoveDown = { viewModel.movePage(page.id, 1) },
onDelete = { viewModel.deletePage(page) },
)
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedButton(
onClick = viewModel::openCamera,
modifier = Modifier.weight(1f),
) {
Icon(imageVector = Icons.Filled.Add, contentDescription = null)
Text(stringResource(R.string.scan_add_page), modifier = Modifier.padding(start = 4.dp))
}
Button(
onClick = viewModel::export,
modifier = Modifier.weight(1f),
enabled = pages.isNotEmpty(),
) {
Icon(imageVector = Icons.Filled.Save, contentDescription = null)
Text(stringResource(R.string.scan_export), modifier = Modifier.padding(start = 4.dp))
}
}
}
}
@Composable
private fun PageCard(
page: ScanPageEntity,
index: Int,
isFirst: Boolean,
isLast: Boolean,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
onDelete: () -> Unit,
) {
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AsyncImage(
model = File(page.tempUri),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(8.dp)),
)
Column(
modifier = Modifier
.weight(1f)
.padding(start = 12.dp),
) {
Text(
text = stringResource(R.string.scan_pages_count, index + 1),
style = MaterialTheme.typography.titleSmall,
)
Text(
text = "${page.width} × ${page.height}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Column {
IconButton(onClick = onMoveUp, enabled = !isFirst) {
Icon(
imageVector = Icons.Filled.KeyboardArrowUp,
contentDescription = stringResource(R.string.scan_move_up),
)
}
IconButton(onClick = onMoveDown, enabled = !isLast) {
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = stringResource(R.string.scan_move_down),
)
}
IconButton(onClick = onDelete) {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = stringResource(R.string.scan_delete_page),
)
}
}
}
}
}
@@ -0,0 +1,127 @@
package com.vaultdrop.mobile.ui.scan
import android.graphics.PointF
import android.graphics.RectF
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class ScanGeometryTest {
private val imageWidth = 1080
private val imageHeight = 1920
// Écran portrait 1080x1600 : FIT = l'image est réduite pour tenir en
// hauteur, avec bandes latérales (letterbox horizontal).
private val viewWidth = 1080
private val viewHeight = 1600
// Zoom « recadrage » : sous-région de l'image 640x640 au centre.
private val sourceRect = RectF(220f, 640f, 860f, 1280f)
@Test
fun fitRect_centre_l_image_dans_le_letterbox() {
val rect = ScanGeometry.fitRect(imageWidth, imageHeight, viewWidth, viewHeight)
// scale = min(1080/1080, 1600/1920) = 0.8333 → shown 900x1600
assertEquals(90f, rect.left, 0.01f)
assertEquals(0f, rect.top, 0.01f)
assertEquals(900f, rect.width(), 0.01f)
assertEquals(1600f, rect.height(), 0.01f)
}
@Test
fun mapToFitView_suit_le_ratio_de_l_image() {
val rect = ScanGeometry.fitRect(imageWidth, imageHeight, viewWidth, viewHeight)
// Coin TL de l'image -> haut-gauche du letterbox.
assertEquals(rect.left, map(0f, 0f).x, 0.01f)
assertEquals(rect.top, map(0f, 0f).y, 0.01f)
// Coin BR -> bas-droite du letterbox.
assertEquals(rect.right, map(imageWidth.toFloat(), imageHeight.toFloat()).x, 0.01f)
assertEquals(rect.bottom, map(imageWidth.toFloat(), imageHeight.toFloat()).y, 0.01f)
// Centre de l'image -> centre du viewport (indépendant du letterbox).
assertEquals(viewWidth / 2f, map(imageWidth / 2f, imageHeight / 2f).x, 0.01f)
assertEquals(viewHeight / 2f, map(imageWidth / 2f, imageHeight / 2f).y, 0.01f)
}
@Test
fun viewportFit_agrandit_une_sous_region_plein_ecran() {
// Sous-région carrée 640x640 dans une vue 1080x1600 : FIT va la
// contraindre par la largeur → 1080x1080, centrée verticalement.
val dst = ScanGeometry.viewportFit(sourceRect, viewWidth, viewHeight)
assertEquals(0f, dst.left, 0.01f)
assertEquals((viewHeight - 1080f) / 2f, dst.top, 0.01f)
assertEquals(1080f, dst.width(), 0.01f)
assertEquals(1080f, dst.height(), 0.01f)
}
@Test
fun mapViewToRect_est_l_inverse_de_mapRectToView() {
val viewPoint = ScanGeometry.mapRectToView(PointF(540f, 960f), sourceRect, viewWidth, viewHeight)
val back = ScanGeometry.mapViewToRect(viewPoint, sourceRect, viewWidth, viewHeight)
assertEquals(540f, back.x, 0.01f)
assertEquals(960f, back.y, 0.01f)
}
@Test
fun mapViewToRect_mappe_les_bords_du_viewport_sur_les_bords_de_la_region() {
val dst = ScanGeometry.viewportFit(sourceRect, viewWidth, viewHeight)
// Coin TL du viewport affiché ↔ coin TL de la source.
val tl = ScanGeometry.mapViewToRect(PointF(dst.left, dst.top), sourceRect, viewWidth, viewHeight)
assertEquals(sourceRect.left, tl.x, 0.01f)
assertEquals(sourceRect.top, tl.y, 0.01f)
// Coin BR du viewport ↔ coin BR de la source.
val br = ScanGeometry.mapViewToRect(PointF(dst.right, dst.bottom), sourceRect, viewWidth, viewHeight)
assertEquals(sourceRect.right, br.x, 0.01f)
assertEquals(sourceRect.bottom, br.y, 0.01f)
}
@Test
fun mapPointsToView_garde_la_geometrie_du_quad() {
val quad = listOf(
PointF(220f, 640f),
PointF(860f, 640f),
PointF(860f, 1280f),
PointF(220f, 1280f),
)
val dst = ScanGeometry.viewportFit(sourceRect, viewWidth, viewHeight)
val mapped = ScanGeometry.mapPointsToView(quad, sourceRect, viewWidth, viewHeight)
// Le rectangle image [220..860]x[640..1280] s'affiche en plein écran :
// TL → coin haut-gauche, BR → coin bas-droite.
assertEquals(dst.left, mapped[0].x, 0.01f)
assertEquals(dst.top, mapped[0].y, 0.01f)
assertEquals(dst.right, mapped[2].x, 0.01f)
assertEquals(dst.bottom, mapped[2].y, 0.01f)
// L'aire du quad doit rester positive (pas de repères inversés).
val area = quadArea(mapped)
assertTrue(area > 0f)
}
private fun quadArea(pts: List<PointF>): Float {
var sum = 0f
for (i in pts.indices) {
val p = pts[i]
val q = pts[(i + 1) % pts.size]
sum += p.x * q.y - q.x * p.y
}
return sum / 2f
}
private fun map(x: Float, y: Float): PointF =
ScanGeometry.mapToFitView(
points = listOf(PointF(x, y)),
imageWidth = imageWidth,
imageHeight = imageHeight,
viewWidth = viewWidth,
viewHeight = viewHeight,
).first()
}