v1.10.1 : durcissement rappels — singleTop (onNewIntent vivant), instant planifié dans la garde, BootReceiver goAsync

- launchMode="singleTop" sur MainActivity : un tap notification app-ouverte
  délivre onNewIntent SANS recréer l'activité (l'ancien standard+CLEAR_TOP
  recréait tout et rendait le chemin v1.10.0 inopérant — code mort).
- EXTRA_PLANNED_AT : l'instant planifié voyage dans l'alarme (scheduleFor),
  le snooze le forward, le garde « créneau déjà honoré » évalue le jour du
  CRÉNEAU et non celui du feu (alarme inexacte/Doze glissant après minuit).
- BootReceiver : goAsync + coroutine (dette §20.bis #2), runBlocking supprimés.
- Audit doc : comptes résiduels 223 → 259 (§14 #64, §16.ter) ; §9/§20.bis à jour.

259 tests JVM + 14 UI + lint verts ; validations émulateur sur APK RELEASE
avec données réelles seedées (singleTop 0 recreation, planned_at A/B, 0 crash).
Web non concerné (aucune logique miroir touchée).
This commit is contained in:
Siphonight 2026-09-28 21:49:06 +02:00
parent 0bb259d12d
commit d531ecae24
7 changed files with 117 additions and 32 deletions

View File

@ -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 {

View File

@ -26,7 +26,13 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize">
<!-- launchMode singleTop (v1.10.1) : un tap sur la notification de
rappel quand l'app est DÉJÀ ouverte délivre onNewIntent (avec les
extras open_log_dose/treatment_id) SANS détruire/recréer
l'activité — l'ancien comportement (standard + CLEAR_TOP)
recréait tout, et onNewIntent était du code mort. -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />

View File

@ -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<Long?>(null)
private val logDoseRequest = mutableStateOf<Long?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

View File

@ -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)
}
}
}

View File

@ -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()
}
}
}

View File

@ -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)

View File

@ -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<Long?>` 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