amelioration OCR carte couleur + ascenseur agenda + fix synchro

EBO <eric.bouhana@softalys.com> committé le 2026-09-12 21:38

c15e097fef4df215170660034904d37e4b6f63a0

1 parent(s)

14 fichiers modifiés +301 -13
M README.md
+13 -1
@@ -66,7 +66,7 @@ Spec détaillée : [`docs/superpowers/specs/2026-07-22-sync-agenda-rdv-ressource
66 66
67 67 1. Cadrez la carte et capturez (CameraX).
68 68 2. Correction géométrique (OpenCV) + orientation EXIF.
69 -3. OCR Tesseract (7 langues) + post-traitement.
69 +3. Prétraitement contraste (gris + CLAHE + binarisation adaptative, repli sur l’image brute) puis OCR Tesseract (7 langues) + post-traitement.
70 70 4. Structuration **heuristique** (pas de modèle de langage).
71 71 5. Vérifiez le brouillon, puis :
72 72 - **Enregistrer** → carnet local (SQLite)
@@ -100,6 +100,18 @@ cd android
100 100
101 101 Premier build d’une variante : téléchargement dans `android/tessdata-cache/{fast,full}/` (gitignored), puis injection dans les assets générés. Réseau requis **uniquement** si le cache est vide.
102 102
103 +### Suivi des modèles (le modèle a-t-il changé ?)
104 +
105 +Les modèles sont **figés au build** : jamais re-téléchargés tant que le cache existe, jamais mis à jour au runtime. Les dépôts upstream `tessdata_fast`/`tessdata` sont d’ailleurs stables depuis des années ; les gains de précision viennent de la variante `full`, du prétraitement d’image (`OcrPreprocessor`) et des montées de version de Tesseract4Android — pas de nouveaux traineddata.
106 +
107 +```bash
108 +cd android
109 +./gradlew tessdataStatus # variante fast
110 +./gradlew tessdataStatus -Ptessdata=full # variante full
111 +```
112 +
113 +Pour chaque langue : état vs upstream GitHub (`à jour` / `DIFFÉRENT` / `cache absent`), taille et SHA-256. Le build embarque aussi `model-info.txt` (taille + SHA-256 par langue) dans les assets, à côté de `variant.txt`. Pour forcer un re-téléchargement : supprimer `android/tessdata-cache/` puis relancer un build.
114 +
103 115 ### Chargement ultérieur (sans rebuild / sans Internet dans l’APK)
104 116
105 117 Oui. Au runtime, Tesseract lit `filesDir/tesseract/tessdata/` :
M android/app/build.gradle.kts
+54 -0
@@ -1,4 +1,5 @@
1 1 import java.net.URI
2 +import java.security.MessageDigest
2 3 import java.util.Properties
3 4
4 5 plugins {
@@ -80,6 +81,18 @@ android {
80 81 }
81 82 }
82 83
84 +fun fileSha256(file: File): String =
85 + MessageDigest.getInstance("SHA-256").digest(file.readBytes())
86 + .joinToString("") { "%02x".format(it) }
87 +
88 +/** SHA-1 « git blob » — l'empreinte qu'expose l'API GitHub pour un fichier. */
89 +fun gitBlobSha1(file: File): String {
90 + val md = MessageDigest.getInstance("SHA-1")
91 + md.update("blob ${file.length()}\u0000".toByteArray())
92 + md.update(file.readBytes())
93 + return md.digest().joinToString("") { "%02x".format(it) }
94 +}
95 +
83 96 val prepareTessdata by tasks.registering {
84 97 group = "build"
85 98 description = "Télécharge (cache) et prépare les traineddata Tessdata ($tessdataVariant)"
@@ -107,6 +120,12 @@ val prepareTessdata by tasks.registering {
107 120 cached.copyTo(outDir.resolve("$lang.traineddata"), overwrite = true)
108 121 }
109 122 outDir.resolve("variant.txt").writeText(tessdataVariant)
123 + outDir.resolve("model-info.txt").writeText(
124 + tessdataLanguages.joinToString("\n") { lang ->
125 + val f = outDir.resolve("$lang.traineddata")
126 + "$lang ${f.length()} sha256=${fileSha256(f)}"
127 + } + "\n"
128 + )
110 129 outDir.resolve("README.md").writeText(
111 130 "Généré au build (variante=$tessdataVariant). Ne pas éditer.\n" +
112 131 "Cache : android/tessdata-cache/$tessdataVariant/\n"
@@ -122,6 +141,41 @@ tasks.matching {
122 141 }
123 142 tasks.named("preBuild").configure { dependsOn(prepareTessdata) }
124 143
144 +/** Compare le cache local des traineddata avec l'upstream GitHub (lecture seule).
145 + * Usage : ./gradlew tessdataStatus [-Ptessdata=full] */
146 +val tessdataStatus by tasks.registering {
147 + group = "verification"
148 + description = "Empreintes des traineddata en cache ($tessdataVariant) vs upstream GitHub"
149 + doLast {
150 + val repo = if (tessdataVariant == "fast") "tessdata_fast" else "tessdata"
151 + val cache = tessdataCacheDir.asFile
152 + val shaRegex = Regex(""""sha"\s*:\s*"([0-9a-f]{40})"""")
153 + for (lang in tessdataLanguages) {
154 + val cached = cache.resolve("$lang.traineddata")
155 + if (!cached.isFile) {
156 + logger.lifecycle("$lang : cache absent (${cached.path}) — sera téléchargé au prochain build")
157 + continue
158 + }
159 + val localSha = gitBlobSha1(cached)
160 + val api = "https://api.github.com/repos/tesseract-ocr/$repo/contents/$lang.traineddata?ref=main"
161 + val remoteSha = runCatching {
162 + URI(api).toURL().openStream().use { it.readBytes().toString(Charsets.UTF_8) }
163 + .let { shaRegex.find(it)?.groupValues?.get(1) }
164 + }.getOrNull()
165 + val etat = when (remoteSha) {
166 + null -> "upstream injoignable"
167 + localSha -> "à jour"
168 + else -> "DIFFÉRENT (upstream $remoteSha)"
169 + }
170 + logger.lifecycle("$lang : $etat — ${cached.length()} octets, sha256=${fileSha256(cached)}")
171 + }
172 + logger.lifecycle(
173 + "Rappel : les modèles sont figés au build ; pour forcer un refresh, " +
174 + "supprimer ${cache.path} puis relancer un build."
175 + )
176 + }
177 +}
178 +
125 179 dependencies {
126 180 implementation(platform("androidx.compose:compose-bom:2024.09.02"))
127 181 implementation("androidx.compose.ui:ui")
A android/app/src/androidTest/java/fr/ebii/card2vcf/ocr/CarteColoreeOcrInstrumentedTest.kt
+54 -0
@@ -0,0 +1,54 @@
1 +package fr.ebii.card2vcf.ocr
2 +
3 +import android.graphics.Bitmap
4 +import android.graphics.Canvas
5 +import android.graphics.Color
6 +import android.graphics.Paint
7 +import android.graphics.Typeface
8 +import androidx.test.ext.junit.runners.AndroidJUnit4
9 +import org.junit.Assert.assertTrue
10 +import org.junit.Test
11 +import org.junit.runner.RunWith
12 +
13 +/**
14 + * Carte colorée synthétique : texte clair sur fond foncé, cas où le seuillage
15 + * global interne de Tesseract décroche sans le prétraitement OcrPreprocessor
16 + * (inversion de polarité + binarisation adaptative).
17 + */
18 +@RunWith(AndroidJUnit4::class)
19 +class CarteColoreeOcrInstrumentedTest {
20 +
21 + @Test
22 + fun reconnaitTexteClairSurFondColore() {
23 + val context = androidx.test.platform.app.InstrumentationRegistry
24 + .getInstrumentation().targetContext
25 +
26 + val bmp = Bitmap.createBitmap(1600, 1000, Bitmap.Config.ARGB_8888)
27 + val canvas = Canvas(bmp)
28 + canvas.drawColor(Color.rgb(26, 75, 140)) // bleu soutenu
29 + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
30 + color = Color.WHITE
31 + typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL)
32 + textSize = 80f
33 + }
34 + canvas.drawText("Sonia MARTIN", 120f, 300f, paint)
35 + canvas.drawText("Voltea Solutions", 120f, 430f, paint)
36 + paint.textSize = 64f
37 + canvas.drawText("Tel. 04 75 35 12 34", 120f, 600f, paint)
38 + canvas.drawText("sonia.martin@voltea.fr", 120f, 720f, paint)
39 +
40 + val ocr = TesseractOcrEngine(context).recognize(bmp)
41 +
42 + println("=== OCR RAW (carte colorée) ===\n${ocr.rawText}")
43 + println("=== phones=${ocr.phones} emails=${ocr.emails}")
44 +
45 + assertTrue(
46 + "téléphone attendu absent: ${ocr.phones}",
47 + ocr.phones.contains("0475351234"),
48 + )
49 + assertTrue(
50 + "email attendu absent: ${ocr.emails}",
51 + ocr.emails.contains("sonia.martin@voltea.fr"),
52 + )
53 + }
54 +}
M android/app/src/main/java/fr/ebii/card2vcf/ocr/OcrPostProcessor.kt
+17 -6
@@ -13,11 +13,16 @@ object OcrPostProcessor {
13 13 .map { it.trim() }
14 14 .filter { it.isNotEmpty() }
15 15 val joined = lines.joinToString("\n")
16 - // Corrige O/0 uniquement dans les séquences numériques (erreurs OCR fréquentes).
17 - val phoneSource = joined
18 - .replace(Regex("""(?<=\d)O(?=\d)"""), "0")
19 - .replace(Regex("""\bO(?=\d)"""), "0")
20 - val phones = phoneRegex.findAll(phoneSource)
16 + // Extraction ligne par ligne : évite d'absorber le code postal ou le numéro
17 + // de rue d'une ligne voisine (\s matche aussi \n dans phoneRegex).
18 + val phones = lines
19 + .asSequence()
20 + // Corrige O/0 uniquement dans les séquences numériques (erreurs OCR fréquentes).
21 + .map {
22 + it.replace(Regex("""(?<=\d)O(?=\d)"""), "0")
23 + .replace(Regex("""\bO(?=\d)"""), "0")
24 + }
25 + .flatMap { phoneRegex.findAll(it) }
21 26 .map { normalizePhone(it.value) }
22 27 .filter { isPlausiblePhone(it) }
23 28 .distinct()
@@ -61,7 +66,13 @@ object OcrPostProcessor {
61 66
62 67 private fun isPlausiblePhone(phone: String): Boolean {
63 68 val digits = phone.filter { it.isDigit() }
64 - return digits.length in 10..15
69 + // Avec indicatif international : 11-15 chiffres. Sans : format national FR
70 + // strict (10 chiffres, 0 initial) pour rejeter les fusions adresse+téléphone.
71 + return if (phone.startsWith("+")) {
72 + digits.length in 11..15
73 + } else {
74 + digits.length == 10 && digits.first() == '0'
75 + }
65 76 }
66 77
67 78 private fun normalizeUrl(raw: String): String {
A android/app/src/main/java/fr/ebii/card2vcf/ocr/OcrPreprocessor.kt
+66 -0
@@ -0,0 +1,66 @@
1 +package fr.ebii.card2vcf.ocr
2 +
3 +import android.graphics.Bitmap
4 +import fr.ebii.card2vcf.scan.OpenCvScanEngine
5 +import org.opencv.android.Utils
6 +import org.opencv.core.Core
7 +import org.opencv.core.Mat
8 +import org.opencv.core.Size
9 +import org.opencv.imgproc.Imgproc
10 +
11 +/**
12 + * Prépare une image de carte pour Tesseract : gris → CLAHE → inversion si
13 + * texte clair sur fond sombre → binarisation adaptative. Le seuillage global
14 + * (Otsu) interne de Tesseract échoue sur les fonds colorés ou dégradés ;
15 + * le seuillage adaptatif local rend le texte exploitable dans ces cas.
16 + */
17 +object OcrPreprocessor {
18 +
19 + /** Largeur visée pour l'OCR : sous ~1800 px les petites lignes (téléphones)
20 + * passent sous la taille de glyphe optimale de Tesseract. */
21 + private const val TARGET_WIDTH = 1800.0
22 + private const val MAX_UPSCALE = 3.0
23 + private const val BLOCK_SIZE = 35
24 + private const val THRESH_C = 15.0
25 + private const val CLAHE_CLIP = 2.5
26 +
27 + /** Retourne l'image binarisée, ou null si OpenCV est indisponible. */
28 + fun prepare(source: Bitmap): Bitmap? {
29 + if (!OpenCvScanEngine.ensureOpenCv()) return null
30 + val src = Mat()
31 + Utils.bitmapToMat(source, src)
32 + val gray = Mat()
33 + try {
34 + when (src.channels()) {
35 + 4 -> Imgproc.cvtColor(src, gray, Imgproc.COLOR_RGBA2GRAY)
36 + 3 -> Imgproc.cvtColor(src, gray, Imgproc.COLOR_BGR2GRAY)
37 + else -> src.copyTo(gray)
38 + }
39 +
40 + val scale = (TARGET_WIDTH / gray.cols()).coerceAtMost(MAX_UPSCALE)
41 + if (scale > 1.0) {
42 + Imgproc.resize(gray, gray, Size(), scale, scale, Imgproc.INTER_CUBIC)
43 + }
44 +
45 + Imgproc.createCLAHE(CLAHE_CLIP, Size(8.0, 8.0)).apply(gray, gray)
46 +
47 + // Texte clair sur fond sombre → inversion pour retomber sur du noir sur blanc.
48 + if (Core.mean(gray).`val`[0] < 127.0) {
49 + Core.bitwise_not(gray, gray)
50 + }
51 +
52 + Imgproc.adaptiveThreshold(
53 + gray, gray, 255.0,
54 + Imgproc.ADAPTIVE_THRESH_GAUSSIAN_C, Imgproc.THRESH_BINARY,
55 + BLOCK_SIZE, THRESH_C,
56 + )
57 +
58 + val out = Bitmap.createBitmap(gray.cols(), gray.rows(), Bitmap.Config.ARGB_8888)
59 + Utils.matToBitmap(gray, out)
60 + return out
61 + } finally {
62 + gray.release()
63 + src.release()
64 + }
65 + }
66 +}
M android/app/src/main/java/fr/ebii/card2vcf/ocr/TesseractOcrEngine.kt
+15 -2
@@ -13,9 +13,22 @@ class TesseractOcrEngine(
13 13
14 14 override fun recognize(bitmap: Bitmap): OcrResult {
15 15 val dataPath = ensureTessData()
16 - return OcrOrientation.recognizeBest(bitmap) { candidate ->
17 - recognizeOnce(candidate, dataPath)
16 + // Passe 1 : image binarisée (contraste local) — décisive sur cartes colorées.
17 + val prepared = OcrPreprocessor.prepare(bitmap)
18 + val onPrepared = prepared?.let { candidate ->
19 + runCatching {
20 + OcrOrientation.recognizeBest(candidate) { recognizeOnce(it, dataPath) }
21 + }.getOrNull().also { candidate.recycle() }
18 22 }
23 + if (onPrepared != null && (onPrepared.emails.isNotEmpty() || onPrepared.phones.isNotEmpty())) {
24 + return onPrepared
25 + }
26 + // Passe 2 : image originale, puis meilleur score des deux.
27 + val onOriginal = runCatching {
28 + OcrOrientation.recognizeBest(bitmap) { recognizeOnce(it, dataPath) }
29 + }.getOrNull()
30 + return listOfNotNull(onPrepared, onOriginal).maxByOrNull { OcrOrientation.score(it) }
31 + ?: throw OcrException("OCR vide — reprenez la photo")
19 32 }
20 33
21 34 private fun recognizeOnce(bitmap: Bitmap, dataPath: String): OcrResult {
M android/app/src/main/java/fr/ebii/card2vcf/sync/SyncEngine.kt
+19 -2
@@ -50,6 +50,13 @@ class SyncEngine(
50 50 private val api: AilianceApi,
51 51 private val db: CrmDatabase,
52 52 private val imageStore: fr.ebii.card2vcf.data.ContactImageStore? = null,
53 + /**
54 + * Identité « serveur|utilisateur » à laquelle appartient le watermark. Si elle diffère de
55 + * celle enregistrée (changement de serveur ou de compte), le pull repart de l'époque :
56 + * sans cela, un watermark hérité d'un autre serveur masque toute donnée plus ancienne
57 + * (ex. données de démo antidatées → « 0 reçu » définitif). `null` = pas de contrôle.
58 + */
59 + private val serverIdentity: String? = null,
53 60 ) {
54 61 private val agenda = AgendaSyncCoordinator(db)
55 62
@@ -127,11 +134,20 @@ class SyncEngine(
127 134
128 135 // ---- watermark ----
129 136
130 - private suspend fun watermark(): String =
131 - db.syncMetaDao().get(WATERMARK_KEY)?.value ?: DEFAULT_WATERMARK
137 + private suspend fun watermark(): String {
138 + val stored = db.syncMetaDao().get(WATERMARK_KEY)?.value ?: return DEFAULT_WATERMARK
139 + if (serverIdentity != null) {
140 + val origin = db.syncMetaDao().get(WATERMARK_ORIGIN_KEY)?.value
141 + if (origin != serverIdentity) return DEFAULT_WATERMARK
142 + }
143 + return stored
144 + }
132 145
133 146 private suspend fun setWatermark(serverTime: String) {
134 147 db.syncMetaDao().upsert(SyncMetaEntity(key = WATERMARK_KEY, value = serverTime))
148 + if (serverIdentity != null) {
149 + db.syncMetaDao().upsert(SyncMetaEntity(key = WATERMARK_ORIGIN_KEY, value = serverIdentity))
150 + }
135 151 }
136 152
137 153 // ---- push ----
@@ -667,6 +683,7 @@ class SyncEngine(
667 683
668 684 private companion object {
669 685 const val WATERMARK_KEY = "watermark"
686 + const val WATERMARK_ORIGIN_KEY = "watermark_origin"
670 687 const val DEFAULT_WATERMARK = "1970-01-01T00:00:00Z"
671 688 const val ENTITY_CONTACT_MEDIA = "contact_media"
672 689
M android/app/src/main/java/fr/ebii/card2vcf/ui/nav/Card2vcfNavHost.kt
+1 -0
@@ -114,6 +114,7 @@ fun Card2vcfNavHost(
114 114 AilianceApiClient(baseUrl, apiKey),
115 115 database,
116 116 fr.ebii.card2vcf.data.ContactImageStore(context.applicationContext),
117 + serverIdentity = "$baseUrl|${credentialsStore.userName.orEmpty()}",
117 118 )
118 119 } else {
119 120 null
M android/app/src/main/java/fr/ebii/card2vcf/ui/settings/SettingsScreen.kt
+3 -1
@@ -13,8 +13,10 @@ import androidx.compose.foundation.layout.Row
13 13 import androidx.compose.foundation.layout.fillMaxSize
14 14 import androidx.compose.foundation.layout.fillMaxWidth
15 15 import androidx.compose.foundation.layout.padding
16 +import androidx.compose.foundation.rememberScrollState
16 17 import androidx.compose.foundation.shape.RoundedCornerShape
17 18 import androidx.compose.foundation.text.KeyboardOptions
19 +import androidx.compose.foundation.verticalScroll
18 20 import androidx.compose.material.icons.Icons
19 21 import androidx.compose.material.icons.automirrored.outlined.ArrowBack
20 22 import androidx.compose.material3.AlertDialog
@@ -120,7 +122,7 @@ private fun LoggedInContent(
120 122 onRequestCalendarPermission: () -> Unit,
121 123 ) {
122 124 Column(
123 - Modifier.fillMaxSize().padding(18.dp),
125 + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(18.dp),
124 126 verticalArrangement = Arrangement.spacedBy(10.dp),
125 127 ) {
126 128 Text(
M android/app/src/test/java/fr/ebii/card2vcf/ocr/OcrPostProcessingTest.kt
+30 -0
@@ -29,6 +29,36 @@ class OcrPostProcessingTest {
29 29 assertEquals(listOf("ligne1", "ligne2"), r.lines)
30 30 }
31 31
32 + @Test fun neFusionnePasTelephoneEtCodePostalSurLignesVoisines() {
33 + val text = """
34 + Jean Dupont
35 + Tél. 04 75 35 12 34
36 + 07200 Aubenas
37 + """.trimIndent()
38 + val r = OcrPostProcessor.process(text)
39 + assertEquals(listOf("0475351234"), r.phones)
40 + }
41 +
42 + @Test fun neFusionnePasNumeroDeRueEtTelephone() {
43 + val text = """
44 + 245
45 + 04 75 35 12 34
46 + """.trimIndent()
47 + val r = OcrPostProcessor.process(text)
48 + assertEquals(listOf("0475351234"), r.phones)
49 + }
50 +
51 + @Test fun ignoreCodePostalSeul() {
52 + val r = OcrPostProcessor.process("07200 Aubenas")
53 + assertEquals(emptyList(), r.phones)
54 + }
55 +
56 + @Test fun rejetteLongueursImplausiblesSansIndicatif() {
57 + // 9 chiffres, 11 chiffres, ou 10 chiffres sans 0 initial : pas des numéros FR
58 + val r = OcrPostProcessor.process("123 45 67 89\n0 12 34 56 78 90\n61 23 45 67 89")
59 + assertEquals(emptyList(), r.phones)
60 + }
61 +
32 62 @Test fun extraitCarteDimoCommeSurAppareil() {
33 63 val text = """
34 64 dimo
M android/app/src/test/java/fr/ebii/card2vcf/sync/FakeAilianceApi.kt
+2 -0
@@ -22,6 +22,7 @@ class FakeAilianceApi : AilianceApi {
22 22
23 23 var statusQueries = mutableListOf<String?>()
24 24 var pullQueries = mutableListOf<String?>()
25 + val pullSince = mutableListOf<String>()
25 26
26 27 override fun authCle(nom: String, motDePasse: String) =
27 28 AilianceApiClient.ApiResult.Ok(AuthCleResponse(nom, "", "", "argon2id", "xchacha20poly1305"))
@@ -33,6 +34,7 @@ class FakeAilianceApi : AilianceApi {
33 34
34 35 override fun syncPull(sinceIso: String, ressourcesQuery: String?): AilianceApiClient.ApiResult<SyncPullResponse> {
35 36 pullQueries += ressourcesQuery
37 + pullSince += sinceIso
36 38 return pullResult
37 39 }
38 40
M android/app/src/test/java/fr/ebii/card2vcf/sync/SyncEngineTest.kt
+19 -0
@@ -39,6 +39,25 @@ class SyncEngineTest {
39 39 fun tearDown() = db.close()
40 40
41 41 @Test
42 + fun watermark_ignoreLorsqueLeServeurOuLeCompteChange() = runBlocking {
43 + // Synchro réussie avec l'identité A : watermark posé à serverTime.
44 + api.pullResult = AilianceApiClient.ApiResult.Ok(
45 + SyncPullResponse(serverTime = "2026-09-11T22:00:00Z"),
46 + )
47 + val engineA = SyncEngine(api, db, serverIdentity = "https://prod.example|alice")
48 + engineA.syncNow()
49 + val engineA2 = SyncEngine(api, db, serverIdentity = "https://prod.example|alice")
50 + engineA2.syncNow()
51 + assertEquals("2026-09-11T22:00:00Z", api.pullSince.last())
52 +
53 + // Même base locale, mais serveur différent : le pull doit repartir de l'époque,
54 + // sinon les données plus anciennes que le watermark hérité ne redescendent jamais.
55 + val engineB = SyncEngine(api, db, serverIdentity = "https://demo.example|alice")
56 + engineB.syncNow()
57 + assertEquals("1970-01-01T00:00:00Z", api.pullSince.last())
58 + }
59 +
60 + @Test
42 61 fun checkStatus_returnsTotalFromFake() = runBlocking {
43 62 api.statusResult = AilianceApiClient.ApiResult.Ok(
44 63 SyncStatusResponse(
M android/app/src/test/java/fr/ebii/card2vcf/ui/projets/ProjetsViewModelTest.kt
+5 -0
@@ -33,6 +33,11 @@ class ProjetsViewModelTest {
33 33 val ctx = ApplicationProvider.getApplicationContext<Context>()
34 34 db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java)
35 35 .allowMainThreadQueries()
36 + // Exécuteurs synchrones : les DAO suspend s'exécutent inline, donc le
37 + // viewModelScope.launch de createProjet se termine avant les assertions
38 + // (sinon course entre le pool Room et runBlocking → test flaky).
39 + .setQueryExecutor { it.run() }
40 + .setTransactionExecutor { it.run() }
36 41 .build()
37 42 }
38 43
M android/app/src/test/java/fr/ebii/card2vcf/ui/sync/SyncChromeViewModelTest.kt
+3 -1
@@ -234,7 +234,9 @@ class SyncChromeViewModelTest {
234 234 fun syncNow_clearsPreviousSummaryOnGlobalPullFailure() = runBlocking {
235 235 val vm = newViewModel()
236 236 vm.syncNow()
237 - awaitUntil { vm.syncSummary.value != null }
237 + // Attendre la fin complète du 1er syncNow (pas seulement le résumé) : ses écritures
238 + // tardives (_error, _syncing) s'intercaleraient sinon avec le 2e syncNow → flaky.
239 + awaitUntil { vm.syncSummary.value != null && !vm.syncing.value }
238 240 api.pullResult = AilianceApiClient.ApiResult.Err(-1, "Réseau indisponible")
239 241
240 242 vm.syncNow()