ocr is working

This commit is contained in:
m
2026-09-16 12:44:48 +02:00
parent 050434325a
commit 906d48702c
21 changed files with 595 additions and 33 deletions
@@ -20,14 +20,16 @@ import com.vaultdrop.mobile.data.local.entity.UserPreferenceEntity
* v5: created_in_app sur folders ; v6: processed sur files (mode review) ;
* v7: pending_operations (outbox) ; v8: scan_sessions + scan_pages (scanner) ;
* v9: content sur files (corps des notes créées dans l'app) ;
* v10: ocr_text sur files (extrait OCR serveur persisté localement).
* v10: ocr_text sur files (extrait OCR serveur persisté localement) ;
* v11: ocr_attempts + ocr_queued_at sur files (driver OCR auto côté client —
* compteur d'échecs terminaux ≤ 3, marqueur « en cours » de l'upload).
*/
@Database(
entities = [
FolderEntity::class, UserPreferenceEntity::class, FileEntity::class,
PendingOperationEntity::class, ScanSessionEntity::class, ScanPageEntity::class,
],
version = 10,
version = 11,
exportSchema = false,
)
abstract class AppDatabase : RoomDatabase() {
@@ -126,4 +126,33 @@ interface FileDao {
/** Persiste l'extrait OCR d'un fichier (résultat local du job serveur). */
@Query("UPDATE files SET ocr_text = :text, updated_at = :updatedAt WHERE resource_id = :resourceId")
suspend fun updateOcrText(resourceId: String, text: String?, updatedAt: Long)
/** Candidate OCR auto : fichier `processed`, `local-cloud`, avec copie
* locale, format potentiellement extractible, jamais extrait, pas en cours
* et pas épuisé (< 3 échecs terminaux). Trié par dernier upload. */
@Query("""
SELECT * FROM files
WHERE "exists" = 1
AND processed = 1
AND sync_status = 'local-cloud'
AND uri IS NOT NULL
AND ocr_text IS NULL
AND ocr_attempts < 3
AND ocr_queued_at IS NULL
AND category IN ('PDF', 'IMAGE', 'TEXT')
ORDER BY COALESCE(last_modified, added_at) DESC, name ASC
""")
suspend fun getOcrAutoCandidates(): List<FileEntity>
/** Pose le marqueur « en cours » du driver OCR auto (upload en vol). */
@Query("UPDATE files SET ocr_queued_at = :queuedAt, updated_at = :now WHERE resource_id = :resourceId")
suspend fun markOcrQueued(resourceId: String, queuedAt: Long, now: Long)
/** Levé du marqueur + reset du compteur d'échecs (succès / erreur transitoire). */
@Query("UPDATE files SET ocr_queued_at = NULL, ocr_attempts = 0, updated_at = :now WHERE resource_id = :resourceId")
suspend fun clearOcrQueued(resourceId: String, now: Long)
/** Échec terminal : +1 tentative, marqueur levé (ne rejoue pas à l'infini). */
@Query("UPDATE files SET ocr_attempts = ocr_attempts + 1, ocr_queued_at = NULL, updated_at = :now WHERE resource_id = :resourceId")
suspend fun markOcrFailure(resourceId: String, now: Long)
}
@@ -57,6 +57,10 @@ data class FileEntity(
val processed: Boolean = false,
@ColumnInfo(name = "ocr_text")
val ocrText: String? = null,
@ColumnInfo(name = "ocr_attempts")
val ocrAttempts: Int = 0,
@ColumnInfo(name = "ocr_queued_at")
val ocrQueuedAt: Long? = null,
@ColumnInfo(name = "added_at")
val addedAt: Long,
@ColumnInfo(name = "updated_at")
@@ -174,7 +174,14 @@ object Migrations {
}
}
private val MIGRATION_10_11 = object : Migration(10, 11) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE `files` ADD COLUMN `ocr_attempts` INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE `files` ADD COLUMN `ocr_queued_at` INTEGER")
}
}
val ALL: Array<Migration> = arrayOf(
MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10,
MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11,
)
}
@@ -7,6 +7,7 @@ import androidx.core.net.toUri
import com.vaultdrop.mobile.data.local.dao.FileDao
import com.vaultdrop.mobile.data.local.entity.FileEntity
import com.vaultdrop.mobile.data.remote.ApiClient
import com.vaultdrop.mobile.data.remote.ApiException
import com.vaultdrop.mobile.data.remote.dto.OcrJobDto
import com.vaultdrop.mobile.data.remote.dto.OcrJobStatus
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -74,11 +75,71 @@ class OcrRepository @Inject constructor(
fileDao.updateOcrText(resourceId, text?.takeIf { it.isNotBlank() }, System.currentTimeMillis())
}
/**
* OCR auto d'un fichier (un seul à la fois) : marqueur « en cours », upload
* multipart ciblé, soumission du job serveur, poll jusqu'au terminal puis
* persistance. Le worker [OcrAutoWorker] appelle cette méthode fichier par
* fichier pour rester séquentiel — le traitement lourd reste la file
* bornée côté serveur.
*/
suspend fun processAuto(file: FileEntity): OcrAutoOutcome {
val now = System.currentTimeMillis()
fileDao.markOcrQueued(file.resourceId, now, now)
return try {
ensurePhysical(file)
val jobId = submit(file.resourceId)
val final = poll(jobId)
when (final.status) {
OcrJobStatus.DONE -> {
saveResult(file.resourceId, final.text)
fileDao.clearOcrQueued(file.resourceId, System.currentTimeMillis())
OcrAutoOutcome.Done
}
else -> {
fileDao.markOcrFailure(file.resourceId, System.currentTimeMillis())
OcrAutoOutcome.Failed
}
}
} catch (e: ApiException) {
when {
e.code == "NETWORK_ERROR" || e.httpCode >= 500 -> {
// Transitoire : levé le marqueur, WorkManager retente avec backoff.
fileDao.clearOcrQueued(file.resourceId, System.currentTimeMillis())
OcrAutoOutcome.Retryable
}
e.httpCode == 401 -> OcrAutoOutcome.Unauthorized
else -> {
// Erreur permanente : +1 tentative (≤ 3), relance manuelle possible.
fileDao.markOcrFailure(file.resourceId, System.currentTimeMillis())
OcrAutoOutcome.Failed
}
}
} catch (e: Exception) {
fileDao.markOcrFailure(file.resourceId, System.currentTimeMillis())
OcrAutoOutcome.Failed
}
}
companion object {
const val POLL_INTERVAL_MS = 3_000L
}
}
/** Résultat d'une passe d'OCR auto (un fichier max). */
sealed interface OcrAutoOutcome {
/** Traité avec succès : `ocr_text` persisté. */
data object Done : OcrAutoOutcome
/** Job terminal `failed` / erreur permanente : `ocr_attempts` incrémenté. */
data object Failed : OcrAutoOutcome
/** Erreur transitoire : à retenter via backoff WorkManager. */
data object Retryable : OcrAutoOutcome
/** Token expiré/révoqué : le re-login passera par l'UI. */
data object Unauthorized : OcrAutoOutcome
}
/**
* `RequestBody` paresseux : le flux SAF est ouvert à l'écriture (multipart),
* jamais chargé en mémoire — les fichiers jusqu'à `MAX_FILE_SIZE_MB` restent
@@ -0,0 +1,100 @@
package com.vaultdrop.mobile.features.ocr
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.vaultdrop.mobile.auth.TokenProvider
import com.vaultdrop.mobile.data.local.dao.FileDao
import com.vaultdrop.mobile.data.remote.ApiException
import com.vaultdrop.mobile.data.remote.dto.OcrJobStatus
import com.vaultdrop.mobile.data.repository.OcrAutoOutcome
import com.vaultdrop.mobile.data.repository.OcrRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import java.util.concurrent.TimeUnit
import timber.log.Timber
/**
* OCR auto fichier par fichier : à chaque exécution, un seul candidat
* (`processed`, `local-cloud`, `ocr_text` nul, URI SAF présente,
* `ocr_attempts < 3`) est uploadé puis enfilé (`POST /ocr/jobs`) dans la
* **file bornée serveur** (`Ocr.Run`). Un seul run à la fois via
* `enqueueUniqueWork(KEEP)`. Le worker se re-enchaine tant qu'il reste des
* candidats *distincts* du dernier fichier traité (pas de relance immédiate
* d'un fichier en échec, pour respecter le cap de 3 tentatives).
*
* Erreurs :
* - **réseau / 5xx** → `Result.retry()` (backoff 30 s) sans incrémenter
* `ocr_attempts` (le marqueur in-flight est levé) ;
* - **4xx non-idempotente** → `ocr_attempts` incrémenté, pas de re-enchaine ;
* - **token expiré (401)** → `Result.success()` (re-login passera par l'UI).
*/
@HiltWorker
class OcrAutoWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val ocrRepository: OcrRepository,
private val fileDao: FileDao,
private val tokenProvider: TokenProvider,
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
if (tokenProvider.current == null) return Result.success()
val file = fileDao.getOcrAutoCandidates().firstOrNull()
?: return Result.success()
Timber.d("ocr-auto: candidate %s (%s)", file.name, file.resourceId)
val outcome = try {
ocrRepository.processAuto(file)
} catch (e: ApiException) {
when {
e.code == "NETWORK_ERROR" || e.httpCode >= 500 -> OcrAutoOutcome.Retryable
e.httpCode == 401 -> OcrAutoOutcome.Unauthorized
else -> OcrAutoOutcome.Failed
}
} catch (e: Exception) {
Timber.w(e, "ocr-auto: unexpected failure for %s", file.resourceId)
OcrAutoOutcome.Failed
}
when (outcome) {
is OcrAutoOutcome.Done ->
Timber.d("ocr-auto: done %s", file.resourceId)
is OcrAutoOutcome.Failed ->
Timber.w("ocr-auto: failed %s", file.resourceId)
is OcrAutoOutcome.Retryable -> return Result.retry()
is OcrAutoOutcome.Unauthorized -> return Result.success()
}
// Self-chain : un autre candidat *distinct* du dernier attend ?
val more = fileDao.getOcrAutoCandidates().any { it.resourceId != file.resourceId }
if (more) {
enqueue(applicationContext)
}
return Result.success()
}
companion object {
const val NAME = "ocr_auto"
fun enqueue(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = OneTimeWorkRequestBuilder<OcrAutoWorker>()
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(NAME, ExistingWorkPolicy.KEEP, request)
}
}
}
@@ -22,6 +22,7 @@ import com.vaultdrop.mobile.data.local.entity.PendingOperationType
import com.vaultdrop.mobile.data.remote.ApiClient
import com.vaultdrop.mobile.data.remote.ApiException
import com.vaultdrop.mobile.data.remote.dto.SyncOpDto
import com.vaultdrop.mobile.features.ocr.OcrAutoWorker
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import java.util.concurrent.TimeUnit
@@ -62,6 +63,9 @@ class OutboxSyncWorker @AssistedInject constructor(
Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java),
)
/** Set à true si un fichier a été promu pendant ce run → enchaîne OcrAutoWorker. */
private var filePromoted = false
override suspend fun doWork(): Result {
// Mode local : sans compte connecté, rien à pousser.
if (tokenProvider.current == null) return Result.success()
@@ -71,10 +75,13 @@ class OutboxSyncWorker @AssistedInject constructor(
fileDao.backfillSyncedStatus(System.currentTimeMillis())
folderDao.backfillSyncedStatus(System.currentTimeMillis())
filePromoted = false
while (true) {
val pending = pendingOperationDao.selectPending(BATCH_SIZE)
if (pending.isEmpty()) {
pendingOperationDao.purgeSynced(System.currentTimeMillis() - PURGE_AGE_MS)
if (filePromoted) OcrAutoWorker.enqueue(applicationContext)
return Result.success()
}
@@ -139,7 +146,10 @@ class OutboxSyncWorker @AssistedInject constructor(
PendingOperationType.CREATE_RESOURCE,
PendingOperationType.MOVE_RESOURCE -> {
when (op.resourceType) {
"file" -> fileDao.promoteSyncStatus(resourceId, System.currentTimeMillis())
"file" -> {
fileDao.promoteSyncStatus(resourceId, System.currentTimeMillis())
filePromoted = true
}
"folder" -> folderDao.promoteSyncStatus(resourceId, System.currentTimeMillis())
}
}
@@ -11,6 +11,7 @@ import com.vaultdrop.mobile.data.local.entity.PendingOperationEntity
import com.vaultdrop.mobile.data.repository.FolderRepository
import com.vaultdrop.mobile.data.repository.SaveFolderInput
import com.vaultdrop.mobile.data.repository.ShareRepository
import com.vaultdrop.mobile.features.ocr.OcrAutoWorker
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
@@ -107,6 +108,8 @@ class SyncViewModel @Inject constructor(
// Les nouvelles ressources découvertes sont dans l'outbox
// → drainer vers POST /sync/ops (single-flight via KEEP).
OutboxSyncWorker.enqueue(appContext)
// OCR auto : uploader les octets des fichiers récemment promus (local-cloud).
OcrAutoWorker.enqueue(appContext)
}
.onFailure { e -> Timber.w(e, "syncAll failed, retrying later") }
// Hydrate les ressources partagées depuis le snapshot serveur.
@@ -9,6 +9,7 @@ import com.vaultdrop.mobile.data.remote.ApiException
import com.vaultdrop.mobile.data.repository.AuthRepository
import com.vaultdrop.mobile.data.repository.ShareRepository
import com.vaultdrop.mobile.features.sync.OutboxSyncWorker
import com.vaultdrop.mobile.features.ocr.OcrAutoWorker
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
@@ -51,6 +52,7 @@ class AuthViewModel @Inject constructor(
authRepository.registerDevice()
// Session restaurée → drainer l'outbox laissée en attente.
OutboxSyncWorker.enqueue(appContext)
OcrAutoWorker.enqueue(appContext)
if (session != null) {
// Snapshot complet des permissions partagées (convergence).
runCatching { shareRepository.syncSnapshot() }
@@ -73,6 +75,7 @@ class AuthViewModel @Inject constructor(
_authState.value = AuthState.SignedIn(response.user)
// Connexion réussie → pousser les mutations locales en attente.
OutboxSyncWorker.enqueue(appContext)
OcrAutoWorker.enqueue(appContext)
// Snapshot complet des permissions partagées (convergence).
runCatching { shareRepository.syncSnapshot() }
.onFailure { e -> Timber.d("syncSnapshot on login failed: %s", e.message) }
@@ -7,6 +7,7 @@ import com.vaultdrop.mobile.data.remote.ApiClient
import com.vaultdrop.mobile.data.remote.ApiException
import com.vaultdrop.mobile.data.repository.OutboxRepository
import com.vaultdrop.mobile.features.sync.OutboxSyncWorker
import com.vaultdrop.mobile.features.ocr.OcrAutoWorker
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
@@ -68,6 +69,7 @@ class ShareViewModel @Inject constructor(
)
}.onSuccess {
OutboxSyncWorker.enqueue(appContext)
OcrAutoWorker.enqueue(appContext)
Timber.d("share: op enqueued for %s", resourceId)
_uiState.update { it.copy(sharing = false, enqueued = true) }
}.onFailure { e ->
@@ -30,6 +30,7 @@ import okhttp3.MultipartBody
import okhttp3.RequestBody
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -117,18 +118,24 @@ class OcrRepositoryTest {
assertEquals("boom", final.error)
}
private fun insertFile(uri: String): FileEntity {
private fun insertFile(
uri: String?,
ocrText: String? = null,
category: String? = "TEXT",
): FileEntity {
val file = FileEntity(
resourceId = "aabbccddeeff11223344556677889900",
resourceId = generateResourceId(),
uri = uri,
name = "sample.txt",
name = uri?.substringAfterLast('/') ?: "cloud.txt",
folderResourceId = "f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0",
extension = "txt",
size = 13L,
extension = uri?.substringAfterLast('.')?.take(8),
size = 10L,
mimeType = "text/plain",
exists = 1,
category = category,
syncStatus = FileStatus.LOCAL_CLOUD,
processed = true,
ocrText = ocrText,
addedAt = 1_700_000_000_000L,
updatedAt = 1_700_000_000_000L,
)
@@ -136,8 +143,83 @@ class OcrRepositoryTest {
return file
}
private var idSeq = 0L
private fun generateResourceId(): String {
val hex = (++idSeq).toString(16).padStart(32, '0')
return hex
}
private fun moshi(): Moshi =
Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
@Test
fun `getOcrAutoCandidates_exclut_ocr_text_present`() = runTest {
val withText = insertFile("content://ocr/a.txt", ocrText = "extrait")
val withoutText = insertFile("content://ocr/b.txt", ocrText = null)
val candidates = fileDao.getOcrAutoCandidates()
assertEquals(1, candidates.size)
assertEquals(withoutText.resourceId, candidates[0].resourceId)
}
@Test
fun `getOcrAutoCandidates_exclut_uri_absente_et_cloud_only`() = runTest {
insertFile("content://ocr/a.txt", ocrText = null)
insertFile(null, ocrText = null)
val candidates = fileDao.getOcrAutoCandidates()
assertEquals(1, candidates.size)
}
@Test
fun `getOcrAutoCandidates_exclut_exhausted_attempts_3`() = runTest {
val exhausted = insertFile("content://ocr/a.txt", ocrText = null)
fileDao.markOcrFailure(exhausted.resourceId, System.currentTimeMillis())
fileDao.markOcrFailure(exhausted.resourceId, System.currentTimeMillis())
fileDao.markOcrFailure(exhausted.resourceId, System.currentTimeMillis())
assertTrue(fileDao.getOcrAutoCandidates().isEmpty())
}
@Test
fun `processAuto_done_persiste_texte_et_clear_queued`() = runTest {
val uri = "content://ocr/auto-done.txt"
shadowOf(context.contentResolver).registerInputStream(uri.toUri(), ByteArrayInputStream("auto".toByteArray()))
val file = insertFile(uri)
apiService.jobSequence = listOf(
OcrJobDto(id = "auto-1", status = OcrJobStatus.QUEUED),
OcrJobDto(id = "auto-1", status = OcrJobStatus.DONE, text = "autoOCR"),
)
val outcome = repository.processAuto(file)
assertTrue(outcome is OcrAutoOutcome.Done)
val stored = fileDao.getByResourceId(file.resourceId)
assertEquals("autoOCR", stored?.ocrText)
assertEquals(0, stored?.ocrAttempts)
assertNull(stored?.ocrQueuedAt)
}
@Test
fun `processAuto_failed_incremente_attempts_et_clear_queued`() = runTest {
val uri = "content://ocr/auto-fail.txt"
shadowOf(context.contentResolver).registerInputStream(uri.toUri(), ByteArrayInputStream(byteArrayOf()))
val file = insertFile(uri)
apiService.jobSequence = listOf(
OcrJobDto(id = "auto-f", status = OcrJobStatus.FAILED, error = "tess err"),
)
val outcome = repository.processAuto(file)
assertTrue(outcome is OcrAutoOutcome.Failed)
val stored = fileDao.getByResourceId(file.resourceId)
assertEquals(1, stored?.ocrAttempts)
assertNull(stored?.ocrQueuedAt)
assertNull(stored?.ocrText)
}
}
/**