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