diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/ScanDao.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/ScanDao.kt new file mode 100644 index 0000000..eaf7c2c --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/ScanDao.kt @@ -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> + + @Query("SELECT * FROM scan_pages WHERE session_id = :sessionId ORDER BY sort_order ASC") + suspend fun getPages(sessionId: Long): List + + @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 + + @Query("SELECT COUNT(*) FROM scan_pages WHERE session_id = :sessionId") + suspend fun pageCount(sessionId: Long): Int +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/ScanPageEntity.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/ScanPageEntity.kt new file mode 100644 index 0000000..73e81cb --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/ScanPageEntity.kt @@ -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" +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/ScanSessionEntity.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/ScanSessionEntity.kt new file mode 100644 index 0000000..32290e5 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/ScanSessionEntity.kt @@ -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" +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/ScanRepository.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/ScanRepository.kt new file mode 100644 index 0000000..348bd65 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/ScanRepository.kt @@ -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> = scanDao.observePages(sessionId) + + fun observePageCount(sessionId: Long): Flow = scanDao.observePageCount(sessionId) + + suspend fun getPages(sessionId: Long): List = 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, 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()) + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/SafWriter.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/SafWriter.kt new file mode 100644 index 0000000..2d66bac --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/saf/SafWriter.kt @@ -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 + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/scan/CornerGeometry.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/scan/CornerGeometry.kt new file mode 100644 index 0000000..3466d81 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/scan/CornerGeometry.kt @@ -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): List { + 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): 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): Pair { + 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() + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/scan/ScanImageProcessor.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/scan/ScanImageProcessor.kt new file mode 100644 index 0000000..f0f23ce --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/scan/ScanImageProcessor.kt @@ -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) + +/** 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() + + 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() + + 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? { + val blur = Mat() + val edges = Mat() + val hierarchy = Mat() + val contours = ArrayList() + var best: List? = 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 { + 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 + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/scan/CameraStage.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/scan/CameraStage.kt new file mode 100644 index 0000000..cd2d350 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/scan/CameraStage.kt @@ -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?>(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(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 \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/scan/CropStage.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/scan/CropStage.kt new file mode 100644 index 0000000..5db6889 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/scan/CropStage.kt @@ -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.None) } + var moveStart by remember { mutableStateOf(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, 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, 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): 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 \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/scan/ScanFlowScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/scan/ScanFlowScreen.kt new file mode 100644 index 0000000..2729bad --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/scan/ScanFlowScreen.kt @@ -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)) + } + } + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/scan/ScanGeometry.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/scan/ScanGeometry.kt new file mode 100644 index 0000000..6d4fdf6 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/scan/ScanGeometry.kt @@ -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, + sourceRect: RectF, + viewWidth: Int, + viewHeight: Int, + ): List = 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, + imageWidth: Int, + imageHeight: Int, + viewWidth: Int, + viewHeight: Int, + ): List { + 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, + imageWidth: Int, + imageHeight: Int, + viewWidth: Int, + viewHeight: Int, + ): List = + 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, + ) +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/scan/ScanViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/scan/ScanViewModel.kt new file mode 100644 index 0000000..44431cd --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/scan/ScanViewModel.kt @@ -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(null) + val session: StateFlow = _session.asStateFlow() + + private val _stage = MutableStateFlow(ScanStage.CAMERA) + val stage: StateFlow = _stage.asStateFlow() + + private val _draft = MutableStateFlow(null) + val draft: StateFlow = _draft.asStateFlow() + + /** Dernière détection en direct (overlay du viewfinder). */ + private val _liveQuad = MutableStateFlow(null) + val liveQuad: StateFlow = _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> = _analysisImageSize.asStateFlow() + + private val _renderMode = MutableStateFlow(ScanRenderMode.COLOR) + val renderMode: StateFlow = _renderMode.asStateFlow() + + private val _busy = MutableStateFlow(false) + val busy: StateFlow = _busy.asStateFlow() + + private val _message = MutableStateFlow(null) + val message: StateFlow = _message.asStateFlow() + + private val _exported = MutableStateFlow(false) + val exported: StateFlow = _exported.asStateFlow() + + private val _pages = MutableStateFlow>(emptyList()) + val pages: StateFlow> = _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 { + 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 + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/scan/SessionStage.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/scan/SessionStage.kt new file mode 100644 index 0000000..fc6416f --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/scan/SessionStage.kt @@ -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, + 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), + ) + } + } + } + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/ui/scan/ScanGeometryTest.kt b/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/ui/scan/ScanGeometryTest.kt new file mode 100644 index 0000000..3f8d88e --- /dev/null +++ b/mobile-kotlin/app/src/test/java/com/vaultdrop/mobile/ui/scan/ScanGeometryTest.kt @@ -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): 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() +} \ No newline at end of file