diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e5b4e32..1fb2a81 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -33,8 +33,8 @@ android { // tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0" // (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans // les APK publiés (Paramètres + titre du dialog « Nouveautés »). - versionCode = 46 - versionName = "1.10.0" + versionCode = 47 + versionName = "1.10.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5acbbbb..c94a945 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -26,7 +26,13 @@ + diff --git a/app/src/main/java/com/hormonetrack/MainActivity.kt b/app/src/main/java/com/hormonetrack/MainActivity.kt index 4569b9f..a42c547 100644 --- a/app/src/main/java/com/hormonetrack/MainActivity.kt +++ b/app/src/main/java/com/hormonetrack/MainActivity.kt @@ -10,6 +10,7 @@ import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.runtime.mutableStateOf import androidx.core.os.LocaleListCompat import androidx.lifecycle.lifecycleScope import com.hormonetrack.ui.HormoneTrackRoot @@ -31,7 +32,7 @@ class MainActivity : AppCompatActivity() { * Accueil détruisait les `remember` locaux, le paramètre lui vivait au * niveau activité → le dialog se rouvrait à CHAQUE retour sur l'accueil). */ - private val logDoseRequest = androidx.compose.runtime.mutableStateOf(null) + private val logDoseRequest = mutableStateOf(null) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/app/src/main/java/com/hormonetrack/reminder/DoseActionReceiver.kt b/app/src/main/java/com/hormonetrack/reminder/DoseActionReceiver.kt index 00a4489..05e5dbc 100644 --- a/app/src/main/java/com/hormonetrack/reminder/DoseActionReceiver.kt +++ b/app/src/main/java/com/hormonetrack/reminder/DoseActionReceiver.kt @@ -43,7 +43,14 @@ class DoseActionReceiver : BroadcastReceiver() { ReminderContract.ACTION_SNOOZE -> { val name = intent.getStringExtra(ReminderContract.EXTRA_TREATMENT_NAME) ?: "" val dose = intent.getDoubleExtra(ReminderContract.EXTRA_DOSE_AMOUNT, 0.0) - AlarmScheduler(context).scheduleSnooze(treatmentId, name, dose) + // v1.10.1 : le snooze FORWARDE l'instant planifié d'origine — + // le garde du feu différé évalue le jour du CRÉNEAU, pas + // celui du snooze (sinon un snooze à cheval sur minuit + // évaluerait le mauvais jour). + val plannedAt = if (intent.hasExtra(ReminderContract.EXTRA_PLANNED_AT)) { + intent.getLongExtra(ReminderContract.EXTRA_PLANNED_AT, 0L) + } else null + AlarmScheduler(context).scheduleSnooze(treatmentId, name, dose, plannedAt = plannedAt) } } } diff --git a/app/src/main/java/com/hormonetrack/reminder/ReminderManager.kt b/app/src/main/java/com/hormonetrack/reminder/ReminderManager.kt index 14dbcd5..9310b46 100644 --- a/app/src/main/java/com/hormonetrack/reminder/ReminderManager.kt +++ b/app/src/main/java/com/hormonetrack/reminder/ReminderManager.kt @@ -20,7 +20,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking object ReminderContract { const val CHANNEL_ID = "hormonetrack_reminders" @@ -31,6 +30,17 @@ object ReminderContract { const val EXTRA_TREATMENT_NAME = "treatment_name" const val EXTRA_DOSE_AMOUNT = "dose_amount" + /** + * Instant PLANIFIÉ de l'alarme (v1.10.1) — embarqué par + * AlarmScheduler.scheduleFor au moment du scheduling et relu par + * [ReminderReceiver]. ⚠️ Nécessaire car une alarme INEXACTE (setWindow + * sans SCHEDULE_EXACT_ALARM) ou retardée (Doze) peut fire après la + * frontière de jour : le garde « créneau déjà honoré » doit évaluer le + * jour PLANIFIÉ, pas le jour réel du feu. Absent (vieux PendingIntents) + * → le receiver retombe sur l'heure courante. + */ + const val EXTRA_PLANNED_AT = "planned_at" + fun reminderIntent(context: Context, treatmentId: Long): Intent = Intent(context, ReminderReceiver::class.java).apply { action = ACTION_REMINDER @@ -54,10 +64,16 @@ class AlarmScheduler(private val context: Context) { private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager - private fun pendingBroadcast(treatmentId: Long, name: String?, dose: Double?): PendingIntent { + private fun pendingBroadcast( + treatmentId: Long, + name: String?, + dose: Double?, + plannedAt: Long? = null + ): PendingIntent { val intent = ReminderContract.reminderIntent(context, treatmentId) name?.let { intent.putExtra(ReminderContract.EXTRA_TREATMENT_NAME, it) } dose?.let { intent.putExtra(ReminderContract.EXTRA_DOSE_AMOUNT, it) } + plannedAt?.let { intent.putExtra(ReminderContract.EXTRA_PLANNED_AT, it) } return PendingIntent.getBroadcast( context, treatmentId.toInt(), @@ -81,12 +97,27 @@ class AlarmScheduler(private val context: Context) { val fireAt = PharmacokineticEngine.nextReminderFireFor( treatment, doseLogs, System.currentTimeMillis() ) ?: return false - val pi = pendingBroadcast(treatment.id, treatment.name, treatment.doseAmount) + // L'instant planifié voyage dans l'intent : le garde « créneau déjà + // honoré » (v1.10.1) évalue le jour PLANIFIÉ, pas celui du feu réel + // (une alarme inexacte peut glisser après minuit — cf + // ReminderContract.EXTRA_PLANNED_AT). + val pi = pendingBroadcast(treatment.id, treatment.name, treatment.doseAmount, fireAt) return scheduleAt(fireAt, pi) } - fun scheduleSnooze(treatmentId: Long, name: String, dose: Double, delayMs: Long = 60 * 60_000L): Boolean { - val pi = pendingBroadcast(treatmentId, name, dose) + /** + * Alarme « Reporter 1 h ». [plannedAt] : l'instant PLANIFIÉ du créneau + * d'origine — forwardé tel quel pour que le garde du feu différé + * évalue toujours le bon jour (et non le jour du snooze). + */ + fun scheduleSnooze( + treatmentId: Long, + name: String, + dose: Double, + delayMs: Long = 60 * 60_000L, + plannedAt: Long? = null + ): Boolean { + val pi = pendingBroadcast(treatmentId, name, dose, plannedAt) return scheduleAt(System.currentTimeMillis() + delayMs, pi) } @@ -217,19 +248,27 @@ class ReminderReceiver : BroadcastReceiver() { // (prise saisie avant l'heure de rappel), la notification est SAUTÉE — // elle ne doit pas sonner pour une prise déjà faite — et le rappel // suivant repart au créneau suivant (re-schedule dans tous les cas). + // v1.10.1 : le jour évalué est celui du créneau PLANIFIÉ + // (EXTRA_PLANNED_AT), pas celui du feu réel — une alarme inexacte + // peut glisser après minuit sans changer de créneau. val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val plannedAtMs = if (intent.hasExtra(ReminderContract.EXTRA_PLANNED_AT)) { + intent.getLongExtra(ReminderContract.EXTRA_PLANNED_AT, 0L) + } else { + System.currentTimeMillis() + } val pending = goAsync() CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { try { val db = AppDatabase.getInstance(context) val doses = db.doseLogDao().getAllOnce() if (PharmacokineticEngine.hasDoseLoggedOnDay( - treatmentId, doses, System.currentTimeMillis() + treatmentId, doses, plannedAtMs ) ) { AppLog.log( "reminder", - "créneau déjà honoré (dose loguée aujourd'hui) → notification sautée (treatment=$treatmentId)" + "créneau déjà honoré (dose loguée le jour planifié) → notification sautée (treatment=$treatmentId)" ) } else { manager.notify(treatmentId.toInt(), notification) @@ -247,23 +286,23 @@ class ReminderReceiver : BroadcastReceiver() { class BootReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action != Intent.ACTION_BOOT_COMPLETED) return - val result = goAsync() - Thread { + // v1.10.1 : consolidation goAsync + coroutine (pattern ReminderReceiver — + // remplace le Thread + double runBlocking de la v1.0, dette §20.bis #2) : + // une seule lecture séquentielle des DAO dans un scope IO dédié, finish() + // garanti par le finally. + val pending = goAsync() + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { try { val db = AppDatabase.getInstance(context) // TOUS les traitements : les inactifs doivent voir leur alarme // annulée au boot (sinon une alarme périmée continue de sonner) ; // les doses alimentent la grille Posologie (fix #52) - val treatments = runBlocking { - db.treatmentDao().getAllOnce() - } - val doses = runBlocking { - db.doseLogDao().getAllOnce() - } + val treatments = db.treatmentDao().getAllOnce() + val doses = db.doseLogDao().getAllOnce() AlarmScheduler(context).rescheduleAll(treatments, doses) } finally { - result.finish() + pending.finish() } - }.start() + } } } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 736d8a8..1708f31 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,6 +3,31 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/). Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.10`, …). +## [1.10.1] — 2026-09-28 (versionCode 47) + +### Amélioré — durcissement du système de rappels (revue de code post-v1.10.0) + +- **`launchMode="singleTop"` sur MainActivity** : un tap sur la notification + quand l'app est DÉJÀ ouverte délivre désormais `onNewIntent` (dialog de + saisie ouvert, extras traités) **sans détruire/recréer l'activité** — + l'ancien combo `standard` + `CLEAR_TOP` recréait tout et rendait le chemin + `onNewIntent` de la v1.10.0 inopérant (code mort). +- **Instant PLANIFIÉ dans l'alarme (`EXTRA_PLANNED_AT`)** : le garde + « créneau déjà honoré » (v1.10.0) évaluait le jour au moment du FEU — une + alarme inexacte (`setWindow`) ou retardée (Doze) glissant après minuit + évaluait le mauvais jour (23:58 → 00:02). L'instant planifié voyage + désormais dans l'intent de l'alarme, le snooze le FORWARDE tel quel, et + le garde évalue toujours le jour du CRÉNEAU. Vérifié sur émulateur par + broadcast avec `planned_at` daté d'hier → notification correctement + sautée. +- **`BootReceiver` consolidé** (dette §20.bis #2) : `goAsync` + coroutine IO + (pattern ReminderReceiver) — remplace le `Thread` + double `runBlocking` + de la v1.0 ; une seule lecture séquentielle des DAO, `finish()` garanti. +- Audit doc : comptes de tests périmés corrigés (§14 note #64, §16.ter). + +Vérifié : 259 tests JVM + 14 tests UI + lint verts ; scénarios émulateur +(singleTop + planned_at). Web non concerné (aucune logique miroir touchée). + ## [1.10.0] — 2026-09-27 (versionCode 46) ### Corrigé — la recommandation de prise de sang précédait la stabilisation (#68) diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index 9b2c8d9..4db0684 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -63,6 +63,7 @@ Fonctionnalités v1 : | Date | Événement | |---|---| +| 28 sept. 2026 (session v1.10.1) | **Durcissement rappels — revue de code post-v1.10.0** (audit demandé : « optimisations ou problèmes, web et Android »). (a) **`launchMode="singleTop"`** : le combo `standard`+`CLEAR_TOP` recréait l'activité au tap notification app-ouverte et rendait le `onNewIntent` de la v1.10.0 inopérant (code mort) — désormais délivré SANS recreation (état UI préservé). (b) **`EXTRA_PLANNED_AT`** : le garde « créneau déjà honoré » évaluait le jour au moment du FEU — une alarme inexacte (`setWindow`/Doze) glissant 23:58→00:02 évaluait le mauvais jour ; l'instant planifié voyage dans l'intent (scheduleFor), le snooze le FORWARDE, le garde évalue le jour du CRÉNEAU ; vérifié émulateur (broadcast `planned_at` hier → notif sautée). (c) **`BootReceiver` consolidé** (dette §20.bis #2 FAIT) : `goAsync`+coroutine, `runBlocking` supprimés. (d) Style (import `mutableStateOf`) + audit doc (comptes 223 résiduels §14/#64 et §16.ter → 259 ; §9 et §20.bis à jour). Web NON concerné (miroir lab-timing laissé tel quel — boucle correcte et testée ; `importJson` par lots reste en dette). 259 tests JVM + 14 UI + lint verts. | | 27-28 sept. 2026 (session v1.10.0) | **2 fixes + 1 amélioration rappels** (remontées + nouvelles données réelles → 6ᵉ régression épinglée `backup-v1.9.8.json`). (a) **#68 — reco de prise de sang antérieure à la stabilisation** : le « creux » = minimum de la FENÊTRE ENTIÈRE tombait juste après l'injection PRÉCÉDENTE pour les esters à montée lente (EEn pic ~J+5 ≈ intervalle 7 j → creux d'absorption ~3 h post-injection) — date gouvernée par l'injection N−1 (antérieure à la stab) et « juste avant ton injection du … » faux de plusieurs jours (réel : creux 5 oct 02:32 < stab 7 oct 04:03). FIX : creux = niveau PRÉ-INJECTION du créneau (restaure le principe v1.8.0 documenté ; EV inchangé — le point pré-injection EST son min de fenêtre) ; **miroir web appliqué** (lab-timing.js). Tests : RegressionUserCase6Test data-driven dont balayage de `now` ±60 j (invariant « creux ≥ stab » à TOUT INSTANT) ; les 17 LabTimingTest passent inchangés. (b) **#69 — dialog fantôme** : tap notification → dialog ; le fermer + changer d'onglet + revenir sur l'accueil le ROUVRAIT à chaque fois (paramètre d'activité vs consommation dans les remember de Home, détruits à chaque sortie d'onglet — reproduit sur émulateur AVANT fix). FIX : `MutableState` détenu par MainActivity, consommé UNE fois (extras retirés de l'intent après lecture ; bonus : tap notification app-ouverte ouvre aussi le dialog). Test UI `LogDoseRequestTest` + vérification émulateur. (c) **Rappel sauté si dose déjà loggée** (demande) : garde au déclenchement — `hasDoseLoggedOnDay` (pur, jour calendaire LOCAL) dans ReminderReceiver → notif sautée + re-programmation créneau suivant dans tous les cas ; 6 tests JVM + vérification A/B émulateur (EEn dose-du-jour → 0 notif, Fluoxetine sans dose → 1 notif). **259 tests JVM + 14 UI + lint** ; **web 180 tests + E2E verts** (check.sh). **Publication** : commit a4f859f + tag v1.10.0 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 77, farewell id 1588583). **Web miroir v1.10.0 sync** (fix #68 lab-timing, WEB_VERSION alignée, commit b865b16 + tag poussés ; #69/garde rappel = Android-only, notés dans le changelog web). | | 22 sept. 2026 (session v1.9.8) | **Socle qualité : tests UI Compose + signature release dédiée + découpage Settings** (revue de code du 21 sept. : « pas de CI, pas de tests UI, APK signé clé debug, écrans qui grandissent »). (a) **13 tests UI instrumentés** (`app/src/androidTest/uitest/`, §8.ter) : navigation 5 onglets (preuves = éléments inconditionnels : chips 24 h, FAB), dialog Nouveautés (épinglage du fix v1.3.2 : ne réapparaît pas après dismiss+restart), sections Paramètres + version installée = BuildConfig (le bug #46 ne peut plus revenir) ; déterminisme = pm grant shell pré-launch + markChangelogSeen + assertions via targetContext (indépendant FR/EN) + zéro dépendance aux données Room ; deps androidTest (BOM + ui-test-junit4 + ext:junit-ktx + junit explicite). (b) **Signature release conditionnelle** (§16.quater) : `scripts/make-release-keystore.sh` (shellcheck+shfmt) génère keystore + keystore.properties (gitignorés), `assembleRelease` l'utilise s'il existe, sinon fallback clé debug inchangé ; migration backup→désinstall→réimport documentée. (c) **SettingsScreen découpé** (840 → 377 + 11 fichiers 33–164 lignes dans `ui/screens/settings/`, §10) — déplacement pur, commentaires historiques conservés, ChartScreen volontairement entier (garde de source #54). **Vérifications** : 249 tests JVM + 13 UI verts, lint vert, assembleDebug/Release OK, **smoke test RELEASE émulateur** (accueil, 5 onglets, Paramètres complet scroll compris — version 1.9.8 visible, éditeur traitement, dialog dose + garde « crée un traitement », 0 crash logcat — anti-#64). **README EN** (README.en.md, liens FR/EN croisés ; DEVELOPPEMENT.md reste FR par choix assumé). **Publication** : commit 09cddfc + tag v1.9.8 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 75, farewell id 1588581). Web miroir NON concerné (v1.9.8 = socle Android uniquement : tests instrumentés, signature APK, découpage Compose, README du dépôt Android — rien à porter côté web, qui reste à v1.9.7). | | 20 sept. 2026 (session v1.9.7) | **Fix « la suggestion de prise de sang change tout le temps »** (remontée + données réelles : logs d'injection à heures variables) : la règle de stabilisation v1.8.1 comparait l'écart inter-doses EXACTEMENT à l'écart précédent — un log 30 min plus tard cassait le régime et repoussait la stabilisation de 5 × t½ à CHAQUE injection (creux fuyant). FIX : l'écart doit rester dans **l'intervalle de Posologie ± 24 h** (`GAP_TOLERANCE_MS`, fenêtre vs l'interval THÉORIQUE — des logs 6,8 j puis 7,2 j ne se déstabilisent plus en cascade ; un vrai passage à 2 j reste hors fenêtre). Le créneau affiché suit toujours la dernière dose réelle (voulu). Tests : « interval change » ré-épinglé + 2 tests de tolérance. 249 verts + lint. Web miroir v1.9.7 (12 verts lab-timing). §7.11 mis à jour. | @@ -1092,6 +1093,12 @@ la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du mod créneau SUIVANT reste exécutée dans tous les cas. Vérifié A/B sur émulateur (`am broadcast -n …ReminderReceiver` : EEn avec dose du jour → 0 notif, Fluoxetine sans dose → 1 notif). + **v1.10.1 — instant PLANIFIÉ (`EXTRA_PLANNED_AT`)** : le garde évalue le + jour du CRÉNEAU PLANIFIÉ (embarqué dans l'intent de l'alarme par + `scheduleFor`, forwardé tel quel par le snooze), pas le jour du feu + réel — une alarme inexacte (`setWindow`) ou retardée (Doze) glissant + après minuit évaluait le mauvais jour (23:58 → 00:02). Vérifié émulateur + (broadcast avec `planned_at` daté d'hier → notification sautée). - `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 notif @@ -1801,7 +1808,7 @@ l'optimiseur. le chip « Lab track » s'active MAIS la courbe n'est ni dessinée (0 pixel #C2185B au screencap) ni légendée ; « Prolonger » idem. En DEBUG, le même chip sur les mêmes données fonctionne (légende + 7090 px rose après fix). -**Les 191 tests JVM de l'époque sont verts** (223 aujourd'hui — ils ne passent JAMAIS par R8). +**Les 191 tests JVM de l'époque sont verts** (259 aujourd'hui — ils ne passent JAMAIS par R8). **Diagnostic** (méthode de bisection debug/release puis `mapping.txt`) : `com.hormonetrack.pk.LabTrajectoryModel -> R8$$REMOVED$$CLASS$$…` — R8 full @@ -2050,12 +2057,13 @@ de signature est requise — elle est désormais câblée : headless hrt36 + seed `scripts/seed-emulator.py` avec le dernier backup de `local-test-data/` + APK release installé PAR-DESSUS) sur **l'APK RELEASE** (JAMAIS le debug seul : **R8 ne se vérifie qu'ici** — - les 223 tests JVM ne le voient jamais, cf #64). Parcours minimum : - l'app démarre 0 crash → écran Graphiques → activer les chips de la - release (rangée modèles défilable : `adb shell input swipe 900 716 150 - 716 400` puis tap aux bounds du `uiautomator dump`) → vérifier dans le - dump `text=` que la légende attendue apparaît → `adb logcat -b crash - -d | grep -c FATAL` reste à 0. Limites : le pan/pinch n'est PAS + les 259 tests JVM ne le voient jamais, cf #64). Parcours minimum : + l'app démarre 0 crash → écran Graphiques → activer les chips de la + release (rangée modèles défilable : `adb shell input swipe 900 716 150 + 716 400` puis tap aux bounds du `uiautomator dump`) → vérifier dans le + dump `text=` que la légende attendue apparaît → `adb logcat -b crash + -d | grep -c FATAL` reste à 0 (les **259** tests JVM ne voient jamais + R8, cf #64). Limites : le pan/pinch n'est PAS testable par adb (§16.ter 4.bis) — le geste reste validé par l'utilisatrice sur Pixel 9. 4. **Commit** (message descriptif par couche) + **tag annoté** : @@ -2288,10 +2296,9 @@ pour ne pas les redécouvrir et re-reposer les mêmes questions : Room par ligne. `@Insert` en liste préserverait l'ordre (IDs) et accélérerait l'import d'un gros historique. Différé : surface DAO + repository à modifier, validation manuelle (aucun test Room auto). -2. **`BootReceiver` : consolidation `goAsync` + une seule lecture** : - les deux `runBlocking` séquentiels peuvent fusionner trivialement ; le - passage complet au pattern `goAsync`+coroutine de ReminderReceiver est - équivalent mais re-testé sur émulateur (boot). Différé. +2. ~~**`BootReceiver` : consolidation `goAsync` + une seule lecture`~~** + **FAIT v1.10.1** (pattern `goAsync`+coroutine de ReminderReceiver, + `runBlocking` supprimés du fichier). 3. **Double évaluation de `generateForecastDoses`** dans ChartScreen (producer `forecastDoses` vs producer `curves`) : NE PAS fusionner tel quel — le premier utilise `System.currentTimeMillis()`, le second le