113 KiB
Documentation de développement — HormoneTrack
Doc de référence pour toute future session (humaine ou IA) : contexte, décisions, architecture, maths, build, tests, bugs corrigés, montre, évolutions. Projet :
~/projects/HormoneTrack— voir aussi README.md, GUIDE_INSTALLATION.md, MONTRE-GADGETBRIDGE.md.
Table des matières
- Contexte & objectifs
- Historique du projet
- Stack & versions (épinglées)
- Environnement de build (cette machine)
- Architecture générale
- Modèle de données (Room) + 6.bis Sémantique isActive
- Moteur pharmacocinétique
- Tests unitaires + 8.bis Données de test hors dépôt
- Système de rappels
- UI & navigation
- Graphiques (CurveChart)
- i18n FR/EN
- Sauvegarde JSON
- Bugs corrigés (historique complet — à ne pas réintroduire)
- Comment régénérer l'asset pk_profiles.json
- Workflow build / test / install + 16.bis Releases Gitea
- Montre : Gadgetbridge & options
- Espace disque & coûts
- Limites connues & choix volontaires
- Idées d'évolution (Phase 2+)
- Checklist de test manuel
1. Contexte & objectifs
Utilisatrice : femme trans, THS (thérapie hormonale), injections d'estradiol (esters
EV/EU/EEn, switchables) ± anti-androgènes. Elle tient déjà un suivi rigoureux dans
LibreOffice Calc (Estrogen.ods, cf §7.1) avec deux modèles PK : Estrannaise
(EstraNase) et Transfem Science. L'app doit reproduire fidèlement ces modèles.
Montre : Huawei Watch GT 3 (HarmonyOS 4.0.0.120) = Lite Wearable, pas d'apps Android, apps tierces au poignet quasi impossibles (cf §17). Utilisatrice équipe de Gadgetbridge (FOSS) sur son téléphone → v1 = app téléphone + notifications miroir sur la montre via GB (ou Huawei Health).
Développement IA-assisté : le code a été produit avec un assistant IA ; la contribution humaine = feedback continu, retours utilisateur (tests réels sur téléphone, bugs avec exports), suggestions et validation des releases (détail §2 — factuel, session par session).
Fonctionnalités v1 :
- Courbes estimées heure par heure : E2 (pg/mL) + T (ng/mL) — 24 h / 7 j / 30 j
- Deux modèles PK du
.ods(Estrannaise / TFS) pour injections EV/EU/EEn ; Bateman paramétrable pour gel/patch/oral - Log des doses (date/heure exacte, mg, ester par injection)
- Labs (E2/T/PRL) + calibration (facteur d'échelle + calibration k du modèle T)
- Rappels quotidiens, actions « Pris » / « Reporter 1 h » dans la notification
- Export/Import JSON, FR/EN, 100 % local
2. Historique du projet
| Date | Événement |
|---|---|
| 5 sept. 2026 (session 1) | Plan, vérification GT 3 = Lite Wearable, création couche données + ancien moteur Bateman + ancien ReminderManager. Extraction des modèles du Estrogen.ods → /tmp/pk_models.json (6 profils × 8001 h + params D/k1–k3). |
| 5 sept. 2026 | L'utilisatrice mentionne un travail d'un assistant tiers « Mimo V2.5 » : aucune trace trouvée (fichiers identiques à la session 1, timestamps identiques). Reprise depuis l'état existant. Bugs trouvés au passage : settings.gradle, BootReceiver, cancel PendingIntent. |
| 5 sept. 2026 (session build) | Redesign données (ester/pkModel/scaleFactor), réécriture moteur PK sur tables ODS, modèle T + calibration, rappels complets, UI 6 écrans, chart Canvas, backup JSON, i18n, wrapper Gradle, guide. Installation SDK Android (brew) + premier build. |
| 5 sept. 2026 (session tests/docs) | Correction de toutes les erreurs de compilation (dont 3 vrais bugs logiciels trouvés par les tests), 24 tests unitaires verts, APK debug généré (18 MB), documentation complète (README + docs/), préparation repo git. |
| 5 sept. 2026 (session v1.1.0) | Bugs remontés par l'utilisatrice : courbes vides (casse EEn) + pas d'édition des doses → corrigés ; régression épinglée sur ses données réelles ; APK v1.1.0. |
| 5 sept. 2026 (session v1.2.0) | Repo git initialisé (commits par couche + tags de release) ; montée toolchain AGP 9.4/Gradle 9.7.1/Kotlin 2.3.21/BOM 2026.08.00/compileSdk 37 ; panoramique du chart, superposition des deux modèles, prévision par « Fréquence », calibration automatique optionnelle, intervalles entre doses, TimePicker centré ; 36 tests verts, APK v1.2.0 (23 MB). |
| 5 sept. 2026 (session builds release) | Question debug vs release → builds release optimisés (R8 full mode + shrinkResources, 20 Mo → 2,4 Mo, signés avec la clé debug → upgradables sans perte) ; garde-fous Gson dans proguard-rules.pro (réflexion) ; releases publiées avec les deux APK (release recommandé + debug) ; 62 tests verts. |
| 5 sept. 2026 (session v1.2.5) | Bug graphique : double espace en haut (double insets) → edge-to-edge propre (enableEdgeToEdge + insets consommés une seule fois) ; 62 tests verts, APK v1.2.5 + release. |
| 5 sept. 2026 (session v1.2.4) | Bug isActive (simulation effacée) → sémantique « drapeau administratif » (§6.bis) ; régression n°3 sur le 3ᵉ export réel (transition EV inactif → EEn actif, 38 doses, 22 labs) ; 62 tests verts, APK v1.2.4. |
| 5 sept. 2026 (session push Gitea + v1.2.3) | Push initial vers gitea.cloudyfy.fr/Siphonight/HormoneTrack (privé) après anonymisation de l'historique (filter-branch : les premiers commits embarquaient les valeurs réelles des tests) ; données de test réelles déplacées hors dépôt (local-test-data/ gitignoré, tests Assume-skippés) ; releases avec APK en pièce jointe ; pics/creux + calibration T par période d'ester ; 56 tests verts. |
| 5 sept. 2026 (session v1.2.1) | Remontées UX/utilisateur : calibration par période d'ester (labs EV → doses EV, labs EEn → doses EEn — corrige les courbes gonflées), lab T "pg/mL" neutralisé, édition des labs, tap accueil → Graphiques + mini-légende, auto-cal fusionnée (E2+T, un seul switch) ; 2ᵉ régression épinglée sur le nouvel export ; 44 tests verts, APK v1.2.1. |
| 5 sept. 2026 (session v1.2.2) | Toggle T masque aussi les labs T ; prise de sang E2 + T en une entrée (chacune optionnelle) affichée côte à côte ; sélecteur d'édition par marqueur ; suppression par prise ; 4 tests de regroupement ; 48 tests verts, APK v1.2.2. |
| 5 sept. 2026 (session v1.2.6) | Valeurs estimées affichées sur les pics/creux (drawExtremum) ; import JSON en mode écrasement (bug : conflit d'IDs si données existantes) avec restauration du tConfig et reprogrammation des rappels ; docs ; 62 tests verts, APK v1.2.6 + releases. |
| 6 sept. 2026 (session v1.3.1) | En-tête « temps sous THS » sur Doses ; logs de diagnostic (AppLog : buffer 500 lignes, persisté, exportable de Paramètres) ; fix toggle agenda (callback async) ; 87 tests verts, APK v1.3.1 + releases. |
| 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.5) | Premier diagnostic à distance RÉUSSI via les logs exportés : les AppLog de la v1.3.4 prouvent export logs ok=true (fix #48 ✓ au téléphone), permission agenda accordée (fix manifest ✓) et révèlent le bug racine EXPLICITE : ensureCalendar plantait car l'URI sync-adapter n'embarquait pas ACCOUNT_NAME/ACCOUNT_TYPE (§14 #50). En validant sur émulateur, DEUX bugs découverts : delete d'event sans account (#50 bis — « supprimé » logué mais l'event restait) et buildTreatment sans calendarEventId (#51 — événements orphelins). Cycle ON→save→OFF→save validé end-to-end (content query). Publication v1.3.5. |
| 6 sept. 2026 (session v1.4.0) | Modèle TFS reconstruit sur la méta-analyse officielle (params V3C du simulateur transfemscience.github.io/injectable-e2-simulator, extraits de ester-data.js/calc-curve.js) : forme close 3 compartiments, 7 esters (EB/EC/ECS/PEP ajoutés), pics de l'article reproduits à ~1 % (AUC + Figure 11 épinglés). + Fix #52 (remontée) : rappels quotidiens même hors jour d'injection → grille Posologie (moteur = source unique), re-programmation après notif et « Pris ». 12 nouveaux tests (102 verts), lint vert. Publication v1.4.0. |
| 6 sept. 2026 (session v1.4.1) | UX graphique et carte « Prochaine dose » (remontées) : (a) activer la Prévision ne saute PLUS dans le futur — la fenêtre reste en place, la projection s'étend au-delà et se parcourt en tirant vers la gauche (panHours NÉGATIF = futur, clamp unique clampPanHours, horizon 12× Posologie borné 30 j–1 an) ; (b) delta en JOURS sur la carte « Prochaine dose » au-delà de 24 h (HrtDuration.daysAndHours + next_dose_days FR/EN). 5 tests (112 verts). Publication v1.4.1. |
| 6 sept. 2026 (session v1.4.2) | Seuils d'alerte configurables + notification (demande) : limites E2/T hautes/basses (Paramètres, opt-in, validées haut > bas), cartes d'avertissement sur l'accueil (niveau ESTIMÉ actuel), notification via un worker WorkManager périodique 15 min (enfin utilisé !) + check one-time au save, anti-spam par état persisté (Alerts.encodeState/shouldNotify), canal dédié hormonetrack_alerts. + backup JSON v2 : les PARAMÈTRES utilisateur (langue, auto-cal, seuils) voyagent dans l'export et sont restaurés à l'import (rétrocompat v1, -keep R8 pour UserSettings). 9 tests (129 verts). Publication v1.4.2. |
| 7 sept. 2026 (session v1.4.3) | Question pharmacocinétique → 1 bug + 1 confirmation : « l'estimation ne remonte pas le lendemain de mon injection EEn » → (a) comportement NORMAL (contribution J+1 < 12 % du total, plateau d'équilibre — épinglé par la régression n°4 sur le 4ᵉ export réel) ; (b) bug #53 : le delta « vs il y a 6 h » de l'accueil comparait en réalité il y a 24 h (firstOrNull sur la fenêtre 24 h) → helper pointHoursBefore + 4 tests. 137 verts. Publication v1.4.3. |
| 7 sept. 2026 (session v1.4.4) | Prévision inaccessible depuis v1.4.1 (#54, remontée) : DEUX causes emboîtées — (a) captures FIGÉES dans pointerInput(Unit) (futurePanHorizon lu à 0 pour toujours → pan futur interdit) → rememberUpdatedState + garde de source (ChartScreenSourceGuardTest, le lint Compose ne voit pas ce pattern) ; (b) toggle sans effet visible (endMs = now → 0 dose générée) → extension contrôlée à droite (forecastExtensionHours, jusqu'au 1ᵉʳ créneau, sans déplacer le début). Validé émulateur : drag gauche → panHours < 0, courbe dessinée au-delà de « maintenant » (screencap). 140 verts. Publication v1.4.4. |
| 7 sept. 2026 (session v1.4.5) | « La prévision simule le 13 au lieu du 12 » (#55) : diagnostic sur données réelles — GÉNÉRATION EXACTE (créneau = dernière dose loguée + 6 j ; subtilité : la dose avait été loguée le LENDENDE de l'injection réelle → dérive d'un jour par cycle, l'app suit les logs) ; illusion causée par l'absence de marqueur + labels X à minuit UTC. FIX : marqueurs de doses (prévisionnels pointillés + réels discrets), labels à minuit LOCAL, fuseau de graphique configurable (Paramètres). + #56 : boucle de recomposition (nowMs relu à chaque frame → endMs dérive → saturation main thread → taps perdus) → mémoïsé sur le tick. 145 verts. Publication v1.4.5. |
| 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. |
| 5 sept. 2026 (session v1.2.8) | « Fréquence d'injection » → « Posologie » (terme adapté à toutes les voies) ; quirk Gitea découvert (noms d'assets normalisés à l'upload) → renommage PATCH dans le script ; releases avec les 2 APK ; 64 tests verts. |
| 5 sept. 2026 (session v1.2.7) | Prévision : les créneaux passés ne sont plus simulés (bug oubli → faux pic historique) ; retard = décalage voulu ; mismatch doc-code §7.3b corrigé ; 2 tests ; 64 tests verts, APK v1.2.7 + releases. |
Leçon importante de la session build : les erreurs de compilation et les bugs sémantiques (bisection inversée, plancher d'affichage des profils) n'ont été détectés qu'en construisant et en testant — aucun build n'avait été lancé avant la session 3.
3. Stack & versions (épinglées — à jour v1.2.0)
| Composant | Version | Où |
|---|---|---|
| Gradle | 9.7.1 (wrapper) | gradle/wrapper/gradle-wrapper.properties |
| AGP | 9.4.0 — Kotlin intégré : ne PAS appliquer org.jetbrains.kotlin.android ; kotlinOptions supprimé (cible JVM via compileOptions, 17) |
build.gradle.kts racine |
| Kotlin | 2.3.21 (plugin compose 2.3.21) | idem |
| KSP | 2.3.11 (versionnage indépendant depuis KSP2) | idem |
| Compose BOM | 2026.08.00 (Compose 1.12 ; material3 pinné par le BOM) | app/build.gradle.kts |
| Room | 2.8.4 (KSP) — DB v3 + MIGRATION_1_2 (forecastIntervalDays) + MIGRATION_2_3 (calendarEventId), fallbackToDestructiveMigration retiré |
idem + AppDatabase.kt |
| Navigation Compose | 2.10.0 | idem |
| AppCompat | 1.8.0 (langue par app) | idem |
| DataStore Preferences | 1.2.1 | idem |
| Gson | 2.14.0 | idem |
| JUnit | 4.13.2 (testImplementation) | idem |
| WorkManager | 2.11.2 (UTILISÉ depuis v1.4.2 : worker périodique des seuils d'alerte, cf §9.bis) | idem |
| compileSdk / targetSdk | 37 / 36 ; minSdk 26 ; Java target 17 | app |
| buildFeatures | compose + buildConfig (VERSION_NAME pour l'app) | app |
Notes importantes (v1.2.0) :
- AGP 9 : Kotlin est intégré à AGP — appliquer
org.jetbrains.kotlin.androidest une erreur ; le pluginorg.jetbrains.kotlin.plugin.composereste appliqué normalement. - compileSdk 37 : la plateforme
platforms;android-37n'était pas dans sdkmanager (API 37 en preview à la date du build) mais AGP l'a auto-téléchargée (licences signées) — le build passe. - Compose 1.12 (BOM 2026.08.00) exige compileSdk ≥ 37 et AGP ≥ 9.1 ; le BOM 2026.06.01 est le dernier compatible compileSdk 36.
- Material Expressive :
MaterialExpressiveTheme/ExperimentalMaterial3ExpressiveApisont encore internal dans la ligne material3 pinnée par ce BOM (erreur de compilation vérifiée — javap montrepublicJVM mais la visibilité Kotlin est internal).MaterialThemestandard conservé ; basculer dès que l'API devient publique (NOTE dansui/theme/Theme.kt). - Kotlin 2.0 → compose compiler via
org.jetbrains.kotlin.plugin.compose. Room convertit les enums ↔ String automatiquement. Ne pas monter Kotlin/AGP/Gradle sans vérifier la matrice de compatibilité (les versions sont récupérées via maven-metadata.xml de dl.google.com / repo1.maven.org, pas devinées).
4. Environnement de build (cette machine)
- macOS (Apple Silicon), brew présent, Java 21 (Microsoft OpenJDK) sur
/usr/bin/java✓ - Téléphone de test : Google Pixel 9 sous /e/OS (ROM dé-Googlée, base AOSP) —
SAF/DocumentsUI standard (l'export JSON via
CreateDocumenty fonctionne → c'est LE pattern d'IO de référence pour tout export de fichier) ; pas de Play Store, sideload paradb installou 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 :emulator37.1.11 +system-images;android-31;aosp_atd;arm64-v8a(ATD : léger, boot rapide, MAIS DocumentsUI STUBBÉ (fakesystemapp) → SAF non pilotable) etsystem-images;android-36;google_apis;arm64-v8a(image complète — REQUISE pour tester les flux SAF). AVDhrt(ATD 31) ethrt36(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 - paquets :
platform-tools,platforms;android-34,build-tools;34.0.0
- licences acceptées :
local.propertiesà la racine (non commité) :sdk.dir=/opt/homebrew/share/android-commandlinetools- Gradle 8.9 téléchargé par le wrapper ; caches
~/.gradle≈ 1,5 GB - Build validé :
./gradlew assembleDebug testDebugUnitTest→ BUILD SUCCESSFUL, APK debug 18 MB (app/build/outputs/apk/debug/app-debug.apk)
5. Architecture générale
Pas de ViewModel ni de DI externe — volontairement simple pour une v1 :
HormoneTrackApp (Application)
└─ AppContainer
├─ AppDatabase (Room singleton)
├─ HormoneRepository (DAOs : Flow réactifs + one-shots suspend)
└─ AppSettings (DataStore : TConfig, langue)
MainActivity (AppCompatActivity)
└─ setContent { HormoneTrackTheme { HormoneTrackRoot } }
├─ CompositionLocal LocalAppContainer
└─ NavHost + NavigationBar (5 tabs + settings + treatment_edit/{id})
Écrans = collectAsState sur les Flows + calcul PK dans produceState(Dispatchers.Default)
Points clés :
HormoneTrackApp.onCreate(): initPKProfileStore(asset), canal de notificationMainActivity: applique la langue sauvegardée (AppCompatDelegate.setApplicationLocales), demande POST_NOTIFICATIONS (API 33+), lit les extras d'intentopen_log_dose+treatment_id(venus de la notification) → Home pré-ouvre le dialog de log- Tout calcul PK est hors UI thread (
produceState+Dispatchers.Default)
6. Modèle de données (Room)
DB hormonetrack.db, version 3, migrations explicites (⚠️ plus de
fallbackToDestructiveMigration — retiré en v1.2.0 car l'utilisatrice a des données
réelles ; toute évolution de schéma = Migration(x, y) + ALTER TABLE) :
- v1 → v2 :
forecastIntervalDays REAL(prévision, v1.2.0) - v2 → v3 :
calendarEventId INTEGER(agenda récurrent, v1.3.0)
Treatment (treatments)
- base :
id,name,type(ESTRADIOL/ANTI_ANDROGEN/PROGESTOGEN/OTHER),route(ORAL/TRANSDERMAL_GEL/TRANSDERMAL_PATCH/INJECTION_IM/INJECTION_SUBCUT/OTHER),doseAmount,doseUnit,isActive,notes,createdAt - PK par table :
esterType("NONE"/"EV"/"EU"/"EEN" — objetsEsters),pkModel("ESE"/"TFS" — objetsPKModels) - PK Bateman :
absorptionHours(Tmax),eliminationHalfLifeHours,bioavailabilityFraction - Calibration :
scaleFactor(défaut 1.0) - Prévision (v1.2) :
forecastIntervalDays: Double?(jours ; null = pas de simulation à venir) — colonne ajoutée par la migration Room v1→v2 - Rappel :
reminderHour/Minute/Enabled - Helpers :
isInjection(IM/SC),usesProfileModel(injection et ester ≠ NONE)
DoseLog (dose_logs)
FK → treatments (CASCADE), index treatmentId + timestamp. esterType: String? =
override par injection (l'ODS permet de switcher d'ester d'une injection à l'autre) ;
null = ester du traitement.
LabResult (lab_results)
marker libre ("E2", "T", "PRL"…), value, unit libre. La calibration et les charts
comparent marker.equals("E2", true) / "T" — les dropdown suggèrent E2/T ; si
l'utilisatrice tape autre chose, la calibration ignorera ces labs.
DAOs
Flow pour l'UI + one-shots suspend *Once() pour backup/boot/calibration :
TreatmentDao.getActiveOnce/getAllOnce, DoseLogDao.getAllOnce, LabResultDao.getAllOnce.
6.bis Sémantique isActive (v1.2.4) — drapeau administratif, JAMAIS un filtre
Treatment.isActive = false signifie « ce traitement ne produit plus de nouvelles
doses ». Ce n'est PAS un filtre de données — passer un traitement à inactif ne
doit rien effacer (bug v1.2.4 : Home/Chart collectaient activeTreatments, la
simulation de tout l'historique EV disparaissait) :
| Effet de l'inactivation | Où |
|---|---|
| ✅ Retiré des chips « Log rapide » (accueil) | HomeScreen → activeTreatments |
| ✅ Retiré du dropdown des nouvelles doses | DosesScreen : création → actifs only ; édition → tous (une dose existante reste rattachée à son traitement inactif) |
| ✅ Rappels annulés (au save, au boot, au calcul de « prochaine dose ») | rescheduleAll + nextReminderFireMs (filtre isActive) ; BootReceiver lit getAllOnce() pour annuler les alarmes des inactifs |
| ❌ JAMAIS retiré de la simulation | Home/Chart passent allTreatments au moteur — les écrans ne filtrent jamais |
| ❌ JAMAIS retiré de la calibration (auto) | autoCalibrated(allTreatments, …) |
| ❌ JAMAIS supprimé de l'historique des doses | DosesScreen liste tout, suppression manuelle uniquement |
Épinglé par la régression n°3 (RegressionUserCase3Test, cf §8) : EV inactif + EEn
actif en même temps, les 22 labs couvrant les deux périodes doivent rester simulés
et calibrés.
7. Moteur pharmacocinétique
pk/PharmacokineticEngine.kt + pk/PKProfileStore.kt.
7.1 Source : Estrogen.ods
- Fichier : le tableur
Estrogen.odsde l'utilisatrice (Owncloud, 30 MB, 13 tables) - 6 tables nominatives (surnoms anonymisés) : historique injections (datetime, cuisse L/R, ester, dose mg, Z-track) + labs (E2 pg/mL, T ng/mL) + facteur d'échelle manuel par profil (valeurs entre 0,5 et 1,4)
- Table « Models » : paramètres D, k1, k2, k3 par ester×modèle + profils horaires normalisés (pg/mL par mg) sur 8001 h — ce sont ces tables qui sont consommées
- Les profils affichent 2 décimales → plancher 0,01 / 0,00 en queue (conséquence importante, cf §7.2)
Pics de référence (pg/mL par mg) :
| Clé | Modèle | Ester | Pic | Tmax |
|---|---|---|---|---|
EV_ese |
Estrannaise | valerate | 61,12 | ~45 h |
EU_ese |
Estrannaise | undecylate | 3,44 | ~55 h (plateau très long) |
EEn_ese |
Estrannaise | enanthate | 31,35 | ~152 h |
EV_tfs |
Transfem Science | valerate | 58,96 | ~51 h |
EU_tfs |
Transfem Science | undecylate | 10,11 | ~198 h |
EEn_tfs |
Transfem Science | enanthate | 31,97 | ~156 h |
7.2 PKProfileStore (asset loader + échantillonnage)
- Asset
app/src/main/assets/pk_profiles.json:{ "params": {D/k1/k2/k3…}, "profiles": { "EV_ese": [8001 floats], … } }(550 KB, parse ~ms viaJsonParser) initWithJson(json)= point d'entrée testable (JVM) ;init(context)lit l'assetsample(ester, model, dtHours):- modèle strict : seul "TFS"→
tfset "ESE"→ese; tout autre → 0 (piège corrigé, cf §14) - interpolation linéaire entre heures entières
- extrapolation terminale : dernier point ≥ 1 % du pic (pour éviter le plancher d'affichage 0,01/0,00 de l'ODS), pente = décroissance moyenne sur les 48 h précédentes (jamais avant le pic)
- modèle strict : seul "TFS"→
- ⚠️ tous les calculs en Double (Float×Double n'existe pas en Kotlin — source d'erreurs de compilation, cf §14)
7.3 Superposition
Contribution d'une dose = sample(...) × dose_mg ; niveau total = somme des contributions
de toutes les doses E2, chacune multipliée par le scaleFactor de son traitement.
Coupure par dose : cutoffHours = longueur de table (8001 h) pour les profils,
30 × t½ pour Bateman.
7.3b Override de modèle + prévision + auto-calibration (v1.2)
modelOverride: paramètre optionnel deconcentrationOfDose/e2At/computeCurvequi force ESE ou TFS pour les traitements par profil — le graphique dessine les deux modèles côte à côte depuis le même traitement (Bateman non concerné : les deux séries y sont identiques).generateForecastDoses(treatment, doseLogs, toMs, nowMs): projette les doses à venir = dernière dose réelle + k ×forecastIntervalDays, strictement aprèsnowMs(v1.2.7 : les créneaux déjà passés — cas d'un OUBLI — sont sautés au rythme configuré, sinon faux pic dans l'historique ; un RETARD décale toute la prévision) ; dose = standard du traitement, ester = override de la dernière injection. Jamais persistées : uniquement passées àcomputeCurvepar le ChartScreen quand le chip « Prévision » est actif.autoCalibrated(treatments, doseLogs, labs, tConfig): option « Calibration automatique » (v1.2.1+) — renvoieesterScales(facteur par ester, cf §7.6)tKPerEster(k T par ester, cf §7.5) recalculés depuis les labs ;treatmentsettConfiginchangés (fallback par ester absent = valeurs stockées). HomeScreen et ChartScreen branchent dessus quand l'option est active.
7.4 Bateman (gel/patch/oral)
C(dt) = (F·D·ka/(ka−ke))·(e^(−ke·dt) − e^(−ka·dt)) ; cas dégénéré ka≈ke :
F·D·ke·dt·e^(−ke·dt). computeKa résout ln(ka/ke) = (ka−ke)·Tmax par bisection
(50 itérations, bornes ke×1.001 … ke×1000) — direction corrigée (cf §14 : eq > 0
⇒ la racine est au-dessus de mid ⇒ lo = mid).
7.5 Courbe T (empirique)
T(t) = floor + (base − floor) / (1 + k(ester actif) · E2_calibrée(t)) [ng/mL].
Défauts TConfig : base 6.0, floor 0.2, k 0.19 (→ T≈0,4 à E2≈150). Non issu du
.ods (qui ne modélise pas la T) — modèle d'inhibition simple, étiqueté
« estimation » partout.
- k par période d'ester (v1.2.3) : la suppression T diffère selon l'ester
(valerate = pics hauts et courts, enanthate = plateau plus doux) →
computeTKPerEsterattribue chaque lab T à la période de la dernière dose ≤ lab et k = médiane des k de cette période ; la courbe utilise à chaque instant le k de l'ester actif (activeEsterAt, curseur sur les doses triées danscomputeCurve), fallback =tConfig.kstocké. - Formule :
k_i = ((base−floor)/(T_lab − floor) − 1)/E2_est(t_lab), garde k ∈ (1e-4, 10). ⚠️ L'E2 utilisée est la version calibrée (scalePerEster) — calibrer k contre une E2 brute faussait les k (corrigé v1.2.3). computeTConfigCalibration(k global unique) reste pour le bouton manuel « Calibrer avec les analyses » des Paramètres.
Unités : les labs T peuvent être saisis en ng/mL, ng/dL, ng/L ou nmol/L —
convertTToNgMl(value, unit) normalise (ng/dL ÷100, ng/L ÷1000, nmol/L ×0,2884,
pg/mL ÷1000 défensif) ; appliqué à la calibration ET au rendu du chart (sinon l'axe
T est faux d'un facteur 100, bug réel remonté par l'utilisatrice : labs 33/44 ng/dL).
7.6 Calibration (v1.2.1 : PAR PÉRIODE D'ESTER pour l'auto)
Automatique (computeEsterScaleFactors + scalePerEster, option « Auto-calibration ») :
- chaque lab est attribué à la période d'injection dans laquelle il tombe = dernière dose E2 ≤ lab (une prise de sang reflète d'abord l'injection qui précède) ;
- ratio = lab ÷ prédiction non calibrée (toutes doses superposées, scaleFactor forcé 1) ;
- facteur final par ester = médiane des ratios de sa période (EV/EU/EEN) ;
- application :
e2At/computeCurveacceptentscalePerEster: Map<String, Double>?— chaque dose est scalée par le facteur de son ester (doseEster, override compris), fallback =scaleFactorstocké du traitement pour les esters sans lab. - Pourquoi : un facteur unique par traitement mélangeait les périodes (labs valerate mesurés contre une prédiction enanthate → ratio aberrant → courbes gonflées à 250–375 pg/mL, remontée v1.2.0). Cas vérifié sur données réelles : EEn 5 mg tous les 6–7 j (t½ ≈ 6,7 j) → accumulation ×2 → ~270 pg/mL calibré, cohérent labs 300/250 ; non calibré ≈ 367.
autoCalibrated()renvoieAutoCalibrated(treatments **inchangés**, tConfig recalibré, esterScales, calibratedEsters, tRecalibrated)— écrans :scalePerEster = effectiveAuto?.esterScales.
Manuelle (computeScaleFactor, bouton « Calibrer avec les analyses » dans
l'éditeur de traitement) : facteur unique par traitement (médiane lab ÷ prédiction,
garde prédiction > 0,5), écrit le scaleFactor stocké. ⚠️ Limite documentée : en cas
de changement d'ester dans un même traitement, la manuelle mélange les périodes —
préférer l'auto-calibration dans ce cas.
T (computeTConfigCalibration) : k_i = ((base−floor)/(T_lab − floor) − 1)/E2_est,
garde k ∈ (1e-4, 10), médiane ; labs normalisés via convertTToNgMl.
7.7 API du moteur
levelAt / currentLevel / computeCurve(start, end, step=1h, tConfig) / e2At / testosteroneAt / computeScaleFactor / computeTConfigCalibration / nextReminderFireMs(treatments, doseLogs, now) / nextReminderFireFor / batemanParams / concentrationOfDose / computeKa / doseEster / isInjectionRoute.
Type de retour : LevelPoint(timestamp, e2, t).
7.8 Modèle TFS V3C (v1.4.0) — pk/TransfemScienceModels.kt
Le modèle « Transfem Science » n'est PLUS dérivé des tables ODS : c'est la
forme close exacte du modèle à 3 compartiments de la méta-analyse
(https://transfemscience.org/articles/injectable-e2-meta-analysis/), avec les
paramètres D/k1/k2/k3 publiés par le simulateur officiel
(transfemscience.github.io/injectable-e2-simulator — fichiers ester-data.js
/ calc-curve.js, valeurs = données scientifiques des études) :
Cp(t) = D·k1·k2 · [ e^(−k1·t)/((k1−k2)(k1−k3)) + e^(−k3·t)/((k1−k3)(k2−k3))
+ e^(−k2·t)·(k3−k1)/((k1−k2)(k1−k3)(k2−k3)) ] (t en JOURS)
sample(ester, dtHours)=Cp(t_jours) / fitDose(5 mg)→ pg/mL PAR mg ; le moteur multiplie par la dose réelle, comme pour les tables ODS.- Branchement dans
concentrationOfDose: simodel == TFSet que l'ester a un V3C (EV, EEn, EU, EB, EC, ECS, PEP) →TransfemScienceModels.sample; sinon fallback tables ODS (Estrannaise, ou ester sans modèle). - Coupure moteur (
cutoffHours) : 10 demi-vies TERMINALES (t½ = ln2 / min(k1,k2,k3)) — ex. EV 30 j, PEP 284 j — au lieu de la longueur de table. - Compartiment k2 « ultra-rapide » (EU/PEP : k2 ~10⁵–10⁶ j⁻¹) : son terme décroît instantanément, double précision suffisante (testé).
- Sanity checks épinglés (±1–3 %) : pics/t½ des Tableaux 9–10, AUC par mg (EV 377,2 / EEn 436,6 / EC 430 / PEP 65,1 pg·j/mL/mg), état d'équilibre EV 5 mg/7 j (Cmax 384 / Cmin 142 / Cavg 269, Figure 11 de l'article).
- ⚠️ EU (undécylate) : ajustement sur données d'étude LIMITÉES (cf article §Limites) — précision moindre, conservé car c'est le meilleur disponible.
- Les valeurs des pics par mg restent cohérentes avec l'ancienne table ODS (la table en DÉRIVAIT) : EV 59 pg/mL/mg @ 51 h, EEn 31,97 @ 156 h — le changement est la FORME (queues exactes, pas d'arrondi 2 décimales).
8. Tests unitaires
145 tests JVM, tous verts (./gradlew testDebugUnitTest) — 123 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/…).
PKProfileStoreTest(8) : les 6 profils présents (8001 pts) ; pic EV_ese = 61,12 @45 h ; zéro avant injection ; interpolation stricte entre points (heures 100/101 — le plateau 45/46 est plat, piège de test) ; modèle inconnu → 0 ; extrapolation terminale décroissante ; esters longs mesurables à 8000 h ; les 6 pics == valeurs ODSPharmacokineticEngineTest(15) : ke = ln2/t½ ; pic Bateman ≈ Tmax (attrape la bisection inversée) ; EV 4 mg → pic ≈ 4×61 pg/mL ; superposition ; linéarité du scaleFactor ; anti-androgène → 0 en E2 ; modèle T monotone/borné ; calibration SF = médiane (0,5/0,9/1,4 → 0,9) ; calibration nulle sans labs ; calibration T récupère un k planté (0,25) ; grille horaire clampée à la 1ʳᵉ dose ; vide sans doses ; override d'ester par dose (EV≫EU à 45 h) ; prochain rappel dans le futur ; levelAt combinéBackupGsonTest(1) : round-trip JSON complet (enums, IDs, notes, TConfig)RegressionUserCaseTest(6) : régression épinglée sur les données réelles exportées par l'utilisatrice (backup JSON v1.0.0 : 1 traitement EEn/ESE 5 mg, 1 dose, 4 labs dont T en ng/dL). Vérifie : parsing du JSON réel, courbes non vides et physiologiquement plausibles pour 24 h/7 j/30 j (attrape le bug #22 de casse EEn), labs antérieurs à la 1ʳᵉ dose ignorés par la calibration SF, conversion ng/dL→ng/mL, calibration T avec labs en ng/dL. En cas de nouveau bug remonté par l'utilisatrice : exporter le JSON, l'épingler ici, reproduire, corriger.V120FeaturesTest(10) : v1.2.0→v1.2.3 — doses prévisionnelles (rythme 7 j depuis la dernière dose réelle, liste exacte J+4/J+11/J+18/J+25 ; vide sans intervalle ou sans doses ; ester override projeté), override de modèle (ESE ≠ TFS à 45 h pour EV ; sans override = modèle du traitement), auto-calibration v1.2.1 (facteur par ester depuis un lab planté, T recalibré, originaux non modifiés ; inchangée sans lab utilisable), attribution des labs par période d'ester (EV calibré par les labs EV, EEn par les labs EEn — le scénario valerate→enanthate de l'utilisatrice) et application descalePerEsterpar dose.RegressionUserCase2Test(6) : 2ᵉ régression épinglée sur données réelles (export v1.2.0 : 9 doses EEn/TFS 5 mg ~6-7 j, 8 labs dont un T "pg/mL" par erreur, SF stocké 0,72, fréquence 6 j). Vérifie : parsing, état d'équilibre EEn (t½ ≈ 6,7 j + doses ~6-7 j → accumulation ×2 → e2 ≈ 270 calibré, cohérent labs 300/250 ; non calibré ≈ 367 = les « 375 » rapportés), facteur unique EEN plausible, lab T en unité aberrante neutralisé, prévision 6 j exacte, auto-cal cohérente. Tout nouvel export utilisateur = un nouveau test de régression.RegressionUserCase3Test(6) : 3ᵉ régression (export v1.3.1 — mis à jour à la v1.3.2) — le scénario transition : traitement EV inactif (29 doses 2–8 mg, janvier→juillet) + traitement EEn actif (9 doses) + CPA oral (anti-androgène), soit 3 traitements, 51 doses, 26 labs. Vérifie : parsing, l'inactif reste simulé (bug v1.2.4 — le moteur reçoit TOUS les traitements), calibration par période couvrant EV et EEN, k T par ester, continuité de la courbe pendant la transition, niveau actuel ~EEn équilibre.
8.bis Données de test réelles : HORS dépôt (local-test-data/)
Les deux classes de régression (RegressionUserCase{,2}Test) épinglent le comportement
du moteur sur les exports réels de l'utilisatrice. Ce sont des données de santé
personnelles : elles ne sont pas versionnées, pour ne rien divulguer dans le
dépôt (ni maintenant, ni si le repo devient public un jour).
- emplacement :
local-test-data/backup-v1.0.0.json,backup-v1.2.0.json,backup-v1.2.3.json(ancien export, plus consommé par un test),backup-v1.3.1.jsonetbackup-v1.4.2.json(copiés tels quels depuis l'export JSON de l'app) ;RegressionUserCaseTestlit v1.0.0,RegressionUserCase2Testlit v1.2.0,RegressionUserCase3Testlit v1.3.1,RegressionUserCase4Testlit v1.4.2 (premier export avec les settings v2) ; .gitignorecontientlocal-test-data/→ jamais commités. GARDES DE CONFIDENTIALITÉ (rappel 7 sept. 2026, à chaque release) :git check-ignore -v local-test-data/…→ la règle matche ;git ls-tree -r <tag> --name-only | grep local-test-data→ VIDE avant publication ;git log --all -- local-test-data/→ vide (aucun commit n'y a jamais touché — vérifié à la v1.4.3) ;- le scan de confidentialité (working tree + §8.bis dette historique) ;
- les assertions des régressions sont DATA-DRIVEN (elles lisent le fichier local) — JAMAIS de valeur de lab ni de timestamp réel en dur dans le code (le scanner refuserait, et ce serait une fuite).
- les tests font
Assume.assumeTrue(file.exists())dans le@Before: sans le fichier, la classe est IGNORÉE (skipped, pas failed) — un clone neuf ou une CI exécute 44 tests au lieu de 87 ; - le workdir des tests Gradle est le dossier du module (
app/) → les tests cherchent les fichiers à plusieurs chemins (../local-test-data/…en premier) ; - pour les lancer : exporter un backup JSON depuis l'app → l'enregistrer sous
le nom attendu dans
local-test-data/→./gradlew testDebugUnitTest; - ⚠️ ne jamais embarquer ces données dans les tests (string inline dans le code) : tout nouvel export réel → fichier gitignore + assertions data-driven ;
- l'historique a été nettoyé avant le premier push : les premiers commits
embarquaient les valeurs (tests + doc) →
git filter-branch --tree-filteravec un script d'anonymisation (timestamps décalés de +30 j, valeurs perturbées dans la prose des docs), tags réécrits, refs purgeées. ⚠️ Lint de dette connue (constat v1.3.3) : le scanner travaillait sur une liste de motifs figée ; depuis quelocal-test-data/s'enrichit de NOUVEAUX exports (ex. backup-v1.3.1.json ajouté à la v1.3.2), la ré-extraction des valeurs fait que le scan--historydétecte des fragments JSON de labs encore présents dans l'historique des révisions v1.1.0 → v1.2.3 (testsRegressionUserCase{,2}data-driven AVANT la migration fichiers gitignorés ; fragments de la forme « value » + timestamp). Amplitude : quelques valeurs numériques de labs — les mêmes ordres de grandeur figurent déjà en PROSE dans les docs publiés (§14 #23, CHANGELOG 1.1.0). Le working tree est propre. Décision utilisateur (6 sept. 2026) : purge complète de l'historique NON relancée (les révisions sont déjà répliquées sur les deux Gitea, aucune fuite nouvelle par push) ; la dette (réécriture d'historique + re-tags + force-push des 2 remotes + re-publish des releases) reste ouverte et doit être re-décisionnée si le dépôt passe PUBLIC. LabsGroupingTest(4) : v1.2.2 — regroupement de l'écran Analyses (paire E2+T même timestamp ; timestamps différents séparés ; tri E2 avant T avant autres ; ordre chronologique décroissant).ChangelogHelperTest(8) : v1.3.0 — comparaison SemVer numérique (1.2.9 < 1.2.10 : la comparaison lexicographique serait fausse), extraction de sections depuis la dernière vue, première installation (section courante seule), plusieurs versions intermédiaires, versions non numériques.AppLogTest(4) : v1.3.1 — formatage des lignes horodatées, buffer circulaire (600 → 500 dernières, bordures).HrtDurationTest(6) : v1.3.1 — jours depuis la 1re prise (0 si futur/invalide), décomposition mois(30 j)/jours.CalendarRruleTest(3) : v1.3.0 — RRULEFREQ=DAILY;INTERVAL=N, arrondi demi-supérieur des décimales (6,5 → 7), clamp ≥ 1 jour.ExtremaTest(6) : v1.2.3 — détection des pics/creux (detectExtrema) : alternance stricte pic/creux en régime d'équilibre (4 doses hebdo → ≥ 4 extrema, pic > creux voisin, valeurs dans les bornes), courbe monotone → vide, série plate → vide, série trop courte → vide, filtre d'amplitude (sémantique zigzag : une oscillation sous le seuil produit UN pivot, l'alternance complète apparaît quand le seuil baisse), anti-corrélation E2/T (un pic d'E2 ≈ un creux de T).TransfemScienceModelsTest(5) : v1.4.0 — épingle le modèle TFS V3C sur l'article : pics/t½ des Tableaux 9–10 (±1–2 %), AUC par mg (±3 %), état d'équilibre EV 5 mg/7 j = Figure 11 (Cmax 384/Cmin 142/Cavg 269), esters sans modèle → 0, dt ≤ 0 → 0. Toute retouche de TransfemScienceModels.kt passe par CE test (c'est la fidélité au simulateur TFS qui est vérifiée).ChartZoomTest(13) : v1.2.9/v1.4.1/v1.4.5 — pas d'échantillonnage, horizon + clamp de panoramique,pointHoursBefore, extension de prévision,xLabelTicks(minuit LOCAL, fuseau CHOISI America/New_York, heures rondes, cas dégénérés).ChartScreenSourceGuardTest(1) : v1.4.4 — garde de SOURCE contre le bug #54 : vérifie que les vals de fenêtre du graphique sont exposées au gestionnaire de gestes viarememberUpdatedState(aliasgesture*) et que la closuredetectTransformGesturesne les lit jamais en direct (captures figées). Le lint Compose ne détecte pas ce pattern — ce test est le filet.RegressionUserCase4Test(4) : v1.4.3 — 4ᵉ régression épinglée sur les données réelles (export v1.4.2, HORS dépôt) : parsing v2 avec settings (seuils + auto-cal), scénario transition EV inactif → EEn actif, signature pharmacocinétique EEn (contribution d'une dose à J+1 < 12 % du total, plateau 48 h ≤ 25 % — c'est la réponse épinglée à « pourquoi l'estimation ne remonte pas le lendemain »), pas de fausse alerte à l'observation. Tout nouvel export = une régression data-driven.AlertsTest(12) : v1.4.2 — logique pure des seuils (pk/Alerts.kt) : HIGH/LOW, comparaison STRICTE (valeur == limite → rien, éviter le clignotement d'arrondi), seuil null → jamais d'alerte, cohérence haut > bas, HIGH prime LOW en config incohérente (défensif), les deux marqueurs à la fois (E2 puis T), codec d'état (encodeState/parseState— rétro-résistant aux entrées malformées) et décision de notification (shouldNotify: nouveau franchissement → notif, même état → pas de spam, retour à la normale → pas de notif mais ré-arme).AlertsEngineTest(4) : v1.4.2 — câblage moteur → seuils : le niveau évalué par la NOTIFICATION (levelAt(scalePerEster)) est celui de la CARTE accueil (computeCurvedernier point, ±10 % de dérive horaire) ; seuil à la moitié du niveau réel → HIGH, seuil au double → rien ; T bas → LOW en ng/mL ; l'état persisté ne contient que les marqueurs en alerte.TransfemScienceEngineTest(4) : v1.4.0 — câblage MOTEUR ↔ V3C : EB (sans table ODS) routé en TFS → pic article 971 pg/mL @ 0,65 j ; EB forcé en ESE (modelOverride) → 0 partout (comportement documenté : presets EB/EC/ECS/PEP en TFS uniquement) ; PEP 32,5 mg → contribution à 10 j ET 100 j (l'ancienne coupure 24 h du fallback table aurait donné 0) et 0 au-delà de 10 × t½ (par design) ;computeCurve(modelOverride=TFS)sur un traitement stocké ESE reproduit le pic V3C EV 295 — c'est le test du dispatchconcentrationOfDose+ decutoffHours.ChartZoomTest(7) : v1.2.9 (pas d'échantillonnage) + v1.4.1 — horizon de prévision 12 × Posologie borné 30 j–1 an (forecastHorizonHours, null sans Posologie) et clamp du panoramique (clampPanHours: panHours négatif = futur, borné par l'horizon ; prévision OFF → futur interdit) — la logique de fenêtre du graphique est centralisée et testée.ReminderScheduleTest(8) : v1.4.0 — fix #52 : rappel sur la GRILLE Posologie (dose il y a 2 j + intervalle 7 j → alarme dans 5 j à l'heure choisie, PAS demain), heure déjà passée le jour du créneau → créneau suivant, créneau manqué sauté (jamais dans le passé), fallback quotidien sans Posologie ou sans doses, agrégat multi-traitements (le plus tôt gagne), traitements inactifs/désactivés → null.
Ce que les tests ont déjà attrapé : bisection inversée de computeKa (présente depuis
la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du modèle inconnu.
Toute modification du moteur passe par ces tests. Suite envisageable : Robolectric
(UI/logic Android), tests Compose, lint.
9. Système de rappels
reminder/ReminderManager.kt (+ DoseActionReceiver.kt, ReminderReceiver).
ReminderContract: constantes + fabrique uniquereminderIntent()pour schedule ET cancel (même action = même PendingIntent — cf bug §14.3)AlarmScheduler:scheduleFor(treatment, doseLogs)(v1.4.0, source de vérité = moteur) : la date du prochain déclenchement vient dePharmacokineticEngine.nextReminderFireFor— grille Posologie si le traitement aforecastIntervalDays+ des doses (rappel UNIQUEMENT le jour du créneau, à l'heure choisie — fix #52), sinon quotidien (comportement historique, gel/oral ou app sans historique)- alarmes one-shot (
setExactAndAllowWhileIdle) : la suivante est reprogrammée parReminderReceiver(après notif) etDoseActionReceiver(après « Pris ») — avant v1.4.0 la chaîne ne se recousait qu'au boot/au save - permission SCHEDULE_EXACT_ALARM : bouton d'octroi dans Paramètres + éditeur
(
Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM) scheduleSnooze(+1 h),rescheduleAll(treatments, doseLogs)
ReminderReceiver: notif HIGH/REMINDER, 2 actions + tap → MainActivity (open_log_dose,treatment_id) → Home ouvre le dialog pré-rempli ; requestCodes PendingIntent =id*10+{0,1,2}; notificationId =id.toInt(); puis reprogramme le prochain déclenchement (goAsync + IO)DoseActionReceiver(non exporté) : « Pris » →goAsync()+ coroutine IO → insert DoseLog (dose = extra ou standard) + reprogramme (la dose loguée avance la grille) ; « Reporter 1 h » →scheduleSnooze; annule la notifBootReceiver:goAsync()+ thread +runBlocking+ one-shotsgetAllOnce()(traitements) +doseLogDao.getAllOnce()→rescheduleAll(jamais un Flow en runBlocking !)
Événements d'agenda (v1.3.0, reminder/CalendarEvents.kt) : switch dans
l'éditeur (sous Rappels) → insertion dans un calendrier local dédié
« HormoneTrack » (CalendarContract, ACCOUNT_TYPE_LOCAL) d'un événement
récurrent RRULE FREQ=DAILY;INTERVAL=N (N = Posologie, arrondi demi-supérieur
explicite : floor(x + 0.5) — kotlin.math.round arrondit les ties vers
l'entier PAIR !), début = prochaine occurrence à l'heure de rappel (ou 12:00).
L'id est stocké sur le traitement (calendarEventId, Room v3) ; au save :
supprimer + recréer (fiable) si changé, supprimer si désactivé. Permissions
WRITE_CALENDAR + READ_CALENDAR demandées à l'activation du switch.
Manifest : POST_NOTIFICATIONS, SCHEDULE_EXACT_ALARM, RECEIVE_BOOT_COMPLETED,
VIBRATE, WRITE_CALENDAR, READ_CALENDAR.
⚠️ Permissions agenda (v1.3.3) : WRITE_CALENDAR + READ_CALENDAR ont été
ajoutées au manifest seulement en v1.3.3 — elles étaient documentées ici
depuis v1.3.0 mais jamais déclarées dans le XML (bug #44, §14) : tout
jeton « le manifest contient X » doit être VÉRIFIÉ dans le fichier réel.
Sur la montre : remontée par Gadgetbridge ou Huawei Health (cf §17).
9.bis Alertes de seuil : reminder/AlertNotifier.kt + worker (v1.4.2)
- Seuils (
pk/Alerts.kt, PUR) :Thresholds(E2 haut/bas pg/mL, T haut/bas ng/mL, null = pas de limite), évaluation STRICTE (valeur == limite → rien),evaluateAll(E2 puis T), validation haut > bas (isCoherent). - Worker WorkManager :
AlertWorker(CoroutineWorker) périodique 15 min (minimum WorkManager) + one-time au save des seuils (feedback immédiat) ; planifié dansHormoneTrackApp.onCreate(ExistingPeriodicWorkPolicy.KEEP, survit aux reboots — BootReceiver n'a RIEN à faire). - Calcul du niveau :
levelAt(..., scalePerEster, tKPerEster)— MÊME calibration que la carte accueil (auto-cal incluse si l'option est active). - Anti-spam : l'état des alertes déjà notifiées est persisté en DataStore
(
alert_notified_state=Alerts.encodeState, ex. « E2:HIGH;T:LOW ») ;shouldNotify: nouveau franchissement ou changement H↔L → notif, même état → rien, retour à la normale → état effacé (ré-arme), pas de notif de retour. - Canal dédié
hormonetrack_alerts(IMPORTANCE_HIGH) : réglable indépendamment des rappels ; notification avec texte multi-lignes (BigText), tap → MainActivity. Garde défensive POST_NOTIFICATIONS (API 33+) journalisée. - Journalisation AppLog complète (« notification : E2:HIGH... », « franchissement inchangé », « retour à la normale », erreurs worker) — diagnostic à distance.
10. UI & navigation
HormoneTrackRoot: NavigationBar 5 tabs (home/chart/doses/labs/treatments) + routessettings,treatment_edit/{id}(-1 = nouveau) ; barre masquée sur ces 2 routesHomeScreen: bandeau gradient (TransSky→TransPink, discret), carte niveau actuel (E2 ≈ X pg/mL, T ≈ Y ng/mL, delta vs 6 h — v1.4.3 : comparaison au point le plus proche de −6 h viapointHoursBefore, l'ancien code comparait ~24 h, bug #53), cartes d'alerte v1.4.2 (si un seuil est franchi : fonderrorContainer, « ▲ E2 estimé ≈ X pg/mL — au-dessus de ta limite (Y) », basées sur le DERNIER point de courbe = niveau affiché, disclaimer « estimation, pas une mesure »), carte prochaine dose (v1.4.1 : delta en JOURS au-delà de 24 h — « 5 j 2 h · sam. 6 18:00 » viaHrtDuration.daysAndHours+next_dose_days; en dessous, h/min), chips de log rapide (+ FAB), mini-chart 24 h (multi-séries viaChartSeries) cliquable → écran Graphiques (v1.2.1) avec mini-légende E2/T ; données auto-calibrées si l'option est active (scalePerEster = effectiveAuto?.esterScales) ; rafraîchissementtick60 sChartScreen(v1.2, le plus riche) : fenêtre temporelle v1.4.1 —endMs = now − panHoursavecpanHourssigné : > 0 = passé (tirer vers la droite), < 0 = futur (tirer vers la gauche, uniquement avec la prévision active, borné parforecastHorizonHours= 12 × Posologie borné 30 j–1 an). Activer le chip Prévision ne déplace PAS la fenêtre (la projection s'étend au-delà et se parcourt au drag) ; désactiver pendant un voyage futur → retour à maintenant ; clamp centralisé dansclampPanHours(testé). Plages 24 h/7 j/30 j + boutons zoom − / + (v1.2.9) ; panoramique (detectHorizontalDragGestures— tirer vers la droite remonte dans le passé,panHoursborné à [0, âge de la 1ʳᵉ dose + plage], bouton « Revenir à maintenant ») ; toggles indépendants Estrannaise/TFS → deuxcomputeCurveavecmodelOverridesuperposées (E2 ESE bleu plein, E2 TFS turquoise, T ESE rose plein, T TFS rose pointillé) ; chip Prévision (doses projetées viagenerateForecastDoses, horizon = 2× le plus grand intervalle configuré, borné 7–30 j) ; auto-calibration branchée sur les Paramètres ; légende dynamique ; labs T normalisés en ng/mL ; toggle T = masque aussi les labs T (v1.2.2) ; chip « Pics / creux » (v1.2.3 : triangles ▲▼ aux extrema locaux de chaque courbe, viadetectExtrema— E2 seuil 2 pg/mL, T seuil 0,02 ng/mL)DosesScreen: LazyColumn par jour (desc), Δ jours depuis la dose précédente du même traitement (intervalsByDoseId, colonne « Interval (d) » du.ods), suppression avec confirmation, FAB →DoseDialog(création), tap sur la ligne → édition ; en-tête « temps sous THS » (v1.3.1) : carte depuis la 1re prise, jours totaux + décomposition mois/jours (HrtDuration, mois = 30 j estimés)LabsScreen(v1.2.2) : prise de sang E2 + T en une entrée (LabDialog create : deux sections optionnelles → 1 ou 2 LabResult au même timestamp) ; affichage groupé par timestamp (groupLabsForDisplay: E2 avant T, autres ensuite, tri desc) → « E2 306 pg/mL · T 44 ng/dL » côte à côte ; tap → sélecteur E2/T si paire, édition unitaire pré-remplie ; suppression = la prise entière (confirm nommant les valeurs — choix de design : les labs sont prélevés ensemble) ; suggestions d'unités pg/mL, ng/mL, ng/dL, ng/L, nmol/L, mIU/LTreatmentsScreen: cartes (nom, route, dose, chips ester·modèle / Tmax / ×scale / ⏰, badge inactif), FAB → éditeurTreatmentEditorScreen: 12 presets (PKPresets, cfnameRes) pré-remplissent tout ; champs conditionnels (ester+modèle si injection, Bateman sinon) ; carte Calibration (scaleFactor + « Calibrer avec les analyses ») ; section « Posologie » (v1.2 : switch « Simuler les doses à venir » + intervalle en jours) ; carte Rappel (switch + TimePicker centré + avertissement alarmes exactes) ; switch actif ; save → insert/update + schedule/cancel ; delete avec confirmation ;createdAtpréservé- Logs de diagnostic (v1.3.1,
util/AppLog.kt) : journal horodaté (buffer circulaire 500 lignes, persistéfilesDir/debug-log.txt), alimenté par l'agenda (permissions, upsert/delete), l'import/export et les erreurs ; export (SAF) et effacement dans Paramètres — permet le debug à distance (l'utilisateur joint les logs à son retour). - Dialog « Nouveautés » (v1.3.0) : au démarrage, si
BuildConfig.VERSION_NAMEest plus récente que la dernière vue (DataStorechangelog_seen_version), un AlertDialog affiche les sections CHANGELOG concernées (extraites parChangelogHelper.sectionsSince, assetchangelog.mdsynchronisé par la tâchecopyChangelog— gitignoré, source de vérité = docs/CHANGELOG.md) ; fermable, ne réapparaît pas avant la prochaine mise à jour. SettingsScreen: langue (Système/Français/English, chips reflétant l'état) ; carte « Seuils d'alerte » (v1.4.2) : 4 champs optionnels (E2 haut/bas pg/mL, T haut/bas ng/mL, vide = pas d'alerte), validation haut > bas (refus + message rouge), Save → persiste + check one-time immédiat (AlertNotifier.checkNow) ; version installée + lien cliquable vers les releases Gitea (v1.3.0,BuildConfig.VERSION_NAME,enableEdgeToEdge-friendly) ; « Calibration automatique (E2 + T) » — UNE option (par période d'ester + modèle T, v1.2.1, désactivée par défaut) puis réglages T manuels (base/floor/k + bouton « Calibrer avec les analyses » ponctuel) ; statut alarmes exactes + bouton d'octroi ; Export/Import JSON ; à propos- Composants :
CurveChart(§11),DateTimeField(DatePicker+TimePicker Material3, LocalDateTime, horloge centrée),DoseDialog(create/edit + override d'ester),LabDialog,formatDose()(top-level, dansDoseDialog.kt) - Insets edge-to-edge (v1.2.5) :
enableEdgeToEdge()dans MainActivity ; les insets sont consommés UNE FOIS — TopAppBar M3 (barre de statut, insets par défaut) et NavigationBar (barre système) ; lesScaffold(racine HormoneTrackRoot + imbriqués Doses/Labs) ontcontentWindowInsets = 0pour ne pas cumuler. Avant : fenêtre AppCompat + padding interne TopAppBar = double espace vide en haut. - Thème M3 custom (
ui/theme/Color.kt: bleu #4F5BD5, rose #D6589E, labs orange, bandeau TransSky/TransPink), dynamic color désactivé ;MaterialExpressiveThemeencore internal dans la ligne material3 pinnée (cf §3) →MaterialThemestandard - ⚠️
Card(onClick=…)etExposedDropdownMenuBox= API expérimentales M3 →@OptInrequis sur chaque composable qui les utilise
11. Graphiques (CurveChart)
Canvas pur (aucune lib), multi-séries (v1.2) : ChartSeries(points, e2Style, tStyle?)
— le ChartScreen superpose les courbes Estrannaise et Transfem Science depuis le
même traitement (modelOverride), styles plein/pointillé par série. Dual axe : E2
gauche (pg/mL), T droite (ng/mL). Échelle « nice » (niceCeil : 1/2/2.5/5/10 × 10ⁿ)
partagée entre toutes les séries. Grille 4 lignes ; labels Y gauche/droite ; X : pas
6 h/24 h/5 j selon plage (SimpleDateFormat HH'h' / dd/MM). Labs : cercles (E2) et
carrés (T) orange + valeur, T convertie en ng/mL (convertTToNgMl) au rendu.
Ligne verticale « maintenant ». Pics/creux (v1.2.3, valeurs v1.2.6) :
triangles ▲▼ via PharmacokineticEngine.detectExtrema accompagnés de la
valeur estimée du pic (au-dessus) ou du creux (en dessous), dans la couleur
de la série (drawExtremum).
Zoom (v1.2.9) : pinch et boutons − / + — plage temporelle bornée
6 h → 300 j (MIN_RANGE_H/MAX_RANGE_H), point focal du pinch maintenu
fixe dans le temps (formule centroid), échantillonnage adaptatif
(stepForRange : 15 min ≤ 12 h, 30 min ≤ 24 h, sinon 1 h — courbes lisses à
fort zoom) et labels X adaptatifs (1 h / 3 h ajoutés). Le pan ET le zoom
partagent un seul detectTransformGestures (un handler = pas de conflit de
consommation) ; le zoom/pan restent gérés par le parent, le Canvas demeure
purement déclaratif. Convention des boutons (v1.2.10) : « + » = zoom avant
(fenêtre courte), « − » = zoom arrière — le sens initial était inversé (retour
utilisateur).
Pièges :
DrawScopeimplémenteDensity→X.dp.toPx()direct ; ne PAS écrire de helper custom- Tout label passe par
drawContext.canvas.nativeCanvas+android.graphics.Paint - Mélange Double/Float interdit (
1 - i / 4fet pas/4.0) - Le panoramique est géré par le parent (ChartScreen change
startMs/endMs), pas par le Canvas — le chart reste un composant purement déclaratif
12. i18n FR/EN
- Standard Android :
values/strings.xml(EN défaut) +values-fr/strings.xml(FR). L'objetStrings.ktcustom de la session 1 a été supprimé. - Langue par app : AppCompat 1.7 +
AppCompatDelegate.setApplicationLocales(fonctionne < API 33) ; choix persisté DataStore (system/fr/en), appliqué au démarrage. Thème app =Theme.AppCompat.DayNight.NoActionBar(requis par AppCompat). - Notifs localisées via
context.getString(R.string.*) - ⚠️ Toute nouvelle string = les DEUX fichiers (une référence manquante = erreur de
compilation
Unresolved reference 'active'— déjà arrivé)
13. Sauvegarde JSON
data/backup/BackupManager.kt :
BackupData{version=2, exportedAt, treatments[], doseLogs[], labResults[], tConfig, settings?}→ Gson — v2 (v1.4.2) : champ optionnelsettings: UserSettings(champs PLATS : language, autoCalibrate, alertE2High/Low pg/mL, alertTHigh/Low ng/mL). Rétrocompat : les backups v1 (sans settings) restent parsables (null) et importables ; l'import ne vérifie pas strictement la version.changelog_seen_versionvolontairement EXCLU (pas une donnée utile à restaurer). ⚠️ R8 : UserSettings est lue par réflexion Gson →-keepexplicite dans proguard-rules.pro (sinon objets vides en release seule).- Les IDs Room sont conservés dans l'export et réinsérés tels quels → les FK dose→traitement restent valides
- Import : mode ÉCRASEMENT (v1.2.6) — les données actuelles sont effacées
AVANT l'insertion (
deleteAllDoseLogs→deleteAllLabResults→deleteAllTreatments, ordre enfants → parents pour la FK CASCADE), puis restauration avec les IDs du backup conservés (FK valides). Avant (v1.2.5) : insertion en « ajout » → conflit d'ID dès que l'app contenait des données → l'import échouait (bug remonté). Après un import : letConfigdu backup est restauré dans DataStore, les paramètres utilisateur sont restaurés (v1.4.2 : auto-calibration, seuils d'alerte, langue — la LANGUE est appliquée sur le thread MAIN carsetApplicationLocalesrecrée l'activité) et les rappels sont reprogrammés (rescheduleAll). Le dialog prévient que TOUT sera remplacé (bouton « Effacer & restaurer ») - Transport : SAF (
CreateDocument("application/json")/OpenDocument), écritureopenOutputStream(uri, "wt"); ⚠️ pas dereturndans un expression body= try{}
14. Bugs corrigés
Historique complet — à ne pas réintroduire (utile pour diff/revert) :
Session 1 → 2 (avant tout build) :
settings.gradle.kts:dependencyResolution(inexistant) →dependencyResolutionManagementBootReceiver:runBlocking { flow.collect {…} }→ blocage infini → one-shot + goAsyncAlarmScheduler.cancel: Intent sans l'action → annulation inopérante → fabrique uniquePKProfileStore: parsait la racine JSON → crash → lecture deprofiles
Session build (détectés à la compilation) :
5. kotlin.math.ln2 n'existe pas (hallucination) → ln(2.0) ; cascade d'erreurs sur
les lignes suivantes du même fichier (opérateurs sur types error)
6. Mélange Double/Float interdit en Kotlin : mg * bioavailabilityFraction (Float),
30.0 * t½ (Float), Float×exp()… → .toDouble() partout
7. BackupManager.writeBackup : return dans expression body = try{} → block body
8. DateTimeField : spacedBy(8f/2f*8) (Float sans unité) → 8.dp + import dp manquant
9. DateTimeField : extension fun LocalDate.Companion.ofEpochMs (java.time n'a pas de
Companion) → supprimée ; imports nettoyés
10. CurveChart : helper dpToPx() custom cassé → dp.toPx() de DrawScope
11. CurveChart : labels Y en Double (i / 4.0) → i / 4f
12. LabsScreen / TreatmentsScreen : imports dp / fillMaxWidth manquants
13. TreatmentEditorScreen : R.string.active inexistante → string ajoutée EN+FR
14. TreatmentCard : Card(onClick=…) sans @OptIn(ExperimentalMaterial3Api::class)
15. Typo Locale.getDefault sans parenthèses (SimpleDateFormat)
16. SettingsScreen : chips de langue codées en dur → état depuis DataStore
17. ReminderManager : constantes d'action mortes → implémentées (DoseActionReceiver)
18. TreatmentEditorScreen : createdAt écrasé à l'édition → préservé
Session tests (bugs SÉMANTIQUES trouvés par les tests unitaires) :
19. computeKa : bisection inversée — if (eq > 0) hi = mid else lo = mid convergeait
vers ka énorme (pic à ~0 h au lieu de Tmax) ; bug présent depuis la session 1, jamais
testé. → if (eq > 0) lo = mid else hi = mid (eq décroît en mid ; eq>0 ⇒ racine au-dessus)
20. Plancher d'affichage des profils : l'ODS arrondit à 2 décimales → queues à 0,01/0,00
; extrapoler depuis la fin de table donnait 0 à vie (ou une constante plate). →
extrapolation depuis le dernier point ≥ 1 % du pic avec pente sur 48 h
21. Mapping silencieux du modèle : profileKey mappe tout modèle ≠ "TFS" sur "ese"
→ sample("EV","XXX") renvoyait EV_ese. → validation stricte dans sample
Session v1.1.0 (remontées par l'utilisatrice, reproduites en test) :
22. Casse des clés de profils — LE bug « les graphiques ne se génèrent pas » :
l'asset contient "EEn_ese"/"EEn_tfs" (casing biologique du .ods) mais
Esters.EEN = "EEN" → lookup exact null → sample()=0 pour tout traitement EEn
(courbe E2 plate à 0, T plate à la base). EV/EU marchaient (casse identique) et les
tests profils utilisaient la casse "EEn" — le trou passait entre les deux.
→ lookup insensible à la casse (PKProfileStore.lookup()), régression épinglée
sur les données réelles (RegressionUserCaseTest).
23. Unités T non converties : labs saisis en ng/dL (32/45) → axe T du chart à
×100 (courbe T invisible) et calibration T fausse. →
PharmacokineticEngine.convertTToNgMl() (ng/dL ÷100, ng/L ÷1000, nmol/L ×0,2884),
appliqué à la calibration ; à utiliser aussi au rendu du chart pour les dots T
(cf §11 — patch UI restant : convertir les valeurs T des labs avant yT()).
24. Pas d'édition des doses : suppression+recréation obligatoire. → DoseDialog
create/edit (préfill, changement de traitement, date/heure, notes, override
d'ester par injection), appelé depuis DosesScreen (tap sur la ligne) ;
LogDoseDialog supprimé (attention : formatDose vivait dedans → déplacée
top-level dans DoseDialog.kt).
Session v1.2.1/v1.2.2 (remontées utilisateur) :
25. Calibration mélangeant les périodes d'ester — le fond du « 250–375 » :
un facteur unique par traitement comparait des labs valerate à des prédictions
enanthate → ratios aberrants → courbes gonflées. → calibration par période
(computeEsterScaleFactors + scalePerEster, cf §7.6) ; vérifié que l'état
d'équilibre EEn (accumulation ×2) est CORRECT — le « 375 » = calcul non calibré.
26. Lab T en unité aberrante (« 38 pg/mL », faute de frappe) : renvoyé brut il
écrasait l'axe T et rendait la courbe T invisible. → convertTToNgMl avec
branche défensive pg/mL ÷1000 (et µg/L).
27. Pas d'édition des labs → LabDialog create/edit + tap sur la ligne ;
formatLabValue (préserve les décimales, contrairement à formatDose).
Session v1.2.3/push Gitea :
28. Commentaires Kotlin imbriqués : /**TFS** dans un KDoc ouvre un bloc
/** JAMAIS fermé (Kotlin les imbrique !) → « Unclosed comment » → NE PAS
mettre /** dans un texte de doc ; écrire « EEn + TFS ».
29. XML de test périmés après échec de compilation : quand compile échoue,
les anciens XML test-results restent → on « corrige » des échecs fantômes.
Toujours vérifier que la COMPILATION passe (grep ^e: du log) avant
d'analyser les résultats de tests.
30. filter-branch non idempotent : le scrub (45.0→44.0) a corrompu une
constante d'un test PUR écrit après coup (assert 0.45 vs 44/100). Règle :
les remplacements d'anonymisation doivent être idempotents (les valeurs de
remplacement ne re-matchent jamais les motifs) et les tests purs doivent
utiliser des constantes hors des motifs de scrub.
31. Release script : préfixe « v » — le tag git = v1.2.3, la CHANGELOG
titre [1.2.3] : l'extraction cherchait [vX.Y.Z] → fallback silencieux.
→ scripts/gitea-release.py (corps = section CHANGELOG, APK attaché).
- Import JSON impossible si l'app contient déjà des données (v1.2.6) : l'insertion en « ajout » avec les IDs du backup entrait en conflit d'unicité dès la moindre donnée existante → import échouait. → mode ÉCRASEMENT (effacement ordonné enfants→parents puis restauration) + restauration du tConfig + reprogrammation des rappels.
- Upload d'asset Gitea instable (v1.2.10) : le
?name=du POST et le PATCH de renommage peuvent être ignorés (asset au nom générique, APK release disparu de la release). →ensure_assetdans le script : upload + vérification nom/taille + retry PATCH + échec bruyant. - Dialog « Nouveautés » à chaque réouverture (v1.3.2, remontée) : la version vue était mémorisée APRÈS l'affichage — si l'app est fermée pendant que le dialog est ouvert, DataStore n'avait pas encore flushé → même changelog ressortait. → mémoriser la version vue AVANT d'afficher (l'affichage est purement visuel, la mémoire est déjà faite). Titre = BuildConfig.VERSION_NAME (version de L'APP) : si l'APK installé est v1.3.0, le titre montre 1.3.0 même si le contenu parle d'une version plus récente — installer le dernier APK.
- Toggle agenda toujours inopérant (v1.3.2, remontée) : le FIX v1.3.1
(callback async) était correct mais insuffisant : (a) le switch
s'activait même SANS Posologie (l'événement RRULE aurait un INTERVAL
invalide) → désormais refusé avec feedback rouge ; (b) au save, la
permission était vérifiée via le flag
remembered(peut être faux après recomposition) → vérification RÉELLE via ContextCompat ; (c) AppLog journalise chaque étape (permission demandée/accordée, upsert, delete) pour le debug à distance. - Toggle de l'agenda inopérant (v1.3.1, remontée) : le résultat de la
permission arrive ASYNCHRONE — le test synchrone juste après
launch()était toujours faux → le switch rebondissait sans s'activer, sans feedback. → le CALLBACK active le switch ; journalisation AppLog ; leçon : ne jamais lire un état de permission juste après launch(). - Export des logs plantait l'app (v1.3.2, remontée) : le launcher CreateDocument avec écriture inline dans une coroutine IO non couverte par le même pattern que l'export JSON. → réutilise BackupManager (openOutputStream "wt") + journalisation AppLog à chaque étape. Leçon : quand un pattern d'IO marche (export JSON), le réutiliser tel quel plutôt que d'en écrire un nouveau.
- Piège lexicographique de tags/versions (v1.3.0, publication
farewell) :
tag >= "v1.2.5"en comparaison de CHAÎNES faitv1.2.10 < v1.2.5("1" < "5") → la boucle de publication a sauté v1.2.10. → toujours comparer les versions STRUCTURÉEMENT (tuple numérique, cf ChangelogHelper.isVersionNewer) ; même famille que le bug #22 (casse EEn) : identifiants « presque pareils ». (Numéro corrigé en v1.3.3 : l'entrée était dupliquée sous « #41 » alors que §2/§16 référencent #38.) - Course entre uploads rapprochés (v1.2.6→v1.3.2, confirmé 4 fois) :
publier les 2 APK par DEUX INVOCATIONS rapprochées de gitea-release.py
→ le second upload écrase le premier (APK release disparu de la
release). →
scripts/publish-release.py: UNE invocation fait purge + upload des 2 APK + vérification PAR TÉLÉCHARGEMENT de chacun (§16.bis). - Sens des boutons de zoom inversé (v1.2.10, retour utilisateur) : le « + » dézoomait (fenêtre plus longue) et le « − » zoomait — contre la convention carte. → « + » = zoom avant (fenêtre courte), « − » = zoom arrière. Le pinch (écarter = zoom avant) était déjà correct.
- Prévision cassée après un oubli d'injection (v1.2.7) : le premier
créneau projeté tombait dans le passé (dernière + intervalle = jour
manqué) → faux pic dans l'historique + rythme décalé. → les créneaux
passés sont sautés (
while (t <= nowMs) t += intervalMs), la prévision démarre au premier créneau futur ; un retard décale toute la prévision (comportement voulu, testé). NB : la doc §7.3b affirmait « strictement après nowMs » alors que le code incluait les créneaux passés — mismatch doc-code détecté par l'utilisatrice. - Double insets edge-to-edge (v1.2.5) : espace vide en haut de l'écran —
la fenêtre AppCompat poussait le contenu sous la barre de statut ET les
TopAppBar M3 rajoutaient leur padding interne. →
enableEdgeToEdge()+contentWindowInsets = 0sur les Scaffold, insets consommés une seule fois. isActivetraité comme filtre de données (v1.2.4) : passer un traitement à inactif effaçait sa simulation du graphique (écrans collectaientactiveTreatments) tout en permettant d'y loger des doses. → sémantique documentée §6.bis : l'inactivation ne touche que la SAISIE (chips, dropdown création, rappels) ; simulation et calibration reçoivent TOUS les traitements. Épinglé par la régression n°3.
Session v1.3.3 (audit de reprise de maintenance — session IA démarrant sans aucun contexte, dans l'esprit de cette doc) :
- Permissions agenda absentes du manifest (v1.3.0 → v1.3.2) : la
fonctionnalité « événement d'agenda récurrent » ne pouvait JAMAIS
fonctionner —
WRITE_CALENDAR/READ_CALENDARn'étaient pas déclarées dans AndroidManifest.xml (la demande limitée à l'exécution est refusée d'office sans déclaration, etCalendarEvents.ensureCalendar/upsertlèvent SecurityException). La doc §9 les listait pourtant depuis v1.3.0 → leçon : « documenté » ≠ « implémenté » ; toute affirmation « le manifest contient X » doit être vérifiée dans le XML réel (30 s degrep uses-permission). Les fixes v1.3.1/v1.3.2 du « toggle agenda » réparaient la logique UI mais pas la cause racine. FIX : déclaration + commentaire dans le manifest. - Export des logs plantait TOUJOURS l'app (le « fix » v1.3.2, #41
plus haut, était insuffisant) : l'IO restait réimplémentée inline
(leçon du #41 : réutiliser BackupManager — PAS appliquée) et 3 appels
AppLog.logdu callback étaient HORS try/catch — or une exception non interceptée dans une coroutine à scope racine (CoroutineScope (Dispatchers.IO)) remonte au handler de la thread = crash du process. FIX : (a) l'export réutiliseBackupManager.writeBackupTEL QUEL — le même code path que l'export JSON qui marche sur le Pixel 9 /e/OS ; (b) tout le callback est gardé ; (c)AppLog.logest fail-safe (l'IO fichier est avalée dans le wrapper — une erreur disque ne doit jamais tuer l'app pour une ligne de journal) ; (d) feedback visible succès/échec +logLineCountrafraîchi (sur Main). - Bump de version jamais commité (v1.3.0 → v1.3.2) : les tags
v1.3.0/v1.3.1/v1.3.2 contenaient TOUS
versionCode = 14/versionName = "1.3.0"→BuildConfig.VERSION_NAMEétait faux dans les APK publiés (Paramètres, titre du dialog « Nouveautés », comparaisonisVersionNewer). La checklist §16 étape 1 existait mais n'a jamais été appliquée. FIX : versionCode 17 / « 1.3.3 » commité 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) :
- L'écran Doses crashait l'app (
MissingFormatArgumentException: Format specifier '%3$d') : la stringhrt_durationa TROIS placeholders (%1$d mois, %2$d jours, %3$d total) maisstringResource(R.string.hrt_duration, months, days)ne passait que DEUX arguments. Crash dès que le fragmenttotalDays > 0s'affiche (donc pour TOUTE donnée antérieure à aujourd'hui) — né en v1.3.1 (string + appel dans le même commitfa5d2df, jamais testés ensemble) et passé à travers v1.3.1→v1.3.3. - 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")). UnLocalDaten'a PAS de champ horaire →UnsupportedTemporalTypeException: Unsupported field: HourOfDaylevé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 caryyyyMMddest 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. - StringFormatMatches (capture du lint, v1.3.4) : la notification de
rappel passait un Double à
%s. Fixdose.toString()(l'affichage ne change pas). L'intérêt des règles lint est confirmé PAR CETTE SESSION :./gradlew lintaurait 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éoriquesNonObservableLocale/LocalContextGetResourceValueCallsont tombés en warning viaapp/lint.xml, cf §19).
Session v1.3.5 (diagnostic à distance via les logs exportés — la boucle par l'exemple) :
- Création du calendrier d'agenda impossible (
IllegalArgumentException: Sync adapters must specify an account and account type, remontée par les AppLog exportés du Pixel 9 v1.3.4) :CalendarEvents.ensureCalendarconstruisait l'URI calendars avecCALLER_IS_SYNCADAPTER=trueSANSACCOUNT_NAME+ACCOUNT_TYPE— requis par le CalendarProvider pour TOUTE opération sync-adapter (requête ET insertion). De plus :- les mêmes logs CONFIRMAIENT au passage les fixes précédents : export
logs
ok=true(#48 ✓ depuis v1.3.4 au téléphone), permission agenda accordée (manifest v1.3.3 ✓) — AppLog fait son job de diagnostic à distance ; - le log « événement agenda supprimé » était imprimé aussi quand le switch était inéligible (posologie absente, permission absente, switch off) → trois messages distincts désormais (créé-mis à jour / supprimé / non activé) ;
- contexte : les « événement agenda supprimé » répétés dans les logs =
des saves où le switch ne remplissait pas les conditions d'activation
(l'utilisatrice avait retourné le switch après les échecs) — les trois
messages distincts rendront les prochains logs univoques.
FIX : append ACCOUNT_NAME/ACCOUNT_TYPE sur l'URI ; le pattern complet :
syncadapter=true+ account + account_type sur les URIs de CRÉATION (calendriers) ; l'insertion d'EVENTS l'avait déjà (upsertEvent). Validé émulateur :content query --uri content://com.android.calendar/calendarsmontre le calendrier, events avec RRULE corrects. 50.bis. Delete d'événement silencieusement manqué (cité au #50) : le même défaut (CALLER_IS_SYNCADAPTER sans account) sur l'URI de DELETE dedeleteEvent— l'exception est avalée par le catch, le log dit « supprimé » mais l'event reste dans le provider. FIX : mêmes params sur l'URI. Règle : TOUT URI avec CALLER_IS_SYNCADAPTER doit embarquer account+account_type — pas seulement les insertions.
- les mêmes logs CONFIRMAIENT au passage les fixes précédents : export
logs
- Événement orphelin après re-save (découvert en validant #50 sur
émulateur — le provider montrait un event que la base ne référençait
plus) :
buildTreatment()reconstruisait le Treatment SANS le champcalendarEventId→ null à chaque save → l'event existant devenait orphelin dès la deuxième sauvegarde, plus jamais désactivable. FIX :loadedCalendarEventIdchargé dans le LaunchedEffect + renvoyé par buildTreatment(). Leçon : la reconstruction complete d'une entité pour un save doit repartir des champs NON ÉDITABLES (voirloadedCreatedAt, déjà protégé, et désormaisloadedCalendarEventId).
Session v1.4.0 (modèle TFS sur la méta-analyse + fix #52) :
-
« La prévision simule le 13 au lieu du 12 » (v1.4.5, remontée) : diagnostic sur les données réelles (régression #4) : la GÉNÉRATION est exacte (créneau = dernière dose LOGUÉE + intervalle en ms exact) — l'illusion venait de trois défauts d'AFFICHAGE : aucun marqueur des doses simulées, labels X alignés sur minuit UTC (= 02:00 FR), courbe encore descendante au créneau (physiologie EEn). FIX : marqueurs (prévisionnels pointillés + réels discrets),
xLabelTicksà minuit LOCAL dans le fuseau CHOISI (Paramètres, null = téléphone). ⚠️ Subtilité : une dose loguée le LENDENDE de l'injection réelle décale le rythme d'un jour par cycle — l'app suit les LOGS ; recadrer = éditer la dose à son instant réel. -
Boucle de recomposition saturant le main thread (v1.4.5, découvert en validant #55 : les chips ne répondaient plus, « Skipped 52 frames » en continu) :
nowMs = System.currentTimeMillis()relu frais à CHAQUE recomposition → endMs dérive → les keys du producer (endMs) changent à chaque frame → re-calcul perpétuel. FIX : nowMs mémoïsé sur le tick minute (remember(tick)) ; le calcul des créneaux prévisionnels mémoïsé aussi. Leçon : JAMAIS deSystem.currentTimeMillis()nu dans un corps de composable dont il dérive des keys de producer — le mémoïser sur un tick. -
Prévision inaccessible : captures figées dans
pointerInput(Unit)(v1.4.4, remontée « le toggle activé ne génère pas les prévisions, je ne peux même pas dragger vers la gauche ») : la closure du gestionnaire de gestes est créée UNE FOIS (pointerInput(Unit)) ; lesvalcalculées de la composition (futurePanHorizon,maxPanHours,forecastExtensionH) y étaient FIGÉES à leur valeur initiale (0, chip désactivé) → le clamp interdisait le pan vers le futur POUR TOUJOURS, même après activation du chip. Fix :rememberUpdatedState(lesMutableStaten'ont pas le problème : c'est la fermeturebyqui lit à jour). + cause secondaire :generateForecastDoses(toMs = endMs = now)ne générait rien → extension contrôlée à l'activation (cf §10). Garde de source :ChartScreenSourceGuardTest(extraire la closure et interdire les lectures des vals brutes). Leçon : danspointerInput(Unit), TOUTE val calculée lue par la closure doit passer parrememberUpdatedState— seulpointerInput(key)re-keyé relance la closure (et perd les gestes en cours). -
Delta « vs il y a 6 h » = en réalité 24 h (v1.4.3, remontée) :
NowLevelCardprenaitcurve.firstOrNull { écart ≥ 6 h }sur une fenêtre de 24 h — le premier point satisfait la condition immédiatement → comparaison à ~24 h. Le lendemain d'une injection EEn (plateau), ce delta 24 h peut être légèrement négatif → fausse impression que « l'injection ne fait rien ». FIX :pointHoursBefore(point au plus petit écart ≥ 6 h, testé). Leçon : unfirstOrNullsur une condition de distance trouve l'élément le PLUS LOIN, jamais le plus proche — écrire l'intention (« le plus proche de N ») explicitement. -
Rappels quotidiens même hors jour d'injection (remontée utilisateur : « je m'injecte tous les samedis à 18 h → je dois recevoir le rappel uniquement le samedi à 18 h ») :
nextReminderFireMsignorait doses et Posologie → l'alarme se ré-armait chaque jour à HH:mm. FIX : le prochain déclenchement suit la GRILLE (dernière dose + k × intervalle, créneaux passés sautés — même sémantique que la prévision) à l'heure de rappel choisie ; fallback quotidien sans Posologie/sans doses ; et la chaîne one-shot est recousue après notif ET après « Pris » (avant : seulement au boot/save). Leçon : toute logique de scheduling doit vivre au MOTEUR (testable JVM), pas dans les composants Android.
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
expérimentales sans @OptIn ; (d) un test de régression sur les VRAIES données
utilisateur attrape les bugs de convention (casse, unités) que les tests
synthétiques ratent — mais garde ces données hors du dépôt (§8.bis) ;
(e) attention aux identifiants « presque pareils » entre sources (constantes app
vs clés d'asset) ; (f) après un échec de COMPILATION, jeter les résultats de tests
de la même passe (XML périmés) ; (g) tout script de réécriture d'historique doit
être idempotent ; (h) dans un KDoc, /** imbrique ; (i) quand un pattern d'IO
marche (export JSON), le réutiliser TEL QUEL — une réimplémentation « équivalente »
perd les garde-fous acquis à l'usage (cf #45) ; (j) vérifier les POSTULATS dans le
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 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
Si le .ods change (re-fits, nouveaux esters) :
# python3 stdlib only :
# 1. zipfile.ZipFile(ods).read("content.xml")
# 2. ElementTree (ns table/office/text) → table "Models"
# 3. lignes 1-4 = D, k1, k2, k3 (colonnes EV/EU/EEn ese + tfs) — informatif, non utilisé
# 4. lignes 5+ = profils horaires (00:00 … 8000:00), décimaux FR "61,12" → float
# 5. json.dump({"params": …, "profiles": {"EV_ese": [8001], "EU_ese": …, "EEn_ese": …,
# "EV_tfs": …, "EU_tfs": …, "EEn_tfs": …}})
# 6. cp vers app/src/main/assets/pk_profiles.json
# 7. vérifier : 6 clés × 8001 valeurs, pics == référence (§7.1) ; les tests le vérifient
Le script de la session 1 a été exécuté inline (non archivé) — le refaire depuis la
structure ci-dessus. Toute restructuration du JSON impose de mettre à jour
PKProfileStore.initWithJson.
16. Workflow build / test / install / git
cd ~/projects/HormoneTrack
./gradlew assembleDebug testDebugUnitTest # build + 87 tests (44 sans données locales)
./gradlew assembleRelease # APK optimisé R8 (cf §16.bis)
adb install -r app/build/outputs/apk/debug/app-debug.apk
Git (initialisé le 2026-09-05, branche main, DEUX remotes Gitea) :
origin→https://gitea.cloudyfy.fr/Siphonight/HormoneTrack(privé, HTTPS + trousseau)farewell→git@farewell:Siphonight/HormoneTrack.git(SSH, aliasfarewell=giteassh.farewell.dev:2222avec clé dédiée, cf~/.ssh/config) — repo créé + push + LES 12 RELEASES publiées avec APK vérifiés par téléchargement (2026-09-06) ; tokengitea.farewell.devau trousseau (scope write:repository)- Historique = commits logiques par couche (toolchain / moteur / UI / docs) ;
- Chaque release = tag annoté (
v1.1.0,v1.2.0, …) :git tag -a vX.Y.Z -m "…" && git tagpour lister ; local.properties,build/,.gradle/,.idea/,local-test-data/sont ignorés (.gitignore) — ce dernier protège les données de santé de test ;- Avant chaque commit de release :
./gradlew testDebugUnitTestdoit être vert ; - Push :
git push origin main --tags+git push farewell main --tags(farewell : auth SSH par clé dédiée ; le push-to-create y est DÉSACTIVÉ → le repo doit exister au préalable sur l'instance). ⚠️ Tokens Gitea : scopewrite:repositorysuffit pour push, releases et assets ; il NE permet PAS de créer un repo via API (il fautwrite:user) ni d'utiliser push-to-create.
16.bis Releases Gitea avec APK téléchargeable
Les releases Gitea embarquent l'APK de chaque version — les utilisateurs n'ont pas besoin de compiler. Le corps de chaque release = la section CHANGELOG de la version (markdown rendu par Gitea), extrait automatiquement.
Le tout est automatisé par scripts/gitea-release.py — multi-instances
(cloudyfy par défaut, farewell) :
# 0. Le CHANGELOG est embarqué dans l'app (dialog « Nouveautés ») :
# la tâche Gradle copyChangelog copie docs/CHANGELOG.md vers
# src/main/assets/changelog.md à chaque build (gitignoré, auto)
# 1. Construire les DEUX APK au niveau du tag (debug + release R8)
git checkout vX.Y.Z
./gradlew assembleRelease assembleDebug # release = recommandé (R8, cf plus bas)
cp app/build/outputs/apk/release/app-release.apk /tmp/apks/HormoneTrack-vX.Y.Z-release.apk
cp app/build/outputs/apk/debug/app-debug.apk /tmp/apks/HormoneTrack-vX.Y.Z-debug.apk
git checkout main
# 2. Publier sur chaque instance (corps = CHANGELOG + les 2 APK attachés)
# ⚠️ NE JAMAIS comparer les tags en chaînes (v1.2.10 < v1.2.5
# lexicographiquement !) — comparer en tuples numériques (§14 #38)
# ⚠️ NE JAMAIS utiliser gitea-release.py en deux invocations rapprochées
# (les uploads se remplacent mutuellement, confirmé 4 fois) :
# → UNE invocation de **scripts/publish-release.py** par instance fait
# tout (purge + upload des 2 APK + vérification par téléchargement).
python3 scripts/publish-release.py cloudyfy vX.Y.Z
python3 scripts/publish-release.py farewell vX.Y.Z
Le script choisit l'instance (URL, owner) et lit le token Gitea correspondant
dans le trousseau macOS (security find-internet-password -s <hôte> -w) :
gitea.cloudyfy.fr ✓ présent ; gitea.farewell.dev → à ajouter :
security add-internet-password -s gitea.farewell.dev -a Siphonight -w <TOKEN> -U.
Le script :
- extrait la section
## [X.Y.Z]dedocs/CHANGELOG.mdcomme corps ; ⚠️ piège : le tag git porte le « v » (v1.2.3) mais la CHANGELOG non ([1.2.3]) — première version du script cherchait[vX.Y.Z]et tombait sur le fallback « Voir docs/CHANGELOG.md » ; - crée la release si absente, sinon met à jour le corps (PATCH) ;
- attache l'APK (remplace l'asset du même nom si présent) — appeler le script
DEUX FOIS pour publier les deux APK (noms distincts) ;
⚠️ vérification intégrée (
ensure_asset) : le?name=de l'upload et/ou le PATCH peuvent être ignorés par les instances (assets au nom générique / APK disparu — v1.2.6, v1.2.10) → le script vérifie nom ET taille après chaque upload, retente le PATCH une fois, et échoue bruyamment si l'asset ne colle pas — ne pas retirer ; - Releases antérieures à v1.2.5 = APK debug unique : backfill des APK
release abandonné (best effort) — les anciens
build.gradle.ktssortaient unapp-release-unsigned.apknon signé (pas de signing config à l'époque). Les utilisateurs prennent la dernière version.HormoneTrack-vX.Y.Z-release.apk(recommandé, R8) etHormoneTrack-vX.Y.Z-debug.apk; - lit le token Gitea dans le trousseau macOS (
security find-internet-password) ; scope requis :write:repository(suffit pour releases + assets, pas pour créer un repo — cf plus haut).
Build release (v1.2.5) : ./gradlew assembleRelease — R8 full mode +
isShrinkResources + signé avec la clé debug (même signature que les APK
debug distribués → mise à jour par-dessus sans perte de données ; 20 Mo →
2,4 Mo). Garde-fous dans proguard-rules.pro : Gson lit les champs des
modèles par réflexion → -keep explicites sur data.model.**,
BackupData, TConfig (+ attributs Signature) — sinon l'export/import
JSON casse UNIQUEMENT en release. ⚠️ À chaque activation d'optimisation :
tester sur téléphone l'export/import de backup + les graphiques (R8 ne se
vérifie pas en tests JVM).
Checklist de déploiement complète (de A à Z, pour une session sans contexte)
- Bumper la version dans
app/build.gradle.kts:versionCode = N+1,versionName = "X.Y.Z+1"(SemVer : fix = Z, feature = Y). - Tests verts obligatoires :
./gradlew testDebugUnitTest— 145 au total, 123 silocal-test-data/est absent (les 3 classes de régression réelles sont skippées viaAssume, 6 tests chacune) ; lint vert obligatoire :./gradlew lint(v1.3.4 —StringFormatMatchesaurait attrapé les crashs #47/#48 dès v1.3.1). Paliers de test du projet : chaque commit = tests + lint ; release = + scan confidentialité + APK au tag ; crash reporté = reproduction émulateur (§16.ter) AVANT tout fix. - Docs : section
## [X.Y.Z]en tête dedocs/CHANGELOG.md(le corps des releases Gitea en sera extrait automatiquement par le script), + §14 si bug corrigé, + §2 (historique) si notable. - Commit (message descriptif par couche) + tag annoté :
git tag -a vX.Y.Z -m "résumé". - Vérif confidentialité :
git ls-tree -r <tag> --name-only | grep local-test-data→ VIDE + scan de confidentialité (déjà exigé avant tout push, §8.bis) ; puis Push :git push origin main --tags+git push farewell main --tags(farewell : le repo doit exister sur l'instance ; push-to-create désactivé). - Construire les 2 APK au niveau du tag :
git checkout vX.Y.Z→./gradlew assembleRelease assembleDebug→ copierapp-release.apk→/tmp/apks/HormoneTrack-vX.Y.Z-release.apketapp-debug.apk→HormoneTrack-vX.Y.Z-debug.apk→git checkout main. - Publier les releases (corps = section CHANGELOG + les 2 APK attachés) :
python3 scripts/publish-release.py cloudyfy vX.Y.Zpuis idem avecfarewell(⚠️ UNE invocation = les 2 APK + vérification par téléchargement — NE PAS utiliser gitea-release.py deux fois de suite, les uploads rapprochés se remplacent mutuellement, cf §14 #43 ; tokengitea.farewell.devrequis dans le trousseau). - Smoke-test R8 sur téléphone (le release APK n'est pas vérifiable en tests JVM) : installation par-dessus l'existant, graphiques, export ET import d'un backup JSON, rappel. Le debug APK est le repli (même signature).
- Mettre à jour §2/§8/§19/§21 si besoin puis pusher la doc.
Notes :
-
L'APK est une build debug signée avec la clé debug locale — installable en sideload, mises à jour entre versions OK (même signature) ;
-
Le dépôt est privé : le téléchargement des releases exige d'être connecté ; rendre le dépôt public rend les APK téléchargeables sans compte (aucune donnée 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 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) :
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é) :
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 :
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 <cx> <cy> # 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. Synthèse :
- GT 3 = Lite Wearable ; GB supporte la GT 3 (« mostly supported ») : notifications ✓,
watchfaces
.hwt✓, apps.hap✗ - Health et GB ne peuvent pas être appairés simultanément
- Watchface via GB : aucune signature requise ; app
.hap: certificat debug AGC + UDID (chaîne DevEco Studio → DevEco Assistant) - Régression connue : HarmonyOS 6.1+ casse l'install
.hwtvia GB (issues #5968/#6005/#6199) ; GT 3 en HarmonyOS 4.0.0.120 probablement OK, à valider - Choix v1 : notifications via GB/Health ; Phase 2 : watchface custom (statique) ou mini-app Lite Wearable autonome (Wear Engine = accès partenaire)
18. Espace disque & coûts
Mesuré le 5 sept. 2026 (Mac, 228 Go, 33 Go libres au départ) :
| Élément | Taille |
|---|---|
| SDK Android (cmdline-tools + platforms 34/36/37 + build-tools 34/36/37 + platform-tools) | ≈ 700 MB |
| Cache Gradle (~/.gradle, plusieurs distributions 8.9→9.7.1 + deps AGP 9/Compose 1.12) | ≈ 3–4 GB |
| Projet (sources + build outputs) | ≈ 100 MB |
| Total outillage actuel | ≈ 4–5 GB |
Marges : émulateur + image système ≈ +2–3 GB ; DevEco Studio (Phase 2) ≈ +10 GB → tout rentre très largement. Note : AGP télécharge automatiquement les plateformes manquantes (licences signées) — c'est comme ça que android-37 est arrivé.
19. Limites connues
Volontaires (v1) :
- Pas de ViewModel/DI (couplage UI↔repo via CompositionLocal)
- Modèle T empirique (non publié) — étiqueté estimation partout
- Import JSON = écrasement depuis v1.2.6 (mode fusion non implémenté)
- Profils par tables (pas par formule) : les D/k1–k3 de l'ODS ne sont pas consommés — rétro-ingénierie des fits non tentée ; les tables sont exactes
- DST : les rappels quotidiens peuvent glisser d'1 h après changement d'heure, jusqu'au prochain reschedule (boot/save) — mineur
- Labs : marqueur libre — E2/T exacts requis pour calibration/charts
allowBackup=false→ seul backup = export JSON manuel- APK release (R8) : la réflexion Gson est couverte par des
-keepexplicites, 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 » - Grille de rappel sous-quotidienne (v1.4.0) : une Posologie < 1 j
(ex. 2 injections/jour) + UNE heure de rappel → l'app ne peut exprimer
qu'un rappel/jour à heure fixe les jours de créneau (épinglé par
ReminderScheduleTest) ; deux heures de rappel distinctes = évolution - Lint (v1.3.4) :
./gradlew lintest vert —StringFormatMatcheset famille restent des ERREURS bloquantes ; les checks Compose 1.12+ théoriques (NonObservableLocale,LocalContextGetResourceValueCall, staleness de configuration) sont rétrogradés en WARNING viaapp/lint.xml→ à re-traiter lors d'une refonte i18n (§20)
20. Idées d'évolution
- Robolectric + tests Compose (VM Android en JVM — pas besoin d'appareil)
- Émulateur local pour smoke-tests UI (sur demande, ~2–3 Go)
- Mode « planifier les injections » (schedule récurrent → pré-remplir le log)
- Import JSON : détection de doublons / mode fusion optionnel (l'écrasement est fait, v1.2.6)
- Verrou biométrique (BiometricPrompt), widget, export CSV
- Charts : tooltip au toucher (le pan est fait v1.2.0, le zoom v1.2.9) ; MaterialExpressiveTheme quand l'API passera publique (cf §3)
- Vrai keystore de distribution (signature dédiée ≠ clé debug → nécessite une migration : backup → désinstallation → installation signée → réimport)
Fait (à ne pas refaire) : pan du chart (v1.2.0), zoom du chart (v1.2.9),
pics/creux (v1.2.3), prévision par fréquence (v1.2.0), calibration par période
d'ester E2 et T (v1.2.1/v1.2.3), édition doses (v1.1.0) et labs (v1.2.2),
E2+T en une entrée (v1.2.2), migration Room v1→v2 sans fallback destructif
(v1.2.0), rooms v2→v3 (calendarEventId, v1.3.0), dépôt Gitea + releases APK
(push session).
8. Phase 2 montre : watchface .hwt custom, puis mini-app Lite Wearable (cf §17)
21. Checklist de test manuel
Sur le téléphone de test (à compléter par l'utilisatrice) :
- App se lance sans crash (asset chargé — sinon cf §14.4)
- Créer traitement « EV — Estrannaise » 4 mg + rappel 2 min à l'avance
- Notif arrive sur le téléphone et la GT 3 (via GB ou Health)
- « Pris » → dose loguée dans Doses ; « Reporter 1 h » → nouvelle notif 1 h après
- Logger 2–3 injections passées → Home affiche E2/T + delta 6 h cohérents (4 mg EV → pic ≈ 4×61×scale ≈ 244 pg/mL à scale=1)
- Ajouter un lab E2 → « Calibrer avec les analyses » → scaleFactor plausible (0,5–1,2)
- Labs T + « Calibrer k » → k mis à jour, courbe T proche des points
- Charts 24 h/7 j/30 j, toggles T/labs, axes lisibles
- Export JSON → fichier inspectable ; ré-import → compteur correct
- Langue FR↔EN↔Système : UI + notifs basculent
- Redémarrer le téléphone → rappel reprogrammé (BootReceiver)
- Désactiver un rappel → plus de notif (cancel — cf §14.3)
- Tester l'installation d'une watchface
.hwtvia Gadgetbridge (pour la Phase 2) - v1.2.x : graphique panoramique (glisser → passé, bouton « Revenir à maintenant »)
- v1.2.x : toggles Estrannaise/TFS indépendants (les deux courbes superposées)
- v1.2.x : chip « Prévision » (configurer la Posologie d'un traitement d'abord)
- v1.2.x : chip « Pics / creux » (triangles ▲▼ aux extrema E2 et T)
- v1.2.x : Calibration automatique ON → courbes ajustées depuis les labs (par période d'ester si changement d'ester), OFF → valeurs stockées
- v1.2.x : tap sur le mini-chart de l'accueil → écran Graphiques
- v1.2.x : édition d'une dose (tap ligne Doses) et d'un lab (tap ligne Analyses → sélecteur E2/T si paire) ; suppression par prise entière
- v1.2.x : prise de sang E2 + T en une entrée (champs optionnels)
- v1.2.9 : zoom du graphique (pinch 2 doigts + boutons − / + ; labels X adaptatifs ; courbes lisses à fort zoom)
- v1.2.7 : prévision après un OUBLI (pas de faux pic dans le passé) et après un RETARD (prévision décalée, suivant la dernière prise)
- v1.2.6 : import d'un backup avec l'app déjà remplie → ÉCRASER & restaurer (message clair), tConfig restauré, rappels reprogrammés
- v1.2.4 : passer un traitement à inactif → retiré de « Log rapide » et du dropdown des nouvelles doses, rappel annulé, mais sa simulation reste sur le graphique et la calibration couvre toujours ses périodes
- v1.3.3 : export des logs → le gestionnaire de fichiers s'ouvre, le .txt est écrit, un message « Logs de diagnostic exportés » s'affiche ; cas d'échec (annulation du chooser = silencieux, échec d'écriture = message d'erreur, l'app ne doit JAMAIS planter — bug #45, Pixel 9 /e/OS)
- v1.3.3 : version affichée « 1.3.3 » dans Paramètres et en titre du dialog « Nouveautés » (bug #46 : les APK v1.3.0–1.3.2 affichaient 1.3.0) ; au premier lancement de v1.3.3, le dialog liste 1.3.1→1.3.3 (dernière vue mémorisée = « 1.3.0 » sur les anciens APK)
- v1.3.3 : événement d'agenda : le switch demande la permission agenda (cette fois le système doit vraiment montrer le dialogue de permission — elle est désormais dans le manifest, bug #44) ; après 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 lintvert — intégré à la vérification de release (step 2 de la checklist §16 désormais : tests + lint) - v1.3.5 : agenda end-to-end : sur un traitement actif avec Posologie, activer « Événement d'agenda récurrent » → permission accordée → sauvegarder → l'événement existe dans le calendrier local « HormoneTrack » (« créé/mis à jour (id=…) » dans les logs exportés) ; puis désactiver le switch → re-save → l'événement disparaît RÉELLEMENT de l'app d'agenda (bugs #50 / #50 bis / #51 : en v1.3.4 ni création ni suppression ne fonctionnaient, et les re-saves orphelinaient les événements — cf §14)
- v1.4.0 : rappel = jour du créneau uniquement (fix #52) : un traitement avec Posologie 7 j + rappel 18 h ne doit sonner que le jour d'injection à 18 h (pas les autres jours) ; la carte « Prochaine dose » de l'accueil montre le même créneau ; après « Pris » dans la notification, le rappel suivant se cale sur le créneau d'après
- v1.4.0 : courbes TFS V3C : superposer Estrannaise/TFS sur un traitement EV — les deux doivent être proches aux pics (58–61 pg/mL/mg) et diverger en queues (V3C = queues exactes) ; vérifier un preset EB/EC/PEP (nouvelles courbes) ; PEP à 32,5 mg → niveaux comparables à un EEn à 5 mg
- v1.4.1 : prévision sans saut : ouvrir Graphiques → activer le chip Prévision → le graphique NE BOUGE PAS (avant v1.4.1 il sautait « tout à droite ») ; tirer vers la GAUCHE → la courbe projetée défile vers le futur (jusqu'à 1 an selon la Posologie) ; « Revenir à maintenant » ramène ; désactiver le chip dans le futur → retour auto
- v1.4.1 : carte « Prochaine dose » en jours : avec un créneau à > 24 h, l'accueil affiche « 5 j 2 h · sam. 6 18:00 (traitement) » (et non « 122h22 ») ; à < 24 h, format heures/minutes inchangé
- v1.4.2 : seuils d'alerte : Paramètres → « Seuils d'alerte » → E2 haut 200 (avec un niveau estimé > 200) → Save → message « Saved » + notification immédiate (canal dédié) + carte rouge sur l'accueil (« ▲ E2 estimé ≈ X — au-dessus de ta limite (200) ») ; re-save sans changer → PAS de nouvelle notif (anti-spam) ; seuil haute au-dessus du niveau → tout disparaît ; validation : haut ≤ bas → message rouge
- v1.4.2 : backup avec paramètres : Export JSON → le fichier
contient
"settings"(langue, auto_calibrate, seuils) ; réinstaller + Import → langue/seuils restaurés ; un VIEUX backup (v1.4.1) s'importe sans les réglages (rétrocompat) ; smoke-test R8 : export/import (réflexion Gson sur UserSettings,-keepajouté) - v1.4.3 : delta 6 h de l'accueil : le lendemain d'une injection EEn (plateau), la carte « niveau actuel » doit comparer à il y a 6 h RÉELLEMENT (bug #53 : avant, la comparaison portait sur 24 h et le delta pouvait être trompeusement négatif) — vérifier la cohérence avec la courbe 24 h du graphique
Doc mise à jour le 7 sept. 2026 (v1.4.5) — build OK, lint vert, 145/145 tests verts (123 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. Fil des corrections : v1.3.3 = reprise de maintenance (permissions agenda, export logs IO, bump de version) ; v1.3.4 = 2 crashs reproduits sur émulateur (#47 stringResource arity, #48 LocalDate+pattern horaire), lint filet bloquant ; v1.3.5 = diagnostic à distance via les logs exportés (#50/#50bis/#51 agenda) ; v1.4.0 = modèle TFS V3C sur la méta-analyse officielle (7 esters, fidélité ~1 % épinglée) + rappels sur la grille Posologie (#52) ; v1.4.1 = prévision étendue sans saut (scroll futur, horizon 1 an) + delta en jours sur l'accueil ; v1.4.2 = seuils d'alerte configurables (carte accueil + notification WorkManager 15 min + anti-spam) et backup JSON v2 avec paramètres ; v1.4.3 = fix du delta 6 h (#53) + régression n°4 (plateau EEn épinglé) ; v1.4.4 = prévision réparée (captures figées #54 + extension de fenêtre) ; v1.4.5 = marqueurs de doses + minuit local + fuseau configurable (#55) + boucle de recomposition tuée (#56). Dette connue : fragments de labs dans l'historique git (v1.1.0→v1.2.3) — cf §8.bis.