add document scanner
This commit is contained in:
@@ -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
|
||||
}
|
||||
+59
@@ -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"
|
||||
}
|
||||
+41
@@ -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"
|
||||
}
|
||||
+108
@@ -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()
|
||||
}
|
||||
}
|
||||
+223
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user