diff --git a/README.md b/README.md index 8f4c1fc..ac113e4 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.** > à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de > sang et aux consignes de ton endocrinologue. -- **Statut** : v1.3.3 — build Android ✅, **87 tests unitaires** ✅ (44 sans les données de test locales ; 3 régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅ +- **Statut** : v1.3.4 — build Android ✅, **lint vert** ✅, **90 tests unitaires** ✅ (72 sans les données de test locales ; 3 régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅ - **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md) - **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md) - **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md) @@ -73,7 +73,8 @@ par AGP si les licences sont signées). Le wrapper télécharge Gradle 9.7.1. git clone && cd HormoneTrack echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME ./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk -./gradlew testDebugUnitTest # 87 tests (44 sans les données locales) +./gradlew testDebugUnitTest # 90 tests (72 sans les données locales) +./gradlew lint # lint vert obligatoire avant release ``` Installation sur un téléphone : mode développeur + Débogage USB, puis Android Studio @@ -84,7 +85,7 @@ Pas de Play Store : l'app est sideloadée. Détails pas-à-pas : [docs/GUIDE_INS Dépôts : **gitea.cloudyfy.fr** et **gitea.farewell.dev** (miroir) — `Siphonight/HormoneTrack` sur les deux (privé), avec -**releases taguées** (`v1.1.0` → `v1.3.3`) et **deux APK par release** (depuis v1.2.5) +**releases taguées** (`v1.1.0` → `v1.3.4`) et **deux APK par release** (depuis v1.2.5) (téléchargeables sans compiler, cf [docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)) : `-release.apk` (**recommandé**, optimisé R8, 2,4 Mo) et `-debug.apk` (20 Mo) : @@ -140,7 +141,9 @@ HormoneTrack/ │ ├── CHANGELOG.md journal détaillé des versions │ └── MONTRE-GADGETBRIDGE.md montre Huawei GT 3 : options + limites ├── scripts/ -│ └── gitea-release.py publie une release (corps = CHANGELOG + APK) +│ ├── gitea-release.py publie le corps d'une release (CHANGELOG + APK) +│ ├── publish-release.py publie les 2 APK d'un tag (vérification par téléchargement) +│ └── seed-emulator.py injecte un backup JSON dans la DB d'un émulateur (cf §16.ter) ├── local-test-data/ ← gitignoré : backups réels pour les tests │ de régression (JAMAIS dans le dépôt, cf §8.bis) ├── build.gradle.kts config Gradle racine (AGP/Kotlin/KSP épinglés) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ac83add..d65d79d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,8 +17,8 @@ android { // tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0" // (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans // les APK publiés (Paramètres + titre du dialog « Nouveautés »). - versionCode = 17 - versionName = "1.3.3" + versionCode = 18 + versionName = "1.3.4" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -61,6 +61,15 @@ android { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } + + // Lint (v1.3.4) : les checks Compose 1.12+ (NonObservableLocale, + // LocalContextGetResourceValueCall) sont rétrogradés en WARNING via + // app/lint.xml (meilleure compatibilité que la DSL AGP) — cas THÉORIQUES de staleness de + // configuration présents dans tout le code v1, jamais à l'origine des + // crashs remontés. Les vrais problèmes de format de strings + // (StringFormatMatches — aurait attrapé les bugs #47/#48, épinglés §14) + // restent des ERREURS bloquantes. Re-traiter ces warnings lors d'une + // refonte i18n (cf §20). } // Synchronise docs/CHANGELOG.md → src/main/assets/changelog.md (dialog diff --git a/app/lint.xml b/app/lint.xml new file mode 100644 index 0000000..2a3e980 --- /dev/null +++ b/app/lint.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/java/com/hormonetrack/reminder/ReminderManager.kt b/app/src/main/java/com/hormonetrack/reminder/ReminderManager.kt index 7d6bf31..bf60285 100644 --- a/app/src/main/java/com/hormonetrack/reminder/ReminderManager.kt +++ b/app/src/main/java/com/hormonetrack/reminder/ReminderManager.kt @@ -182,8 +182,11 @@ class ReminderReceiver : BroadcastReceiver() { } else { context.getString(R.string.reminder_title_plain) } + // ⚠️ reminder_text_with_dose attend un %s : passer le Double brut + // déclenche un warning lint StringFormatMatches (« suspicious type ») — + // formater explicitement en dose humaine (ex. « 4,0 »). val text = if (dose != null && dose > 0.0) { - context.getString(R.string.reminder_text_with_dose, dose) + context.getString(R.string.reminder_text_with_dose, dose.toString()) } else { context.getString(R.string.reminder_text) } diff --git a/app/src/main/java/com/hormonetrack/ui/screens/DosesScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/DosesScreen.kt index fc47df3..a09807e 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/DosesScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/DosesScreen.kt @@ -122,8 +122,13 @@ fun DosesScreen() { val totalDays = HrtDuration.daysSince(first, System.currentTimeMillis()) if (totalDays > 0) { val (months, days) = HrtDuration.monthsAndDays(totalDays) + // ⚠️ hrt_duration a TROIS placeholders (%1$d mois, %2$d jours, + // %3$d total) : passer aussi totalDays, sinon + // MissingFormatArgumentException = crash de l'écran Doses + // dès que totalDays > 0 (donc à chaque affichage avec des + // données réelles). Testé au bug réel v1.3.3 (cf §14 #47). Text( - if (months > 0) stringResource(R.string.hrt_duration, months, days) + if (months > 0) stringResource(R.string.hrt_duration, months, days, totalDays) else stringResource(R.string.hrt_duration_days_only, days), style = MaterialTheme.typography.bodyLarge ) diff --git a/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt index d64da26..8dad428 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt @@ -52,12 +52,11 @@ import com.hormonetrack.pk.PharmacokineticEngine import com.hormonetrack.pk.TConfig import com.hormonetrack.reminder.AlarmScheduler import com.hormonetrack.ui.LocalAppContainer +import com.hormonetrack.util.ExportFileNames import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.time.LocalDate -import java.time.format.DateTimeFormatter import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @@ -362,8 +361,7 @@ fun SettingsScreen(onBack: () -> Unit) { Spacer(Modifier.height(6.dp)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button(onClick = { - val date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")) - exportLauncher.launch("hormonetrack-backup-$date.json") + exportLauncher.launch(ExportFileNames.backupFileName()) }) { Text(stringResource(R.string.export_json)) } OutlinedButton(onClick = { importLauncher.launch(arrayOf("application/json")) @@ -385,8 +383,10 @@ fun SettingsScreen(onBack: () -> Unit) { Spacer(Modifier.height(6.dp)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { OutlinedButton(onClick = { - val date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmm")) - logExportLauncher.launch("hormonetrack-logs-$date.txt") + // Nom de fichier via ExportFileNames (cf §14 #48) : + // extrait et testé — passer un LocalDate au pattern + // horaire crashait l'app au tap (bug v1.3.1→v1.3.3). + logExportLauncher.launch(ExportFileNames.diagnosticLogFileName()) }) { Text(stringResource(R.string.logs_export)) } TextButton(onClick = { AppLog.clear() diff --git a/app/src/main/java/com/hormonetrack/util/ExportFileNames.kt b/app/src/main/java/com/hormonetrack/util/ExportFileNames.kt new file mode 100644 index 0000000..73997e5 --- /dev/null +++ b/app/src/main/java/com/hormonetrack/util/ExportFileNames.kt @@ -0,0 +1,46 @@ +package com.hormonetrack.util + +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +/** + * Noms de fichiers d'export (horodatés) — helper PUR, testable en JVM. + * + * Pourquoi cet objet existe (v1.3.4, cf §14 #48) : le nom du fichier des logs + * était généré INLINE dans le handler du bouton via + * `LocalDate.now().format(ofPattern("yyyyMMdd-HHmm"))`. Un `LocalDate` n'a + * PAS de champ horaire : le pattern `HHmm` levait + * `UnsupportedTemporalTypeException: Unsupported field: HourOfDay` de façon + * SYNCHRONE dans le onClick (thread UI) → crash de l'app au tap, sans même + * ouvrir le sélecteur de fichiers. Crash présent depuis v1.3.1 ; les « fixes » + * v1.3.2 et v1.3.3 réparaient l'IO mais pas cette ligne. + * + * Règle anti-régression : les helpers d'ici centralisent le couplage + * type ↔ pattern, chaque fonction documentant quel type porte le pattern. + * ⚠️ Règle générale : un pattern contenant des champs horaires (HH, mm, ss) + * exige LocalDateTime/LocalTime/Instant.withZone ; yyyyMMdd seul sur LocalDate. + */ +object ExportFileNames { + + private val LOG_FMT = DateTimeFormatter.ofPattern("yyyyMMdd-HHmm") + private val BACKUP_FMT = DateTimeFormatter.ofPattern("yyyyMMdd") + + /** + * Nom du fichier d'export des logs de diagnostic : + * `hormonetrack-logs-20260906-1234.txt`. + * Le pattern contient HHmm → il faut un type TEMPOREL COMPLET + * (LocalDateTime). Passer un LocalDate ici = UnsupportedTemporalTypeException + * au tap sur « Exporter » (bug #48, crash réel v1.3.1→v1.3.3). + */ + fun diagnosticLogFileName(now: LocalDateTime = LocalDateTime.now()): String = + LOG_FMT.format(now).let { "hormonetrack-logs-$it.txt" } + + /** + * Nom du fichier de sauvegarde JSON : + * `hormonetrack-backup-20260906.json`. Pattern SANS heure → + * `LocalDate` est correct ici (c'est le cas qui marchait, à garder tel quel). + */ + fun backupFileName(today: LocalDate = LocalDate.now()): String = + "hormonetrack-backup-${BACKUP_FMT.format(today)}.json" +} diff --git a/app/src/test/java/com/hormonetrack/util/ExportFileNamesTest.kt b/app/src/test/java/com/hormonetrack/util/ExportFileNamesTest.kt new file mode 100644 index 0000000..856f7a5 --- /dev/null +++ b/app/src/test/java/com/hormonetrack/util/ExportFileNamesTest.kt @@ -0,0 +1,47 @@ +package com.hormonetrack.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate +import java.time.LocalDateTime + +/** + * Régression du bug #48 (v1.3.4) : le nom de fichier des logs était formatté + * `LocalDate.now().format(ofPattern("yyyyMMdd-HHmm"))` → TypeError + * UnsupportedTemporalTypeException: Unsupported field: HourOfDay + * levée SYNCHRONEMENT dans le onClick du bouton « Exporter » des logs + * (thread UI) → crash de l'app au tap, avant même le sélecteur de fichiers. + * Préalablement invisible en tests JVM : la logique vivait inline dans la + * SettingsScreen (composable). Le helper est désormais pur et testé ici. + */ +class ExportFileNamesTest { + + @Test + fun `log filename is built from a LocalDateTime (has a time field)`() { + val name = ExportFileNames.diagnosticLogFileName( + LocalDateTime.of(2026, 9, 6, 12, 34) + ) + assertEquals("hormonetrack-logs-20260906-1234.txt", name) + } + + @Test + fun `backup filename is date-only (LocalDate is valid for yyyyMMdd)`() { + val name = ExportFileNames.backupFileName( + LocalDate.of(2026, 9, 6) + ) + assertEquals("hormonetrack-backup-20260906.json", name) + } + + @Test + fun `default filenames never throw and match the expected shape`() { + assertTrue( + ExportFileNames.diagnosticLogFileName() + .matches(Regex("hormonetrack-logs-\\d{8}-\\d{4}\\.txt")) + ) + assertTrue( + ExportFileNames.backupFileName() + .matches(Regex("hormonetrack-backup-\\d{8}\\.json")) + ) + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f5f2bbc..3d54b53 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,7 +1,51 @@ # Changelog — HormoneTrack Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/). -Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.3.3`, …). +Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.3.4`, …). + +## [1.3.4] — 2026-09-06 (versionCode 18) + +### Corrigé +- **L'écran Doses faisait crasher l'app** (remontée) : `MissingFormatArgumentException: + Format specifier '%3$d'` — la string `hrt_duration` a TROIS placeholders + (`%1$d mois, %2$d jours, %3$d total`) mais l'appel transmettait 2 arguments. + Crash dès le premier affichage de l'écran Doses avec une donnée antérieure à + aujourd'hui. **Le bug est né en v1.3.1** (string + appel dans le même commit, + jamais testés ensemble) et a survécu aux releases v1.3.1→v1.3.3. Fix : passer + totalDays en 3ᵉ argument (cf §14 #47). +- **L'export des logs de diagnostic faisait crasher l'app** (remontée répétée ; + les « fixes » IO de v1.3.2 et v1.3.3 étaient corrects mais à côté du vrai + problème) : le nom de fichier était construit via + `LocalDate.now().format(ofPattern("yyyyMMdd-HHmm"))` — un `LocalDate` n'a pas + de champ horaire → `UnsupportedTemporalTypeException: Unsupported field: + HourOfDay`, levée SYNCHRONEMENT dans le onClick (thread UI) au tap sur + « Exporter », avant même l'ouverture du gestionnaire de fichiers. L'export + JSON fonctionnait car son pattern `yyyyMMdd` est valide pour un LocalDate. + FIX : utilitaire `ExportFileNames` (pur, testé) — `diagnosticLogFileName()` + utilise un `LocalDateTime` ; `backupFileName()` reste sur `LocalDate`. + Cf §14 #48. +- **Lint `StringFormatMatches`** (détecté en implantant le lint — nouveau filet + de la v1.3.4) : la notification de rappel passait un Double à une `%s`. + Fix : `dose.toString()` (comportement d'affichage inchangé, #49). + +### Ajouté +- **Lint en filet bloquant** : `./gradlew lint` est désormais vert (it aurait + attrapé dès v1.3.1 les deux crashs ci-dessus via `StringFormatMatches`). + Les deux checks Compose 1.12+ théoriques (`NonObservableLocale`, + `LocalContextGetResourceValueCall`) sont rétrogradés en warning via + `app/lint.xml` (staleness de config, refonte i18n plus tard, cf §19/§20). +- **Recette reproductible : test manuel sur émulateur** (documentée §16.ter) : + émulateur + image ATD ou google_apis, seed de la DB du debug avec un backup + JSON via le nouveau script **`scripts/seed-emulator.py`** (générique, sans + aucune donnée embarquée), installation du APK release par-dessus (données + conservées, même signature), pilotage par `uiautomator dump` + `input tap`, + crashs via `adb logcat -b crash`. C'est cette recette qui a reproduit et + confirmé les deux crashs ci-dessus ainsi que LEUR correction (flux SAF + complet : picker → SAVE → fichier écrit → message de confirmation, sur + l'APK release données réelles). +- **3 nouveaux tests** (`ExportFileNamesTest`, 90 au total / 72 sans les + données locales) : épinglent le format des noms de fichiers d'export — + auraient attrapé le bug #48 le jour même. ## [1.3.3] — 2026-09-06 (versionCode 17) diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index 3edf7f9..59faa55 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -80,6 +80,7 @@ Fonctionnalités v1 : | 6 sept. 2026 (session v1.3.2) | 3 bugs remontés : toggle agenda (callback async fix + ContextCompat au save + Posologie requise), export logs plantait (pattern JSON réutilisé + AppLog), dialog changelog récurrent (version vue mémorisée avant affichage + titre BuildConfig documenté) ; export réel mis à jour (3 traitements : EV inactif + EEn actif + CPA oral) ; **87 tests verts**, APK v1.3.2 + releases. | | 6 sept. 2026 (session farewell) | Repo créé côté farewell → **push SSH (alias `farewell` : giteassh:2222) + 12 releases publiées avec APK vérifiés par téléchargement** ; piège lexicographique `v1.2.10 < v1.2.5` en comparaison de chaînes épinglé (§14 #38) ; les deux instances Gitea sont synchrones. | | 6 sept. 2026 (session v1.3.3) | **Audit de reprise de maintenance** (nouvelle session IA) : 3 bugs racines trouvés — permissions agenda ABSENTES du manifest (jamais déclarées, §14 #44), export logs plantait TOUJOURS (le fix v1.3.2 réimplémentait l'IO au lieu de réutiliser BackupManager, §14 #45), bump de version JAMAIS commité (tags v1.3.0–1.3.2 tous `versionCode 14 / "1.3.0"`, §14 #46). Corrigés + doc rafraîchie (DB v3, targetSdk 36, régression 3 = export v1.3.1, §19/§20 staleness) ; **téléphone de test documenté : Google Pixel 9 /e/OS** (AOSP ; SAF DocumentsUI standard). | +| 6 sept. 2026 (session v1.3.4) | **Deux crashs v1.3.3 remontés (Doses + export logs)** → reproduits sur ÉMULATEUR avec les vraies données (recette §16.ter, désormais standard) : (a) Doses = string `hrt_duration` 3 placeholders vs 2 args (**né en v1.3.1**, §14 #47) ; (b) export logs = `LocalDate.format("…-HHmm")` levant `UnsupportedTemporalTypeException` au TAP (§14 #48) — les fixes IO v1.3.2/v1.3.3 étaient à côté du vrai problème. Lint mis en filet bloquant (aurait attrapé les deux) + #49 notif ; helper `ExportFileNames` + 3 tests (90 verts) ; fixes VÉRIFIÉS sur émulateur avec l'APK release + données réelles (flux SAF complet : SAVE → fichier + message). Publication v1.3.4. | | 6 sept. 2026 (session v1.3.0) | Dialog « Nouveautés » après mise à jour (CHANGELOG embarqué en asset, tâche `copyChangelog`, version vue en DataStore) ; **événements d'agenda récurrents** (calendrier local HormoneTrack, RRULE posologie, permission runtime, Room v3 `calendarEventId`) ; Paramètres : version + lien releases ; **78 tests verts**, APK v1.3.0 + releases. | | 5 sept. 2026 (session v1.2.10) | Sens des boutons de zoom inversé (+ = zoom avant, convention carte — retour utilisateur) ; 67 tests verts, APK v1.2.10 + releases. | | 5 sept. 2026 (session v1.2.9) | **Zoom du graphique** (pinch + boutons, 6 h → 300 j, focal stable, échantillonnage adaptatif `stepForRange`, labels X 1 h/3 h) ; **README : disclaimer IA-assisté** en en-tête ; 3 tests ; **67 tests verts**, APK v1.2.9 + releases. | @@ -136,6 +137,15 @@ Notes importantes (v1.2.0) : par `adb install` ou APK direct. Les retours utilisateur (bugs, exports JSON) viennent de ce téléphone. (Historique : la montre est une Huawei Watch GT 3 via Gadgetbridge, cf §17 — ce n'est PAS le téléphone.) +- **Émulateur local (installé en v1.3.4)** : SDK brew = + `/opt/homebrew/share/android-commandlinetools`. Packages installés : + `emulator` 37.1.11 + `system-images;android-31;aosp_atd;arm64-v8a` (ATD : léger, + boot rapide, MAIS DocumentsUI STUBBÉ (`fakesystemapp`) → SAF non pilotable) et + `system-images;android-36;google_apis;arm64-v8a` (image complète — REQUISE pour + tester les flux SAF). AVD `hrt` (ATD 31) et `hrt36` (API 36) créés via + avdmanager (erreur béninie « devices.xml » au create : l'AVD est créé quand même). + Boot headless : `emulator -avd hrt36 -no-window -no-audio -no-boot-anim -no-snapshot + -gpu swiftshader_indirect`. Recette de test → **§16.ter**. - **SDK Android** : installé via `brew install --cask android-commandlinetools` → `/opt/homebrew/share/android-commandlinetools` (524 MB) - licences acceptées : `yes | sdkmanager --licenses` @@ -361,8 +371,9 @@ Type de retour : `LevelPoint(timestamp, e2, t)`. ## 8. Tests unitaires -**87 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — 44 sans les -données de test locales (cf §8.bis). Dépendance : JUnit 4.13.2. +**87 tests JVM, tous verts** → **90 depuis v1.3.4** (`./gradlew testDebugUnitTest`) — +**72 sans les données de test locales** (cf §8.bis : les 3 classes de régression, +6 tests chacune, sont skippées via `Assume`). Dépendance : JUnit 4.13.2. Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution = `app/` → l'asset est lu via `src/main/assets/pk_profiles.json` (fallback `app/src/…`). @@ -837,6 +848,37 @@ aucun contexte, dans l'esprit de cette doc) :** AVANT le tag ; le bump fait partie du commit de release (jamais un état local non commité au moment du build). +**Session v1.3.4 (deux crashs remontés → reproduits sur émulateur) :** + +47. **L'écran Doses crashait l'app** (`MissingFormatArgumentException: + Format specifier '%3$d'`) : la string `hrt_duration` a TROIS placeholders + (`%1$d mois, %2$d jours, %3$d total`) mais + `stringResource(R.string.hrt_duration, months, days)` ne passait que DEUX + arguments. Crash dès que le fragment `totalDays > 0` s'affiche (donc pour + TOUTE donnée antérieure à aujourd'hui) — né en v1.3.1 (string + appel dans + le même commit fa5d2df, jamais testés ensemble) et passé à travers + v1.3.1→v1.3.3. +48. **Le bouton « Exporter » des logs crashait l'app** — LE bug récurrent des + v1.3.1→v1.3.3 : le nom de fichier était construit INLINE via + `LocalDate.now().format(ofPattern("yyyyMMdd-HHmm"))`. Un `LocalDate` n'a + PAS de champ horaire → `UnsupportedTemporalTypeException: + Unsupported field: HourOfDay` levée dans le onClick (thread principal, + synchrone, touch dispatch) → crash au tap, avant même le sélecteur SAF + (stack obtenue sur émulateur : LTSI + frames R8 obfusquées). L'export + JSON marchait car `yyyyMMdd` est un pattern valide pour LocalDate. + Zones d'ombre emboîtées : v1.3.2 et v1.3.3 « corrigent » l'IO (cf #41/#45) + sans voir cette ligne. FIX : `util/ExportFileNames.kt` (pur, testé, + documenté — couplage type↔pattern centralisé) ; `FileNamesTest` épinglant + les deux helpers (aurait attrapé le bug le jour même). Leçon : la + génération de nom de fichier ne doit JAMAIS vivre inline dans un onClick. +49. **StringFormatMatches** (capture du lint, v1.3.4) : la notification de + rappel passait un Double à `%s`. Fix `dose.toString()` (l'affichage ne + change pas). L'intérêt des règles lint est confirmé PAR CETTE SESSION : + `./gradlew lint` aurait signalé #47 dès v1.3.1 → le lint fait désormais + partie de la vérification avant release (les checks Compose 1.12+ + théoriques `NonObservableLocale`/`LocalContextGetResourceValueCall` sont + tombés en warning via `app/lint.xml`, cf §19). + **Leçons** : (a) ne jamais croire un build « probablement bon » sans l'avoir lancé ; (b) les tests sémantiques attrapent ce que la compilation ne voit pas ; (c) se méfier des constantes stdlib « de mémoire » (`ln2`), des mélanges Float/Double, et des APIs M3 @@ -852,10 +894,15 @@ perd les garde-fous acquis à l'usage (cf #45) ; (j) vérifier les POSTULATS dan code réel, pas dans la doc (« le manifest contient WRITE_CALENDAR », cf #44) ; (k) un bump de version non commité = métadonnées fausses dans les APK publiés (cf #46) — le bump fait partie du commit de release ; (l) le scan de -confidentialité s'ADAPTE aux nouveaux exports (`local-test-data/`) : se régénère -donc à CHECK chaque push — un historique « propre hier » peut devenir hit dès -qu'un nouvel export introduit des motifs qui collent ; juger les hits avec -l'utilisatrice (cf dette documentée §8.bis). +confidentialité s'ADAPTE automatiquement aux nouveaux exports +(`local-test-data/`) : un historique « propre hier » peut devenir hit dès +qu'un nouvel export introduit des motifs qui collent — re-scanner PUIS juger +avec l'utilisatrice (dette déjà jugée, cf §8.bis) ; (m) **un crash sans stack +trace = d'abord le reproduire** (émulateur + données réelles, §16.ter) — les +deux « fixes » aveugles de v1.3.2/v1.3.3 (#41/#45) ont laissé passer un bug +trivial (#48) seulement visible sur l'émulateur ; (n) `./gradlew lint` fait +désormais partie de la vérification avant release : `StringFormatMatches` +aurait signalé #47 dès v1.3.1. ## 15. Comment régénérer l'asset pk_profiles.json @@ -980,8 +1027,11 @@ vérifie pas en tests JVM). 1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`, `versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y). -2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 87 au total, - 44 si `local-test-data/` est absent (les régressions réelles sont skippées). +2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 90 au total + (v1.3.4), 72 si `local-test-data/` est absent (les 3 classes de régression + réelles sont skippées via `Assume`, 6 tests chacune) ; **lint vert + obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait + attrapé les crashs #47/#48 dès v1.3.1). 3. **Docs** : section `## [X.Y.Z]` en tête de `docs/CHANGELOG.md` (le corps des releases Gitea en sera extrait automatiquement par le script), + §14 si bug corrigé, + §2 (historique) si notable. @@ -1012,10 +1062,83 @@ Notes : de santé dans le dépôt, cf §8.bis). - Téléphone : mode développeur + Débogage USB (détails : GUIDE_INSTALLATION.md) -- À ma charge (assistant) : build + tests JVM ✓ ; émulateur possible sur demande ; +- À ma charge (assistant) : build + tests JVM ✓ ; **émulateur installé et + opérationnel depuis v1.3.4 (recette §16.ter)** ; **les tests humains sur vrai téléphone restent la référence** (notifs → montre, UX de saisie, pickers, panoramique du chart) +### 16.ter Recette : test manuel sur émulateur (reproductible — v1.3.4) + +But : reproduire/valider un crash ou une UI sur l'app RÉELLE (sans téléphone +branché) — c'est cette recette qui a diagnostiqué les crashs #47/#48 en une +session. ⚠️ AUCUNE donnée personnelle n'est embarquée : le backup utilisé vit +sous `local-test-data/` (gitignoré, §8.bis) et est passé au script en argument. + +**0. Installation (one-shot, cf §4)** : `sdkmanager "emulator" +"system-images;android-31;aosp_atd;arm64-v8a"` (léger, pour le LOGIQUE sans +SAF) et/ou `"system-images;android-36;google_apis;arm64-v8a"` (image COMPLÈTE, +nécessaire pour piloter les sélecteurs de fichiers). AVD : +`avdmanager create avd -n hrt36 -k "system-images;…" -d pixel_6` (l'erreur +béninie « devices.xml » ne bloque pas la création). Boot headless : +`emulator -avd hrt36 -no-window -no-audio -no-boot-anim -no-snapshot +-gpu swiftshader_indirect &` puis `adb wait-for-device` + attente +`getprop sys.boot_completed`. + +**1. Seed des « données réelles »** — la DB est modifiable uniquement sur un +APK DEBUGGABLE (`run-as`) : + +```bash +adb install app/build/outputs/apk/debug/app-debug.apk +adb shell am start -n com.hormonetrack/.MainActivity # 1er lancement → crée la DB +adb shell am force-stop com.hormonetrack # flush de la DB +adb shell run-as com.hormonetrack cat databases/hormonetrack.db > /tmp/hrt.db +python3 scripts/seed-emulator.py /tmp/hrt.db local-test-data/backup-v1.3.1.json +adb push /tmp/hrt.db /data/local/tmp/hrt.db +adb shell run-as com.hormonetrack cp /data/local/tmp/hrt.db databases/hormonetrack.db +adb shell run-as com.hormonetrack rm -f databases/hormonetrack.db-wal \ + databases/hormonetrack.db-shm # WAL périmé sinon ! (checkpoint implicite fait par le seed) +``` + +Règles du seed (le script les impose) : colonnes = noms de propriétés Kotlin +(Room n'applique pas de snake_case), enums stockés en String — on insère +SEULEMENT des lignes, on ne touche NI au schéma NI à `room_master_table` +(Room valide le schéma à l'ouverture, pas les lignes). + +**2. Passer en RELEASE (c'est LE build à tester)** — `install -r` PAR-DESSUS +le debug CONSERVE la DB (les APK release/signés debug sont la même clé) : + +```bash +adb install -r app/build/outputs/apk/release/app-release.apk +adb logcat -c && adb shell am start -n com.hormonetrack/.MainActivity +``` + +**3. Piloter l'UI headless** : à l'aide de `uiautomator dump` + `input tap` — +l'UI se pilote SANS écran : + +```bash +adb shell uiautomator dump /sdcard/ui.xml && adb shell cat /sdcard/ui.xml +# → parser text=…/content-desc=… + bounds=[x1,y1][x2,y2] → centre (cx,cy) +adb shell input tap # naviguer (5 tabs ≈ y=2280 à 1080×2400) +adb shell input keyevent KEYCODE_BACK +``` + +**4. Attraper le crash** : le process meurt et repasse au launcher — c'est le +signal ; stack : `adb logcat -b crash -d` (grep FATAL). **0 crash attendu : +`grep -c FATAL` reste à 0** après chaque interaction. + +**5. Flux SAF (export JSON/logs)** : sur android-31 ATD le +DocumentsUI est STUBBÉ (`fakesystemapp`) → INUTILISABLE ; utiliser l'image +android-36 google_apis. Le picker se pilote pareil (bouton `SAVE` en bas à +droite) ; vérification : fichier présent dans `/sdcard/Download/` + le message +de confirmation (v1.3.3 : « Logs de diagnostic exportés ») visible via un +nouveau dump UI. + +**6. Vérifications post-fix** : Doses (en-tête « temps sous THS » + liste), +Settings → Export logs (SAF + SAVE + message), export/import JSON, changelog +dialog au premier lancement (sinon re-seed un `changelog_seen_version`). +⚠️ Les coordonnées UI (x/y) évoluent avec l'écran/l'app : TOUJOURS re-dumper +`uiautomator` avant de taper — ne jamais figer des coordonnées hors dump. + ## 17. Montre : Gadgetbridge & options Doc dédiée : [MONTRE-GADGETBRIDGE.md](MONTRE-GADGETBRIDGE.md). Synthèse : @@ -1062,6 +1185,11 @@ Volontaires (v1) : explicites, mais R8 ne se vérifie pas en tests JVM → **smoke-test sur téléphone** (export/import backup, graphiques) avant chaque publication ; signé clé debug → upgradable sans perte, mais pas une signature « officielle » +- **Lint (v1.3.4)** : `./gradlew lint` est vert — `StringFormatMatches` et + famille restent des ERREURS bloquantes ; les checks Compose 1.12+ + théoriques (`NonObservableLocale`, `LocalContextGetResourceValueCall`, + staleness de configuration) sont rétrogradés en WARNING via `app/lint.xml` + → à re-traiter lors d'une refonte i18n (§20) ## 20. Idées d'évolution @@ -1135,11 +1263,25 @@ Sur le téléphone de test (à compléter par l'utilisatrice) : accord, l'événement récurrent apparaît dans l'app d'agenda du Pixel 9 ; le smoke-test R8 de la checklist §16 doit aussi couvrir export/import de backup + graphiques (réflexion Gson sous R8) +- [ ] **v1.3.4** : **écran Doses** s'ouvre et affiche l'en-tête « temps sous + THS » avec les données réelles (bug #47 : crash dès que des doses + antérieures à aujourd'hui existaient — né v1.3.1) +- [ ] **v1.3.4** : **export des logs** ouvre VRAIMENT le gestionnaire de + fichiers et le message « Logs de diagnostic exportés » apparaît après + la sauvegarde (bug #48 : crash au tap depuis v1.3.1) — vérifié sur + émulateur API 36 (§16.ter), à confirmer au téléphone +- [ ] **v1.3.4** : rappel de notification affiche la dose proprement + (lint #49, ex format « 4.0 ») +- [ ] **v1.3.4** : `./gradlew lint` vert — intégré à la vérification de + release (step 2 de la checklist §16 désormais : tests + lint) --- -*Doc mise à jour le 6 sept. 2026 (v1.3.3) — build OK, 87/87 tests verts (44 sans les -données locales), dépôts Gitea (cloudyfy + farewell) avec releases APK, aucune donnée +*Doc mise à jour le 6 sept. 2026 (v1.3.4) — build OK, lint vert, 90/90 tests verts (72 sans +les données locales), dépôts Gitea (cloudyfy + farewell) avec releases APK, aucune donnée de santé dans le dépôt ni l'historique. v1.3.3 = session de reprise de maintenance : -3 bugs racines corrigés (permissions agenda, export logs, bump de version) ; doc -raccourcie (DB v3, targetSdk 36, régression 3 = export v1.3.1, numéros #38/#43 rétablis, -staleness §19/§20 nettoyé).* +3 bugs racines corrigés (permissions agenda, export logs, bump de version). v1.3.4 : +2 crashs remontés reproduits sur émulateur avec les données réelles (recette §16.ter, +scripts/seed-emulator.py versionné) — bugs #47 (stringResource arity, né v1.3.1) et #48 +(LocalDate + pattern horaire au tap), lint mis en filet bloquant (#49, app/lint.xml), +ExportFileNames + 3 tests. Dette connue : fragments de labs dans l'historique git +(v1.1.0→v1.2.3) — cf §8.bis.* diff --git a/docs/GUIDE_INSTALLATION.md b/docs/GUIDE_INSTALLATION.md index a03feca..7dedc81 100644 --- a/docs/GUIDE_INSTALLATION.md +++ b/docs/GUIDE_INSTALLATION.md @@ -108,9 +108,12 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei - Choisis bien l'unité : elle est convertie automatiquement pour l'affichage et la calibration (E2 en pg/mL ; T en ng/mL, ng/dL, ng/L, nmol/L) 4. **Calibration** (dans l'édition d'un traitement E2) → « Calibrer avec les analyses » - - Paramètres → **Logs de diagnostic** : exporte le journal de l'app (utile - en cas de bug à signaler) - → calcule le facteur d'échelle = médiane(lab ÷ prédiction), comme le « Scale factor » du `.ods` + - Paramètres → **Logs de diagnostic** : « Exporter » ouvre le gestionnaire de + fichiers → choisis où enregistrer le .txt → un message de confirmation + (ou d'erreur) s'affiche en haut de Paramètres — utile pour joindre les + logs à un rapport de bug (≥ v1.3.4 ; les versions 1.3.1–1.3.3 + faisaient planter ce bouton) + → calcule le facteur d'échelle = médiane(lab ÷ prédiction), comme le « Scale factor » du `.ods` 5. **Paramètres** : - **Langue** : Système / Français / English - **Estimation T** : modèle `T = plancher + (base − plancher) ÷ (1 + k·E2)` (ng/mL), @@ -164,6 +167,7 @@ Les profils viennent de ta feuille `Estrogen.ods` (table « Models ») : | Notif absente sur la montre | Huawei Santé → Notifications → autorise l'app ; redémarre la montre | | Rappels en retard | Paramètres → « Accorder les alarmes exactes » + désactive l'optimisation de batterie pour l'app | | Import JSON échoué | Le fichier doit venir d'un export de l'app même version (IDs conservés) | +| L'export des logs ou JSON a planté (versions ≤ 1.3.3) | Mettre à jour vers la v1.3.4+ (fixons confirmés sur émulateur) — le message de confirmation doit apparaître en haut de Paramètres | ## 10. Données & vie privée diff --git a/scripts/seed-emulator.py b/scripts/seed-emulator.py new file mode 100755 index 0000000..82df581 --- /dev/null +++ b/scripts/seed-emulator.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Seed la DB Room d'un émulateur avec un backup JSON exporté par l'app. + +OUTIL DE TEST MANUEL (post-v1.3.4) — cf recette complète dans +docs/DEVELOPPEMENT.md §16.ter « Test manuel sur émulateur ». + +Constitue l'étape « données réelles dans l'émulateur » : lance l'APK debug une +fois (crée la DB via Room), récupère la DB via run-as, injecte les lignes du +backup passé en argument (SANS toucher au schéma ni à room_master_table — Room +valide le schéma, pas les lignes), puis la repousse. + +Usage (le téléphone = émulateur démarré, avec com.hormonetrack - APK DEBUG - +installé et lancé au moins une fois) : + + adb shell am force-stop com.hormonetrack + adb shell run-as com.hormonetrack cat databases/hormonetrack.db > /tmp/hrt.db + python3 scripts/seed-emulator.py /tmp/hrt.db # modifie la BD locale + adb push /tmp/hrt.db /data/local/tmp/hrt.db + adb shell run-as com.hormonetrack cp /data/local/tmp/hrt.db databases/hormonetrack.db + adb shell run-as com.hormonetrack rm -f databases/hormonetrack.db-wal databases/hormonetrack.db-shm + +⚠️ PRIVATITÉ : le fichier backup contient des données de santé personnelles et +vit sous `local-test-data/` (gitignoré, cf §8.bis). Ce script ne contains +AUCUNE donnée : tout passe par arguments/lignes insérées à l'exécution. Ne +JAMAIS copier un backup dans le dépôt ni en nommer dans les commentaires. +""" +import json +import sqlite3 +import sys + + +def main(): + if len(sys.argv) != 3: + sys.exit("usage: seed-emulator.py ") + db_path, backup_path = sys.argv[1], sys.argv[2] + + data = json.load(open(backup_path, encoding="utf-8")) + + con = sqlite3.connect(db_path) + cur = con.cursor() + + # ⚠️ Les colonnes portent les noms des propriétés Kotlin SANS renommage + # (Room ne convertit pas en snake_case) ; les enums sont stockés en String. + for tr in data["treatments"]: + cur.execute( + "INSERT OR REPLACE INTO treatments (id, name, type, route, doseAmount," + " doseUnit, isActive, notes, esterType, pkModel, absorptionHours," + " eliminationHalfLifeHours, bioavailabilityFraction, scaleFactor," + " forecastIntervalDays, reminderHour, reminderMinute, reminderEnabled," + " calendarEventId, createdAt)" + " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + tr["id"], tr["name"], tr["type"], tr["route"], tr["doseAmount"], + tr["doseUnit"], tr["isActive"], tr.get("notes"), + tr.get("esterType", "NONE"), tr.get("pkModel", "ESE"), + tr.get("absorptionHours", 4.0), + tr.get("eliminationHalfLifeHours", 24.0), + tr.get("bioavailabilityFraction", 1.0), tr.get("scaleFactor", 1.0), + tr.get("forecastIntervalDays"), tr.get("reminderHour"), + tr.get("reminderMinute"), tr.get("reminderEnabled", False), + tr.get("calendarEventId"), tr["createdAt"], + ), + ) + + for d in data["doseLogs"]: + cur.execute( + "INSERT OR REPLACE INTO dose_logs (id, treatmentId, timestamp," + " doseAmount, notes, esterType) VALUES (?,?,?,?,?,?)", + (d["id"], d["treatmentId"], d["timestamp"], d["doseAmount"], + d.get("notes"), d.get("esterType")), + ) + + for l in data["labResults"]: + cur.execute( + "INSERT OR REPLACE INTO lab_results (id, marker, value, unit," + " timestamp, notes) VALUES (?,?,?,?,?,?)", + (l["id"], l["marker"], l["value"], l["unit"], l["timestamp"], + l.get("notes")), + ) + + con.commit() + cur.execute( + "SELECT (SELECT COUNT(*) FROM treatments), (SELECT COUNT(*) FROM dose_logs)," + " (SELECT COUNT(*) FROM lab_results)") + t, d, l = cur.fetchone() + print(f"seed OK : {t} traitements, {d} doses, {l} labs") + con.close() + + +if __name__ == "__main__": + main()