v1.7.0 : sauvegarde automatique quotidienne (opt-in) + unités des axes

AUTO-BACKUP JOURNALIER (en plus de l'export manuel, inchangé) :
- AppSettings : enabled / treeUri (SAF persisté) / keep (1..30, défaut 7) /
  lastRun ; BackupManager.writeAutoBackup : tree URI → createDocument d'un
  fichier horodaté (ExportFileNames.autoBackupFileName — signature
  LocalDateTime, règle anti-#48) → écriture via writeBackup TEL QUEL
  (leçon #45) → purge par AutoBackupRetention (PURE, testée : ne touche
  JAMAIS exports manuels/logs/étrangers, keep clampé ≥ 1).
- AutoBackupWorker + AutoBackupScheduler (pattern AlertWorker) : périodique
  24 h (KEEP au démarrage, no-op si désactivé), runNow à l'activation
  (premier backup immédiat), statut dernier run persisté + AppLog ;
  proguard : -keep explicite du worker (réflexion WorkManager, leçon #64).
- Paramètres : carte « Sauvegarde automatique quotidienne » — switch (ON
  sans dossier → picker SAF), dossier persisté (takePersistableUriPermission),
  nom lisible (folderDisplayName), copies conservées, statut OK/ÉCHEC.
- Validé émulateur §16.ter (APK release + données réelles seedées) : dossier
  Download/HormoneTrack choisi via le picker SAF, premier backup v2 écrit
  immédiatement (3 traitements/52 doses/26 labs, importable), rétention
  keep=2 vérifiée (3 vieux autos supprimés, export manuel intact), statut
  « OK (date) », 0 crash.

UNITÉS DES AXES (remontée « jamais ajoutées depuis v1.0 ») : pg/mL (E2,
gauche) / ng/mL (T, droite) au sommet des colonnes de labels (padTop
12→26 dp) — validé émulateur (screencap). Portées web (v1.7.0 web sync).

9 nouveaux tests (200 verts / 177 sans données locales) + lint vert.
versionCode 33 / versionName 1.7.0.
This commit is contained in:
Siphonight 2026-09-16 19:52:04 +02:00
parent 76a9c0eb63
commit e02c30d4f8
18 changed files with 908 additions and 12 deletions

View File

@ -16,7 +16,7 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
> sang et aux consignes de ton endocrinologue.
- **Statut** : v1.6.0 — build Android ✅, **lint vert** ✅, **191 tests unitaires** ✅ (168 sans les données de test locales ; régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅
- **Statut** : v1.7.0 — build Android ✅, **lint vert** ✅, **200 tests unitaires** ✅ (177 sans les données de test locales ; régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅
- **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md)
- **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)
@ -82,6 +82,14 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
canal dédié) — évaluées sur le taux **estimé**, opt-in
- **Sauvegarde JSON complète** : traitements + doses + analyses + réglages T
**+ paramètres (langue, auto-calibration, seuils d'alerte)** (v1.4.2)
- **Sauvegarde automatique quotidienne (v1.7.0, opt-in)** : en PLUS de
l'export manuel — chaque jour, un backup JSON complet (même format,
importable tel quel) est écrit dans le **dossier que tu choisis** (ex.
dossier Owncloud synchronisé). Aucune permission de stockage (SAF, dossier
choisi une fois, permission persistante révocable), rétention configurable
(1–30 copies, défaut 7 — fichier horodaté par run, jamais d'écrasement ;
exports manuels et fichiers étrangers **jamais touchés**), statut du
dernier run dans Paramètres, premier backup immédiat à l'activation
- **Nouveautés à chaque mise à jour** : dialog de changelog automatique
(fermé = ne réapparaît pas avant la prochaine version)
- **Temps sous THS** affiché en haut de la page Doses (depuis la 1re prise) ;
@ -102,7 +110,7 @@ par AGP si les licences sont signées). Le wrapper télécharge Gradle 9.7.1.
git clone <repo> && cd HormoneTrack
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk
./gradlew testDebugUnitTest # 191 tests (168 sans les données locales)
./gradlew testDebugUnitTest # 200 tests (177 sans les données locales)
./gradlew lint # lint vert obligatoire avant release
```
@ -184,6 +192,9 @@ forme. L'auto-calibration est optionnelle (désactivée par défaut).
depuis `local-test-data/` (**gitignoré**, hors dépôt — et l'historique a été
nettoyé avant le premier push, cf [docs/DEVELOPPEMENT.md §8.bis](docs/DEVELOPPEMENT.md))
- Sauvegarde = fichier JSON que tu stockes où tu veux (Owncloud, etc.)
- **L'auto-backup quotidien (v1.7.0) n'écrit QUE dans le dossier que tu as
toi-même choisi** (sélecteur système SAF, permission révocable) — rien
ne part ailleurs, aucun serveur
- `allowBackup=false` (données sensibles) ; verrou biométrique prévu en Phase 2
- Le dépôt est **privé** : les releases APK se téléchargent en étant connecté ;
passer le dépôt en public rend les APK téléchargeables sans compte (sans risque

View File

@ -17,8 +17,8 @@ android {
// tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
// les APK publiés (Paramètres + titre du dialog « Nouveautés »).
versionCode = 32
versionName = "1.6.0"
versionCode = 33
versionName = "1.7.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

View File

@ -30,3 +30,9 @@
# vérifier le mapping (`app/build/outputs/mapping/release/mapping.txt`)
# et étendre cette garde si besoin.
-keep class com.hormonetrack.pk.LabTrajectoryModel { *; }
# --- Auto-backup (v1.7.0) : worker instancié par WorkManager via réflexion ---
# Les règles consumer de WorkManager gardent normalement les sous-classes de
# ListenableWorker, mais le leçon #64 a montré le coût d'une garde absente :
# on l'écrit explicitement (coût = quelques entrées de classe).
-keep class com.hormonetrack.data.backup.AutoBackupWorker { *; }

View File

@ -27,6 +27,11 @@ class HormoneTrackApp : Application() {
// démarrage ne change rien ; WorkManager survit aux reboots.
AlertNotifier.createChannel(this)
AlertNotifier.schedulePeriodic(this)
// v1.7.0 : auto-backup journalier (WorkManager périodique 24 h, KEEP —
// le worker sort immédiatement si l'option est désactivée ; cf
// data/backup/AutoBackupWorker.kt). WorkManager survit aux reboots :
// BootReceiver n'a rien à faire.
com.hormonetrack.data.backup.AutoBackupScheduler.schedulePeriodic(this)
}
}

View File

@ -0,0 +1,49 @@
package com.hormonetrack.data.backup
import com.hormonetrack.util.ExportFileNames
/**
* RÉTENTION des auto-backups journaliers (v1.7.0) — décision PURE, testée en
* JVM ([com.hormonetrack.data.backup.AutoBackupRetentionTest]) : elle ne fait
* AUCUN accès disque/SAF, elle ne fait que DÉCIDER quels noms supprimer.
* L'IO (énumération et suppression SAF) vit dans
* [BackupManager.writeAutoBackup] — même séparation décision/IO que le reste
* du moteur.
*
* PRINCIPE : chaque run écrit un NOUVEAU fichier horodaté
* (`hormonetrack-auto-20260913-1830.json`) et supprime les plus vieux
* au-delà de « copies conservées » (défaut 7, borné 1..30 dans les
* Paramètres). Pourquoi ne pas ÉCRASER un fichier fixe : un backup corrompu
* (écriture interrompue, quota Owncloud plein…) écraserait la dernière
* bonne copie — avec la rétention, les N dernières copies saines restent
* disponibles.
*
* GARDES DE VIE PRIVÉE / SÉCURITÉ DE SUPPRESSION (les deux critiques ici) :
* 1. le dossier cible est choisi par l'utilisatrice (SAF, ex. un dossier
* Owncloud) et peut contenir SES propres fichiers → on ne supprime
* JAMAIS un nom qui n'est pas un auto-backup reconnaissable ;
* 2. même parmi les `hormonetrack-auto-…json`, un nom MALFORMÉ (horodatage
* non parsable) est ignoré : jamais supprimé, jamais trié.
*/
object AutoBackupRetention {
/**
* Décide quels fichiers supprimer pour respecter la rétention.
*
* @param existingNames tous les noms vus dans le dossier cible (le tri
* d'entrée est indifférent — la fonction trie elle-même)
* @param keepCount nombre de copies récentes À CONSERVER (borné : tout
* `keepCount < 1` vaut 1 — sans garde, `0` supprimerait TOUT, y
* compris la toute dernière copie)
* @return les noms à SUPPRIMER (sous-ensemble de [existingNames]) —
* vide quand la rétention est déjà respectée.
*/
fun filesToDelete(existingNames: Iterable<String>, keepCount: Int): List<String> {
val keep = keepCount.coerceAtLeast(1)
// Seuls les auto-backups RECONNAISSABLES participent à la rétention
val autos = existingNames
.mapNotNull { name -> ExportFileNames.parseAutoBackupTimestamp(name)?.let { name to it } }
.sortedByDescending { it.second } // plus récent d'abord
return autos.drop(keep).map { it.first }
}
}

View File

@ -0,0 +1,139 @@
package com.hormonetrack.data.backup
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.hormonetrack.data.backup.UserSettings
import com.hormonetrack.util.AppLog
import kotlinx.coroutines.flow.first
import java.util.concurrent.TimeUnit
/**
* SAUVEGARDE AUTOMATIQUE JOURNALIÈRE (v1.7.0) — en PLUS de l'export manuel
* (qui reste inchangé dans Paramètres). Opt-in : désactivée par défaut.
*
* FONCTIONNEMENT (miroir du worker des alertes, AlertNotifier/AlertWorker) :
* - **dossier choisi UNE FOIS** : ACTION_OPEN_DOCUMENT_TREE dans Paramètres
* → tree URI persisté (takePersistableUriPermission → la permission
* survit au reboot, SANS aucune permission de stockage) — l'utilisatrice
* choisit typiquement un dossier Owncloud synchronisé, le backup atterrit
* donc dans son cloud ;
* - **WorkManager périodique 24 h** : survit aux reboots, gère le Doze
* (pas d'alarme exacte nécessaire — un backup journalier n'a pas besoin
* d'une heure à la minute près) ; planifié au démarrage de l'app
* (HormoneTrackApp.onCreate, KEEP) — le worker sort immédiatement si
* l'option est désactivée (même pattern qu'AlertWorker sans seuil) ;
* - **run immédiat à l'activation** (`runNow`) : l'utilisatrice voit le
* fichier apparaître tout de suite — et c'est ce qui rend la validation
* émulateur §16.ter triviale ;
* - **contenu = le backup COMPLET** (`BackupManager.exportJson`, format v2 :
* traitements + doses + labs + réglages T + paramètres) — un auto-backup
* est importable EXACTEMENT comme un export manuel ;
* - **rétention** : les N copies les plus récentes sont conservées (défaut
* 7, borné 1..30) — cf [AutoBackupRetention], les exports manuels ne
* sont JAMAIS supprimés ;
* - **statut visible** : « dernier : réussi/échec + date » dans Paramètres
* (DataStore, alimenté par le worker) + journalisation AppLog complète
* (debug à distance §9.bis — le mécanisme qui a résolu #50).
*
* ⚠️ R8 (leçon #64) : le worker est instancié par WorkManager via réflexion
* → classe couverte par un `-keep` explicite dans proguard-rules.pro.
*/
object AutoBackupScheduler {
/** Nom du travail PÉRIODIQUE (unique) — 24 h. */
private const val PERIODIC_WORK = "auto_backup_daily"
/** Nom du travail one-time (run immédiat à l'activation). */
private const val ONESHOT_WORK = "auto_backup_now"
/** Période : 1 jour (WorkManager lègue au scheduler OS, Doze inclus). */
private const val PERIOD_DAYS = 1L
/**
* Planifie le travail PÉRIODIQUE — à appeler UNE FOIS dans
* Application.onCreate. KEEP : si déjà planifié, on ne repousse pas le
* prochain run (comme AlertNotifier.schedulePeriodic). Le worker lit ses
* options dans DataStore à l'exécution : changer le dossier ou le nombre
* de copies ne requiert AUCUN re-enqueue.
*/
fun schedulePeriodic(context: Context) {
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
PERIODIC_WORK,
ExistingPeriodicWorkPolicy.KEEP,
PeriodicWorkRequestBuilder<AutoBackupWorker>(PERIOD_DAYS, TimeUnit.DAYS).build()
)
}
/**
* Run IMMÉDIAT (à l'activation dans Paramètres) : feedback instantané —
* le fichier apparaît dans le dossier choisi sans attendre 24 h.
* REPLACE : plusieurs taps rapides = un seul run en attente.
*/
fun runNow(context: Context) {
WorkManager.getInstance(context).enqueueUniqueWork(
ONESHOT_WORK,
ExistingWorkPolicy.REPLACE,
OneTimeWorkRequestBuilder<AutoBackupWorker>().build()
)
}
}
/**
* Worker WorkManager de l'auto-backup : no-op si l'option est désactivée ou
* sans dossier (planifié inconditionnellement au démarrage, comme AlertWorker
* sans seuils) ; sinon export JSON complet + écriture SAF + rétention, et
* statut « dernier run » persisté pour l'affichage Paramètres.
*/
class AutoBackupWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val container = (applicationContext as com.hormonetrack.HormoneTrackApp).container
val settings = container.settings
val enabled = settings.autoBackupEnabled.first()
val treeUri = settings.autoBackupTreeUri.first()
if (!enabled || treeUri == null) {
// Option off (ou dossier jamais choisi) : rien à faire, ce n'est
// pas un échec (Result.success = le périodique continue de tourner)
return Result.success()
}
val now = System.currentTimeMillis()
val ok = try {
// MÊME contenu que l'export manuel (format v2 avec paramètres) :
// un auto-backup est importable tel quel (rétrocompat assurée)
val userSettings = UserSettings(
language = settings.language.first(),
autoCalibrate = settings.autoCalibrate.first(),
alertE2High = settings.alertThresholds.first().e2High,
alertE2Low = settings.alertThresholds.first().e2Low,
alertTHigh = settings.alertThresholds.first().tHigh,
alertTLow = settings.alertThresholds.first().tLow
)
val json = BackupManager.exportJson(container.repository, settings.tConfig.first(), userSettings)
val keep = settings.autoBackupKeep.first()
BackupManager.writeAutoBackup(applicationContext, treeUri, json, keep)
} catch (e: Exception) {
AppLog.log("autobackup", "ERREUR worker : ${e.message}")
// Statut quand même enregistré (l'UI montrera « échec »)…
settings.setAutoBackupLastRun(false, now)
// …et WorkManager re-tentera avec son backoff (IO transitoire :
// quota cloud, réseau Owncloud absent — un retry a du sens)
return Result.retry()
}
settings.setAutoBackupLastRun(ok, now)
AppLog.log("autobackup", "run terminé : ok=$ok")
// Échec d'écriture (dossier absent, permission révoquée…) : retry
// n'a pas de sens tant que l'utilisatrice ne re-choisit pas le
// dossier → success (le prochain cycle journalier réessaiera).
return Result.success()
}
}

View File

@ -1,7 +1,9 @@
package com.hormonetrack.data.backup
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.provider.DocumentsContract
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.hormonetrack.data.model.DoseLog
@ -9,6 +11,8 @@ import com.hormonetrack.data.model.LabResult
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.repository.HormoneRepository
import com.hormonetrack.pk.TConfig
import com.hormonetrack.util.AppLog
import com.hormonetrack.util.ExportFileNames
/**
* Paramètres utilisateur embarqués dans le backup (v1.4.2) — la sauvegarde
@ -133,4 +137,98 @@ object BackupManager {
} catch (e: Exception) {
null
}
// ── Auto-backup journalier (v1.7.0) ─────────────────────────────────────
/**
* Écrit UN auto-backup dans le DOSSIER SAF persisté ([treeUriString] = tree
* URI choisi par l'utilisatrice via ACTION_OPEN_DOCUMENT_TREE, ex. un
* dossier Owncloud synchronisé), puis applique la rétention
* ([AutoBackupRetention] : garde les N plus récents, ne supprime QUE les
* fichiers `hormonetrack-auto-*.json` — jamais les exports manuels ni les
* fichiers personnels du dossier).
*
* Étapes :
* 1. tree URI → document URI du dossier (DocumentsContract, SANS la lib
* androidx.documentfile : pas de dépendance ajoutée) ;
* 2. `createDocument` d'un nouveau fichier horodaté
* ([ExportFileNames.autoBackupFileName]) — jamais d'écrasement :
* la rétention garde les N dernières copies saines ;
* 3. écriture du JSON via [writeBackup] TEL QUEL (leçon §14 #45 : même
* IO éprouvée que l'export manuel — openOutputStream "wt") ;
* 4. purge des vieux auto-backups (échec NON bloquant : un refus de
* suppression ne doit pas faire échouer le backup écrit).
*
* @return true si le fichier a été écrit (la purge peut avoir échoué sans
* invalider le succès).
*/
suspend fun writeAutoBackup(
context: Context,
treeUriString: String,
json: String,
keepCount: Int
): Boolean {
return try {
val resolver = context.contentResolver
val treeUri = Uri.parse(treeUriString)
val dirUri = DocumentsContract.buildDocumentUriUsingTree(
treeUri, DocumentsContract.getTreeDocumentId(treeUri)
)
val fileUri = DocumentsContract.createDocument(
resolver, dirUri, "application/json", ExportFileNames.autoBackupFileName()
) ?: return false
val written = writeBackup(context, fileUri, json)
if (written) {
// Purge best-effort : le backup est écrit, l'échec de purge ne
// l'invalide pas (l'utilisatrice le verra dans les AppLog)
try {
purgeOldAutoBackups(resolver, treeUri, keepCount)
} catch (e: Exception) {
AppLog.log("autobackup", "purge impossible : ${e.message}")
}
}
written
} catch (e: Exception) {
AppLog.log("autobackup", "ERREUR écriture : ${e.message}")
false
}
}
/**
* Énumère le dossier cible (SAF children) et supprime les auto-backups au-delà
* de [keepCount] — décision dans [AutoBackupRetention] (pur, testé) : ne
* touche JAMAIS aux exports manuels ni aux fichiers étrangers du dossier.
*/
private fun purgeOldAutoBackups(
resolver: ContentResolver,
treeUri: Uri,
keepCount: Int
) {
val dirId = DocumentsContract.getTreeDocumentId(treeUri)
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, dirId)
// Une seule requête : (nom affiché, id document) — le delete SAF exige
// l'URI de DOCUMENT, pas le nom.
val byName = LinkedHashMap<String, String>()
resolver.query(
childrenUri,
arrayOf(
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_DOCUMENT_ID
),
null, null, null
)?.use { cursor ->
while (cursor.moveToNext()) {
val name = cursor.getString(0) ?: continue
byName[name] = cursor.getString(1)
}
}
AutoBackupRetention.filesToDelete(byName.keys, keepCount).forEach { name ->
byName[name]?.let { docId ->
DocumentsContract.deleteDocument(
resolver, DocumentsContract.buildDocumentUriUsingTree(treeUri, docId)
)
AppLog.log("autobackup", "rétention : $name supprimé")
}
}
}
}

View File

@ -6,6 +6,8 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.hormonetrack.pk.Alerts
@ -52,6 +54,31 @@ class AppSettings(private val context: Context) {
* (ex. "Europe/Paris") ; null/absent = fuseau du téléphone (auto).
*/
val CHART_TIMEZONE = stringPreferencesKey("chart_timezone")
// ── Auto-backup journalier (v1.7.0) ────────────────────────────────
/** Activer/désactiver la sauvegarde automatique (défaut : false). */
val AUTO_BACKUP_ENABLED = booleanPreferencesKey("auto_backup_enabled")
/**
* Dossier cible SAF (v1.7.0) : tree URI issu d'ACTION_OPEN_DOCUMENT_TREE,
* persisté via takePersistableUriPermission (la permission SURVIT au
* reboot — le worker peut écrire sans intervention). null = pas de
* dossier choisi → l'auto-backup ne peut pas tourner (worker no-op).
*/
val AUTO_BACKUP_TREE_URI = stringPreferencesKey("auto_backup_tree_uri")
/**
* Nombre de copies d'auto-backup conservées dans le dossier (1..30,
* défaut 7) — les plus vieilles sont supprimées après chaque run
* (cf AutoBackupRetention ; l'export MANUEL n'est jamais touché).
*/
val AUTO_BACKUP_KEEP = intPreferencesKey("auto_backup_keep")
/** Résultat du DERNIER run auto : "ok" / "error" (null = jamais tourné). */
val AUTO_BACKUP_LAST_RESULT = stringPreferencesKey("auto_backup_last_result")
/** Horodatage du dernier run auto tenté (millisecondes, 0/absent = jamais). */
val AUTO_BACKUP_LAST_AT = longPreferencesKey("auto_backup_last_at")
}
val tConfig: Flow<TConfig> = context.dataStore.data.map { prefs ->
@ -136,6 +163,57 @@ class AppSettings(private val context: Context) {
}
}
// ── Auto-backup journalier (v1.7.0) ─────────────────────────────────────
/** Auto-backup activé (défaut DÉSACTIVÉ — opt-in, comme tout ce qui écrit). */
val autoBackupEnabled: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[Keys.AUTO_BACKUP_ENABLED] ?: false
}
/** Dossier cible SAF persisté (null = pas de dossier → worker no-op). */
val autoBackupTreeUri: Flow<String?> = context.dataStore.data.map { prefs ->
prefs[Keys.AUTO_BACKUP_TREE_URI]
}
/** Copies conservées (1..30, défaut 7 — borné au SET, cf setAutoBackupKeep). */
val autoBackupKeep: Flow<Int> = context.dataStore.data.map { prefs ->
prefs[Keys.AUTO_BACKUP_KEEP] ?: 7
}
/** Résultat du dernier run : Pair(résultat, horodatage) ; null = jamais tourné. */
val autoBackupLastRun: Flow<Pair<String?, Long?>> = context.dataStore.data.map { prefs ->
prefs[Keys.AUTO_BACKUP_LAST_RESULT] to prefs[Keys.AUTO_BACKUP_LAST_AT]
}
suspend fun setAutoBackupEnabled(enabled: Boolean) {
context.dataStore.edit { prefs -> prefs[Keys.AUTO_BACKUP_ENABLED] = enabled }
}
suspend fun setAutoBackupTreeUri(uri: String?) {
context.dataStore.edit { prefs ->
if (uri.isNullOrBlank()) prefs.remove(Keys.AUTO_BACKUP_TREE_URI)
else prefs[Keys.AUTO_BACKUP_TREE_URI] = uri
}
}
/** Copies conservées — borné [1 ; 30] (un `0` supprimerait la dernière copie). */
suspend fun setAutoBackupKeep(keep: Int) {
context.dataStore.edit { prefs ->
prefs[Keys.AUTO_BACKUP_KEEP] = keep.coerceIn(1, 30)
}
}
/**
* Persiste le résultat du dernier run (appelé par [com.hormonetrack.data.backup.AutoBackupWorker])
* — affiché dans Paramètres (« dernier : réussi/échec, date »).
*/
suspend fun setAutoBackupLastRun(ok: Boolean, atMs: Long) {
context.dataStore.edit { prefs ->
prefs[Keys.AUTO_BACKUP_LAST_RESULT] = if (ok) "ok" else "error"
prefs[Keys.AUTO_BACKUP_LAST_AT] = atMs
}
}
suspend fun setAlertNotifiedState(encoded: String?) {
context.dataStore.edit { prefs ->
if (encoded.isNullOrEmpty()) prefs.remove(Keys.ALERT_NOTIFIED_STATE)

View File

@ -309,7 +309,10 @@ fun CurveChart(
if (allPoints.size < 2) return@Canvas
val padLeft = 42.dp.toPx()
val padRight = 42.dp.toPx()
val padTop = 12.dp.toPx()
// v1.7.0 : padTop élargi (12 → 26 dp) pour loger les UNITÉS d'axe au
// sommet des colonnes de labels (remontée « on n'a jamais ajouté les
// unités » — l'axe gauche était des nombres nus depuis la v1.0).
val padTop = 26.dp.toPx()
val padBottom = 26.dp.toPx()
val w = size.width - padLeft - padRight
@ -342,8 +345,18 @@ fun CurveChart(
drawGrid(gridColor, padLeft, padTop, w, h, rows = 4)
drawLeftYLabels(labelColor, e2Max, padLeft, padTop, h)
// Unités des axes (v1.7.0 — remontée : « on n'a jamais ajouté les
// unités des mesures dans les axes ») : pg/mL pour l'E2 (axe gauche),
// ng/mL pour la T (axe droit) — dessinées au SOMMET de chaque colonne
// de labels, au-dessus du premier tick (padTop élargi pour ça).
// ⚠️ Ce ne sont PAS des strings i18n : pg/mL et ng/mL sont des unités
// universelles, déjà en dur dans les légendes du chart.
drawAxisUnit(E2_UNIT, padLeft - 6.dp.toPx(), padTop - 6.dp.toPx(),
android.graphics.Paint.Align.RIGHT)
if (options.showT && series.any { it.tStyle != null }) {
drawRightYLabels(labelColor, tMax, padLeft + w, padTop, h)
drawAxisUnit(T_UNIT, padLeft + w + 6.dp.toPx(), padTop - 6.dp.toPx(),
android.graphics.Paint.Align.LEFT)
}
drawXLabels(labelColor, t0, t1, padLeft, padTop + h, w, zone)
@ -541,6 +554,26 @@ private fun DrawScope.drawExtremum(cx: Float, cy: Float, extremum: com.hormonetr
)
}
/** Unité de l'axe E2 (gauche) — universelle, pas une string i18n (cf drawAxisUnit). */
private const val E2_UNIT = "pg/mL"
/** Unité de l'axe T (droite) — idem. */
private const val T_UNIT = "ng/mL"
/**
* Libellé d'UNITÉ d'axe (v1.7.0) : petit texte gris au sommet d'une colonne
* de labels Y, aligné comme les nombres de sa colonne ([align] RIGHT pour
* l'axe gauche, LEFT pour l'axe droit).
*/
private fun DrawScope.drawAxisUnit(
text: String,
x: Float,
baseline: Float,
align: android.graphics.Paint.Align
) {
drawContext.canvas.nativeCanvas.drawText(text, x, baseline, labelPaint(align))
}
private fun DrawScope.drawGrid(color: Color, padLeft: Float, padTop: Float, w: Float, h: Float, rows: Int) { for (i in 0..rows) {
val y = padTop + h * i / rows
drawLine(color, Offset(padLeft, y), Offset(padLeft + w, y), strokeWidth = 1f)

View File

@ -179,6 +179,59 @@ fun SettingsScreen(onBack: () -> Unit) {
}
}
// ── Sauvegarde automatique (v1.7.0) : dossier SAF persisté ──────────────
// L'utilisateur choisit le dossier UNE FOIS (ACTION_OPEN_DOCUMENT_TREE) :
// la permission persistable survit au reboot, le worker WorkManager écrit
// ensuite sans intervention — AUCUNE permission de stockage déclarée.
// pickFolderToEnable distingue les deux entrées du picker :
// - via le SWITCH quand aucun dossier n'existe encore → la sélection
// ACTIVE la sauvegarde (+ premier run immédiat) : le switch sans
// dossier ne peut rien faire, le picker est la suite logique ;
// - via « Choisir le dossier » (changement) → on enregistre le dossier
// SANS changer l'état activé/désactivé.
val autoBackupEnabled by container.settings.autoBackupEnabled
.collectAsState(initial = false)
val autoBackupTreeUri by container.settings.autoBackupTreeUri
.collectAsState(initial = null as String?)
val autoBackupKeep by container.settings.autoBackupKeep.collectAsState(initial = 7)
val autoBackupLastRun by container.settings.autoBackupLastRun
.collectAsState(initial = null as Pair<String?, Long?>?)
var autoBackupKeepText by remember(autoBackupKeep) {
mutableStateOf(autoBackupKeep.toString())
}
var pickFolderToEnable by remember { mutableStateOf(false) }
val folderPickerLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocumentTree()
) { uri ->
uri ?: return@rememberLauncherForActivityResult
// Persiste la permission : sans takePersistableUriPermission, le tree
// URI serait mort au prochain démarrage (worker sans droit d'écrire).
context.contentResolver.takePersistableUriPermission(
uri,
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or
android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
val toEnable = pickFolderToEnable
pickFolderToEnable = false
CoroutineScope(Dispatchers.IO).launch {
container.settings.setAutoBackupTreeUri(uri.toString())
if (toEnable) {
container.settings.setAutoBackupEnabled(true)
// Premier backup IMMÉDIAT (feedback instantané + validation
// émulateur directe), puis le périodique 24 h prend le relais
com.hormonetrack.data.backup.AutoBackupScheduler.runNow(context)
}
withContext(Dispatchers.Main) {
message = if (toEnable) {
context.getString(R.string.autobackup_first_run)
} else {
context.getString(R.string.saved)
}
}
}
}
Column(
Modifier
.fillMaxSize()
@ -548,6 +601,104 @@ fun SettingsScreen(onBack: () -> Unit) {
}
Spacer(Modifier.height(12.dp))
// --- Sauvegarde automatique (v1.7.0) : en PLUS de l'export manuel ---
// Opt-in pur : désactivée par défaut ; nécessite un dossier choisi
// (SAF tree persisté) ; un run immédiat est déclenché à l'activation.
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.autobackup_section), style = MaterialTheme.typography.titleMedium)
Switch(
checked = autoBackupEnabled,
onCheckedChange = { wantEnabled ->
if (!wantEnabled) {
// Désactivation : le périodique reste planifié
// mais le worker sort immédiatement (no-op)
CoroutineScope(Dispatchers.IO).launch {
container.settings.setAutoBackupEnabled(false)
}
} else if (autoBackupTreeUri == null) {
// Pas de dossier → le picker EST la suite :
// la sélection activera (+ premier run)
pickFolderToEnable = true
folderPickerLauncher.launch(null)
} else {
// Dossier existant → activation directe + run
CoroutineScope(Dispatchers.IO).launch {
container.settings.setAutoBackupEnabled(true)
com.hormonetrack.data.backup.AutoBackupScheduler.runNow(context)
}
}
}
)
}
Text(
stringResource(R.string.autobackup_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(6.dp))
// Dossier choisi : nom lisible (documents "primary:Download" →
// "Download") + bouton pour le changer (sans toucher au switch)
Text(
stringResource(R.string.autobackup_folder) + " : " +
(autoBackupTreeUri?.let { folderDisplayName(it) }
?: stringResource(R.string.autobackup_no_folder)),
style = MaterialTheme.typography.bodyMedium
)
TextButton(onClick = {
pickFolderToEnable = false
folderPickerLauncher.launch(null)
}) { Text(stringResource(R.string.autobackup_choose_folder)) }
// Dernier run (statut alimenté par le worker) : réussi/échec + date
autoBackupLastRun?.let { (result, atMs) ->
if (atMs != null && atMs > 0L) {
val date = java.text.SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.getDefault())
.format(java.util.Date(atMs))
Text(
text = context.getString(
if (result == "ok") R.string.autobackup_last_ok
else R.string.autobackup_last_fail,
date
),
color = if (result == "ok") MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
}
Spacer(Modifier.height(6.dp))
// Rétention : copies conservées (1..30, borné au set) — Save
// persiste ; le worker relit DataStore à CHAQUE run (aucun
// re-enqueue nécessaire, cf AutoBackupScheduler)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = autoBackupKeepText,
onValueChange = { autoBackupKeepText = it },
label = { Text(stringResource(R.string.autobackup_keep)) },
singleLine = true,
modifier = Modifier.weight(1f)
)
Button(onClick = {
val keep = autoBackupKeepText.trim().toIntOrNull() ?: return@Button
CoroutineScope(Dispatchers.IO).launch {
container.settings.setAutoBackupKeep(keep) // borné [1;30]
withContext(Dispatchers.Main) {
message = context.getString(R.string.saved)
}
}
}) { Text(stringResource(R.string.save)) }
}
}
}
Spacer(Modifier.height(12.dp))
// --- Logs de diagnostic (v1.3.1) ---
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
@ -669,3 +820,18 @@ fun SettingsScreen(onBack: () -> Unit) {
private fun formatDoubles(d: Double): String =
if (d == d.toLong().toDouble()) d.toLong().toString() else "%.3f".format(Locale.US, d)
/**
* Nom lisible d'un dossier SAF tree URI (v1.7.0) : l'id d'un tree document
* est de la forme « primary:Download » (stockage principal) ou
* « <provider>:<chemin » pour les providers tiers (Owncloud…) — on affiche
* la partie APRÈS le ':', sinon l'ID brut. Pur et défensif : un URI non
* parsable s'affiche tel quel plutôt que de faire crasher l'écran.
*/
private fun folderDisplayName(treeUriString: String): String =
try {
val id = android.provider.DocumentsContract.getTreeDocumentId(Uri.parse(treeUriString))
id.substringAfter(':', id)
} catch (_: Exception) {
treeUriString
}

View File

@ -26,6 +26,21 @@ object ExportFileNames {
private val LOG_FMT = DateTimeFormatter.ofPattern("yyyyMMdd-HHmm")
private val BACKUP_FMT = DateTimeFormatter.ofPattern("yyyyMMdd")
/**
* Pattern des AUTO-BACKUPS journaliers (v1.7.0) : `yyyyMMdd-HHmm` — même
* règle que [LOG_FMT] (contient HHmm → type temporel complet requis).
* L'heure permet plusieurs runs le même jour (activation + tick journalier,
* ou plusieurs activations successives) SANS écrasement : chaque fichier
* a un nom unique → la rétention ([com.hormonetrack.data.backup.AutoBackupRetention])
* trie sur l'horodatage du NOM (jamais sur la mtime du document SAF, non
* fiable sur tous les providers).
*/
private val AUTO_BACKUP_FMT = DateTimeFormatter.ofPattern("yyyyMMdd-HHmm")
/** Préfixe d'un fichier d'AUTO-backup — partagé avec la rétention (PUR). */
const val AUTO_BACKUP_PREFIX = "hormonetrack-auto-"
private const val AUTO_BACKUP_SUFFIX = ".json"
/**
* Nom du fichier d'export des logs de diagnostic :
* `hormonetrack-logs-20260906-1234.txt`.
@ -43,4 +58,34 @@ object ExportFileNames {
*/
fun backupFileName(today: LocalDate = LocalDate.now()): String =
"hormonetrack-backup-${BACKUP_FMT.format(today)}.json"
/**
* Nom d'un AUTO-backup journalier (v1.7.0) :
* `hormonetrack-auto-20260913-1830.json`. Pattern AVEC heure →
* [LocalDateTime] obligatoire (règle anti-#48 en tête de fichier).
* Pur (déterministe sur [now]) → testé en JVM.
*/
fun autoBackupFileName(now: LocalDateTime = LocalDateTime.now()): String =
"$AUTO_BACKUP_PREFIX${AUTO_BACKUP_FMT.format(now)}$AUTO_BACKUP_SUFFIX"
/**
* Re-trouve l'horodatage dans un NOM de fichier d'auto-backup (round-trip
* de [autoBackupFileName]) — PUR, utilisé par la rétention pour trier par
* l'horodatage du nom. Retourne `null` pour tout ce qui n'est PAS un
* auto-backup (exports manuels `hormonetrack-backup-…`, logs, fichiers
* étrangers du dossier choisi par l'utilisatrice) : la rétention ne
* doit JAMAIS les toucher.
*/
fun parseAutoBackupTimestamp(fileName: String): LocalDateTime? {
if (!fileName.startsWith(AUTO_BACKUP_PREFIX)) return null
if (!fileName.endsWith(AUTO_BACKUP_SUFFIX)) return null
return try {
LocalDateTime.parse(
fileName.removePrefix(AUTO_BACKUP_PREFIX).removeSuffix(AUTO_BACKUP_SUFFIX),
AUTO_BACKUP_FMT
)
} catch (_: Exception) {
null // nom presque-correct mais malformé : JAMAIS supprimé
}
}
}

View File

@ -204,6 +204,17 @@
<string name="import_json">Importer</string>
<string name="export_ok">Sauvegarde exportée.</string>
<string name="export_fail">L\'export a échoué.</string>
<!-- v1.7.0 : sauvegarde automatique journalière (SAF tree + WorkManager) -->
<string name="autobackup_section">Sauvegarde automatique quotidienne</string>
<string name="autobackup_enable">Activer</string>
<string name="autobackup_hint">Chaque jour, l\'app écrit une sauvegarde JSON complète (même format que l\'export manuel) dans le dossier que tu choisis — par exemple un dossier Owncloud synchronisé, pour que le backup atterrisse dans ton cloud. Aucune permission de stockage nécessaire : tu choisis le dossier une fois et tu peux le révoquer à tout moment dans les réglages système. Seules les copies les plus récentes sont conservées (les vieilles copies automatiques sont supprimées ; tes exports manuels ne sont jamais touchés).</string>
<string name="autobackup_folder">Dossier</string>
<string name="autobackup_no_folder">Pas encore de dossier choisi — choisis-en un pour activer.</string>
<string name="autobackup_choose_folder">Choisir le dossier</string>
<string name="autobackup_keep">Copies conservées (1–30)</string>
<string name="autobackup_last_ok">Dernière sauvegarde : OK (%1$s)</string>
<string name="autobackup_last_fail">Dernière sauvegarde : ÉCHEC (%1$s) — vérifie le dossier puis réactive pour réessayer.</string>
<string name="autobackup_first_run">Activée — une première sauvegarde a été écrite dans le dossier.</string>
<string name="import_fail">L\'import a échoué : fichier invalide.</string>
<string name="import_ok">Restauré : %1$d traitements, %2$d doses, %3$d analyses (les données actuelles ont été remplacées, rappels reprogrammés).</string>
<string name="import_confirm">Effacer &amp; restaurer</string>

View File

@ -204,6 +204,17 @@
<string name="import_json">Import</string>
<string name="export_ok">Backup exported.</string>
<string name="export_fail">Export failed.</string>
<!-- v1.7.0 : sauvegarde automatique journalière (SAF tree + WorkManager) -->
<string name="autobackup_section">Automatic daily backup</string>
<string name="autobackup_enable">Enable</string>
<string name="autobackup_hint">Each day, the app writes a full JSON backup (same format as the manual export) into the folder you choose — e.g. a synced Owncloud folder, so it lands in your cloud. No storage permission is needed: you pick the folder once and can revoke it anytime in the system settings. Only the newest copies are kept (older automatic copies are deleted; your manual exports are never touched).</string>
<string name="autobackup_folder">Folder</string>
<string name="autobackup_no_folder">No folder selected yet — pick one to enable.</string>
<string name="autobackup_choose_folder">Choose folder</string>
<string name="autobackup_keep">Copies kept (1–30)</string>
<string name="autobackup_last_ok">Last backup: OK (%1$s)</string>
<string name="autobackup_last_fail">Last backup: FAILED (%1$s) — check the folder, then re-enable to retry.</string>
<string name="autobackup_first_run">Enabled — a first backup was written to the folder.</string>
<string name="import_fail">Import failed: invalid file.</string>
<string name="import_ok">Restored: %1$d treatments, %2$d doses, %3$d labs (current data was replaced, reminders rescheduled).</string>
<string name="import_confirm">Erase &amp; restore</string>

View File

@ -0,0 +1,88 @@
package com.hormonetrack.data.backup
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Tests de la RÉTENTION des auto-backups journaliers (v1.7.0) — décision
* PURE ([AutoBackupRetention.filesToDelete]) : quels noms supprimer quand
* la rétention dépasse « copies conservées ».
*
* Les deux garanties CRITIques (le dossier cible est CELUI DE L'UTILISATRICE,
* ex. un dossier Owncloud) :
* 1. seuls les auto-backups RECONNAISSABLES (`hormonetrack-auto-<date>-<hhmm>.json`)
* participent — les exports MANUELS (`hormonetrack-backup-…`), les logs
* et les fichiers personnels du dossier ne sont JAMAIS candidats ;
* 2. un nom presque-correct mais MALFORMÉ (horodatage non parsable) n'est
* jamais supprimé non plus.
* + garde anti-idiocratie : `keep` est clampé à ≥ 1 (keep=0 ne doit JAMAIS
* supprimer la dernière copie restante).
*/
class AutoBackupRetentionTest {
private fun auto(date: String) = "hormonetrack-auto-$date.json"
@Test
fun `keeps the newest keepCount copies and returns the older ones`() {
val files = listOf(
auto("20260907-1830"),
auto("20260913-1830"), // plus récent
auto("20260909-1830"),
auto("20260911-1830"),
)
val toDelete = AutoBackupRetention.filesToDelete(files, keepCount = 2)
// Le tri se fait par horodatage du NOM (pas l'ordre d'entrée)
assertEquals(
listOf(auto("20260907-1830"), auto("20260909-1830")),
toDelete.sorted()
)
}
@Test
fun `never touches manual exports logs or foreign files`() {
val files = listOf(
"hormonetrack-backup-20260101.json", // export MANUEL
"hormonetrack-logs-20260101-1200.txt", // logs
"owncloud-deja-la.txt", // fichier personnel du dossier choisi
auto("20260901-1830"),
auto("20260902-1830"),
)
val toDelete = AutoBackupRetention.filesToDelete(files, keepCount = 1)
// Seul l'auto-backup le plus ancien part ; les étrangers restent
assertEquals(listOf(auto("20260901-1830")), toDelete)
}
@Test
fun `malformed auto backup names are never deleted`() {
val files = listOf(
"hormonetrack-auto-corrompu.json",
"hormonetrack-auto-20260913-1830", // sans extension
auto("20260910-1830"),
)
val toDelete = AutoBackupRetention.filesToDelete(files, keepCount = 1)
assertTrue(
"les noms non parsables ne sont jamais candidats à la suppression",
toDelete.isEmpty()
)
}
@Test
fun `keep is clamped to at least 1 (keep 0 never wipes everything)`() {
val files = listOf(auto("20260910-1830"), auto("20260911-1830"))
val toDelete = AutoBackupRetention.filesToDelete(files, keepCount = 0)
assertTrue(
"keep=0 → clampé à 1 : au moins la copie la plus récente survit",
toDelete == listOf(auto("20260910-1830"))
)
// keep négatif : même garde
assertTrue(AutoBackupRetention.filesToDelete(files, keepCount = -5).size == 1)
}
@Test
fun `retention already satisfied returns nothing`() {
val files = listOf(auto("20260910-1830"), auto("20260911-1830"))
assertTrue(AutoBackupRetention.filesToDelete(files, keepCount = 5).isEmpty())
assertTrue(AutoBackupRetention.filesToDelete(emptyList(), keepCount = 7).isEmpty())
}
}

View File

@ -44,4 +44,49 @@ class ExportFileNamesTest {
.matches(Regex("hormonetrack-backup-\\d{8}\\.json"))
)
}
// ── Auto-backup journalier (v1.7.0) ──────────────────────────────────────
@Test
fun `auto backup filename carries date AND time (LocalDateTime required)`() {
// Règle anti-#48 : le pattern yyyyMMdd-HHmm contient un champ horaire →
// la signature DOIT prendre un LocalDateTime (LocalDate = crash au tap).
val name = ExportFileNames.autoBackupFileName(
LocalDateTime.of(2026, 9, 13, 18, 30)
)
assertEquals("hormonetrack-auto-20260913-1830.json", name)
}
@Test
fun `auto backup default filename never throws and matches the expected shape`() {
assertTrue(
ExportFileNames.autoBackupFileName()
.matches(Regex("hormonetrack-auto-\\d{8}-\\d{4}\\.json"))
)
}
@Test
fun `parse auto backup timestamp round-trips with the generator`() {
val name = ExportFileNames.autoBackupFileName(LocalDateTime.of(2026, 9, 13, 8, 5))
val parsed = ExportFileNames.parseAutoBackupTimestamp(name)
assertEquals(LocalDateTime.of(2026, 9, 13, 8, 5), parsed)
}
@Test
fun `parse returns null for everything that is NOT an auto backup`() {
// Exports MANUELS (jamais supprimés par la rétention), logs, étrangers
assertEquals(
null,
ExportFileNames.parseAutoBackupTimestamp("hormonetrack-backup-20260913.json")
)
assertEquals(
null,
ExportFileNames.parseAutoBackupTimestamp("hormonetrack-logs-20260913-1800.txt")
)
assertEquals(null, ExportFileNames.parseAutoBackupTimestamp("mes-notes.json"))
// Presque-corrects mais MALFORMÉS : jamais supprimés non plus
assertEquals(null, ExportFileNames.parseAutoBackupTimestamp("hormonetrack-auto-pas-unedate.json"))
assertEquals(null, ExportFileNames.parseAutoBackupTimestamp("hormonetrack-auto-20260913-1830"))
assertEquals(null, ExportFileNames.parseAutoBackupTimestamp("hormonetrack-auto-.json"))
}
}

View File

@ -3,6 +3,45 @@
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.7.0] — 2026-09-13 (versionCode 33)
### Sauvegarde automatique quotidienne (opt-in)
- **Nouvelle option Paramètres → « Sauvegarde automatique quotidienne »**
(désactivée par défaut, en PLUS de l'export manuel qui reste inchangé) :
chaque jour, l'app écrit une sauvegarde JSON complète (MÊME format que
l'export manuel — importable tel quel) dans le **dossier que tu choisis**
(ex. un dossier Owncloud synchronisé → le backup atterrit dans ton cloud).
- **Aucune permission de stockage** : le dossier est choisi UNE FOIS via le
sélecteur système (SAF) — la permission persiste (survit au reboot), tu
peux la révoquer à tout moment dans les réglages système. 100 % local :
l'écriture va uniquement dans CE dossier.
- **Rétention configurable** (1–30 copies, défaut 7) : chaque run crée un
NOUVEAU fichier horodaté `hormonetrack-auto-20260913-1830.json` (jamais
d'écrasement — les N dernières copies saines restent disponibles) et les
plus vieilles sont supprimées. **Tes exports manuels, tes logs et tout
fichier étranger du dossier ne sont JAMAIS touchés** (rétention pure,
épinglée par tests).
- **Statut visible** : « Dernière sauvegarde : OK/ÉCHEC (date) » dans
Paramètres + journalisation AppLog complète (diagnostic à distance).
À l'activation, un **premier backup est écrit immédiatement** (pas
d'attente de 24 h) ; ensuite WorkManager (24 h, survit au reboot, gère
le Doze).
- 9 nouveaux tests (200 au total / 177 sans les données locales) : noms
horodatés (règle anti-#48 : signature LocalDateTime obligatoire), garde
de rétention (jamais de suppression hors auto-backups reconnaissables,
keep clampé ≥ 1 — `keep=0` ne supprime jamais la dernière copie).
- Validé sur émulateur (§16.ter, APK release + recette standard) : dossier
choisi via le sélecteur SAF, fichier `hormonetrack-auto-…json` écrit,
rétention appliquée, 0 crash.
### Unités des axes du graphique
- **pg/mL** (E2, axe gauche) et **ng/mL** (T, axe droit) affichées au sommet
des colonnes de labels du graphique de simulation — remontée : « on n'a
jamais ajouté les unités des mesures dans les axes » (des nombres nus
depuis la v1.0). Portées aussi côté web (v1.7.0 web, versions sync).
## [1.6.0] — 2026-09-12 (versionCode 32)
### « Tracé labs » prolongé au-delà du dernier lab

View File

@ -63,6 +63,7 @@ Fonctionnalités v1 :
| Date | Événement |
|---|---|
| 13 sept. 2026 (session v1.7.0) | **Sauvegarde automatique quotidienne (opt-in) + unités des axes** : (a) Paramètres → « Sauvegarde automatique quotidienne » — dossier choisi UNE FOIS via SAF tree (ACTION_OPEN_DOCUMENT_TREE + takePersistableUriPermission, AUCUNE permission de stockage ; typiquement un dossier Owncloud synchronisé), WorkManager périodique 24 h (KEEP au démarrage, worker no-op si désactivé — pattern AlertWorker), **run immédiat à l'activation** (feedback + validation émulateur triviale), rétention configurable 1–30 copies (défaut 7) : fichier horodaté par run (`hormonetrack-auto-YYYYMMDD-HHmm.json`, jamais d'écrasement) + purge par la rétention PURE ([AutoBackupRetention] — ne touche JAMAIS exports manuels/logs/étrangers, keep clampé ≥ 1) ; statut « dernier run » persisté + AppLog complet ; contenu = backup v2 COMPLET (importable tel quel). (b) **Unités des axes du graphique** (remontée « jamais ajoutées depuis v1.0 ») : pg/mL (E2, gauche) / ng/mL (T, droite) au sommet des colonnes de labels (padTop 12→26 dp), portées aussi côté web (versions sync). 9 nouveaux tests (200 verts / 177 sans données locales) + lint vert ; proguard : `-keep` AutoBackupWorker (réflexion WorkManager — leçon #64). Validé émulateur §16.ter APK release (dossier Download via le picker SAF piloté uiautomator, fichier écrit, rétention, unités visibles, 0 crash). |
| 12 sept. 2026 (session v1.6.0) | **« Tracé labs » PROLONGÉ au-delà du dernier lab** (demande) : le tracé s'arrêtait AU dernier lab (v1.5.0) — justement la période la plus récente était invisible. FIX : chip `Prolonger` (5ᵉ de la rangée, off par défaut, désactivé tant que Tracé labs est off) → au-delà du dernier lab significatif, `courbe(t) = M(t) × ρ_last` (ρ CONSTANT — pas d'extrapolation de pente, elle divergerait sans base physiologique) ; M(t) inclut AUTOMATIQUEMENT les doses loguées après le dernier lab (une injection EV après des labs EEn refait monter la courbe — épinglé par test) ; horizon = dernière dose E2 + cutoffHours de son traitement (une seule source de vérité : `cutoffHours` rendu public) ; série SPLITTÉE en "LAB" (ancré) / "LABX" (prolongé, rose atténué α 0,55 + légende dédiée — l'estimation ne se confond pas avec le mesuré) ; branche "LABX" EXPLICITE dans le when de légende AVANT le else (leçon #63) ; **AVERTISSEMENT visible sous la légende quand la prolongation est affichée** (demande) : « simple simulation, sans garantie de correspondre au réel, basée sur tes labs qui peuvent eux-mêmes être erronés » (string FR/EN `lab_track_extend_warning`). < 2 ancres, modèle déjà éteint au dernier lab, ou demande finissant avant → pas de prolongation (retour v1.5.0 exact, épinglé bit-compatible). 8 nouveaux tests (191 verts / 168 sans données locales) + lint vert. **+ Rattrapage validation émulateur §16.ter (oubliée, demandée par l'utilisatrice) → bug #64 TROUVÉ** : « Tracé labs » vide EN RELEASE SEULE (R8 full mode avait REMOVÉ la classe — mapping `R8$$REMOVED$$CLASS` — et inliné le calcul dans le producer ; debug OK, 191 tests JVM aveugles). FIX : `-keep class ...LabTrajectoryModel { *; }` ; validé émulateur APK release re-buildé avec les données réelles seedées : ancrée (7090 px rose + légende), prolongée (légende + avertissement), 0 crash. Tag v1.6.0 reposé sur le commit de fix AVANT publication d'APK (checklist §16 étape 4). **Checklist §16 : étape 3.bis « validation émulateur release OBLIGATOIRE avant tag » ajoutée.** |
| 11 sept. 2026 (session v1.5.0) | **« Tracé labs »** (demande débattue) : courbe hybride `M(t)×ρ(t)` ancrée sur les labs — ρ log-linéaire entre labs, garde #61, E2 seul, fenêtre labs, chip off par défaut + scroll horizontal CONFINÉ (chip 4ᵉ coupé hors fenêtre, constat émulateur) + leçon #63 légende dupliquée ("LAB" tombant dans else TFS) ; ChartSeries.showExtrema=false pour la série ; 11 tests LabTrajectoryModelTest (183 verts) + lint ; validé émulateur (courbe passe exactement sur le lab 248, 0 crash). |
| 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). |
@ -595,7 +596,7 @@ constant à droite comme l'interpolation aboutit à ρ_last à gauche).
## 8. Tests unitaires
**191 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **168 sans
**200 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **177 sans
les données de test locales** (cf §8.bis : les 4 classes de régression,
6/6/6/5 tests, 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 =
@ -670,8 +671,8 @@ dépôt (ni maintenant, ni si le repo devient public un jour).
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 168 tests au lieu de 191 (v1.6.0 ; chiffres historiques : 44/87 à v1.3.x,
150/172 à v1.4.x, 160/183 à v1.5.0) ;
exécute 177 tests au lieu de 200 (v1.7.0 ; chiffres historiques : 44/87 à v1.3.x,
150/172 à v1.4.x, 160/183 à v1.5.0, 168/191 à v1.6.0) ;
- 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
@ -1064,6 +1065,52 @@ Pièges :
- Transport : SAF (`CreateDocument("application/json")` / `OpenDocument`), écriture
`openOutputStream(uri, "wt")` ; ⚠️ pas de `return` dans un expression body `= try{}`
### 13.bis Sauvegarde automatique quotidienne (v1.7.0) — `data/backup/AutoBackupWorker.kt`
Opt-in (désactivée par défaut), en PLUS de l'export manuel (inchangé).
Vue d'ensemble : **dossier choisi UNE FOIS → WorkManager écrit un backup
JSON complet chaque jour, garde les N copies les plus récentes.**
- **Réglages (DataStore, [AppSettings])** : `autoBackupEnabled` (bool),
`autoBackupTreeUri` (tree URI SAF persisté), `autoBackupKeep` (1..30,
défaut 7, borné au SET), `autoBackupLastRun` (résultat + horodatage,
affiché dans Paramètres).
- **Dossier SAF (sans permission de stockage)** :
`ACTION_OPEN_DOCUMENT_TREE` → `takePersistableUriPermission(READ|WRITE)`
→ la permission SURVIT au reboot ; le worker écrit ensuite via
[BackupManager.writeAutoBackup] : tree URI → document URI du dossier
(DocumentsContract, SANS la lib androidx.documentfile) →
`createDocument` d'un NOUVEAU fichier horodaté
(`hormonetrack-auto-YYYYMMDD-HHmm.json` — ExportFileNames, signature
LocalDateTime obligatoire, règle anti-#48) → écriture via `writeBackup`
TEL QUEL (leçon #45 : l'IO éprouvée de l'export manuel) → purge.
- **Rétention ([AutoBackupRetention], PUR et testé)** : décision
« quels noms supprimer » séparée de l'IO ; seuls les auto-backups
RECONNAISSABLES participent (parse de l'horodatage du NOM — la mtime
SAF n'est pas fiable sur tous les providers) ; exports manuels, logs et
fichiers étrangers du dossier JAMAIS touchés ; noms malformés jamais
supprimés ; `keep` clampé ≥ 1. Pourquoi un fichier PAR RUN au lieu
d'écraser un fichier fixe : une écriture interrompue (quota cloud,
réseau) ne doit jamais détruire la dernière bonne copie.
- **Planification ([AutoBackupScheduler])** : `schedulePeriodic` au
démarrage (HormoneTrackApp.onCreate, `ExistingPeriodicWorkPolicy.KEEP`
— ne repousse pas le prochain run) ; périodique 24 h (survivre au
reboot, Doze géré par WorkManager — pas d'alarme exacte nécessaire) ;
`runNow` à l'ACTIVATION (feedback immédiat + validation émulateur
triviale). Le worker lit ses options dans DataStore à CHAQUE exécution :
changer dossier/copies ne requiert aucun re-enqueue ; option désactivée
→ no-op immédiat (pattern AlertWorker sans seuils).
- **Contenu** : [BackupManager.exportJson] complet (format v2, paramètres
inclus) → un auto-backup est IMPORTABLE tel quel (même dialog que
l'export manuel).
- **R8 (leçon #64)** : `-keep class ...AutoBackupWorker { *; }` — le
worker est instancié par WorkManager via réflexion.
- **Écran Paramètres** : switch (ON sans dossier → ouvre le picker, la
sélection active + premier run), nom du dossier lisible
(`folderDisplayName` : « primary:Download » → « Download »), copies
conservées (Save borné), statut dernier run (OK/ÉCHEC + date).
- 9 nouveaux tests (200 verts).
## 14. Bugs corrigés
Historique complet — **à ne pas réintroduire** (utile pour diff/revert) :
@ -1984,6 +2031,19 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
courbe en pic ; désactiver `Prolonger` → retour à la fenêtre
[1er ; dernier lab] exacte ; `Tracé labs` OFF → le chip `Prolonger`
est désactivé ; l'avertissement disparaît avec la série
- [ ] **v1.7.0** : **auto-backup journalier** : Paramètres → « Sauvegarde
automatique quotidienne » → activer → le sélecteur de dossier
s'ouvre → choisir (ex. Download) → un PREMIER `hormonetrack-auto-…json`
apparaît immédiatement dans le dossier (vérifié : `adb shell ls
/sdcard/Download/` sur l'émulateur) ; le statut « Dernière
sauvegarde : OK (date) » s'affiche ; réduction des copies
(garder 2, injecter des vieux fichiers, re-Save → les plus vieux
auto-backups partent, JAMAIS les exports manuels) ; désactivation →
plus de run ; l'export MANUEL reste inchangé
- [ ] **v1.7.0** : **unités des axes** : « pg/mL » au sommet de l'axe
gauche (E2) et « ng/mL » au sommet de l'axe droit (T — disparaît
avec le toggle T), au-dessus des labels numériques ; portées web
(versions sync)
- [ ] **v1.4.10** : **pan sur la vue 24 h** : glisser horizontalement à
24 h doit déplacer la courbe (fix #62 : les deltas < 1 h s'accumulent
— avant, le pan ne bougeait jamais sur 24 h) ; tester aussi à 7 j et
@ -2004,7 +2064,7 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
tap ; et TOUTES les courbes affichées reflètent la calibration (test)
---
*Doc mise à jour le 12 sept. 2026 (v1.6.0) — build OK, lint vert, 191/191 tests verts (168 sans
*Doc mise à jour le 13 sept. 2026 (v1.7.0) — build OK, lint vert, 200/200 tests verts (177 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

View File

@ -138,9 +138,15 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
bas requis. C'est une estimation du modèle, pas une mesure
- **Estimation T** : modèle `T = plancher + (base − plancher) ÷ (1 + k·E2)` (ng/mL),
calibrable avec tes résultats T
- **Sauvegarde JSON** : Export / Import (traitements + doses + analyses +
réglages T **+ paramètres : langue, auto-calibration, seuils d'alerte** —
v1.4.2)
- **Sauvegarde JSON** : Export / Import (traitements + doses + analyses +
réglages T **+ paramètres : langue, auto-calibration, seuils d'alerte** —
v1.4.2)
- **Sauvegarde automatique quotidienne** (v1.7.0, opt-in) : active-la,
choisis le dossier une seule fois (ex. un dossier Owncloud synchronisé)
→ chaque jour un backup JSON complet y est écrit, avec les copies les
plus récentes conservées (réglable, défaut 7) — tes exports manuels et
tes autres fichiers ne sont jamais touchés. Un premier backup est écrit
dès l'activation ; le statut (OK/ÉCHEC + date) s'affiche dans la carte
## 7. Les modèles mathématiques
@ -201,6 +207,9 @@ Pics de référence (dose unique de 5 mg, IM) :
- **Fuseau horaire du graphique** (v1.4.5) : Paramètres → « Fuseau horaire du
graphique » — les jours s'alignent sur minuit du fuseau choisi (vide = celui
du téléphone)
- **Unités sur les axes** (v1.7.0) : l'axe gauche affiche « pg/mL » (E2) et
l'axe droit « ng/mL » (T, avec le toggle T) au sommet des colonnes de
labels — plus besoin de deviner l'unité des nombres
- **Pics / creux** (chip sur le graphique) : triangles ▲▼ aux extrema estimés de
chaque courbe (E2 et T, les deux modèles) — pratique pour visualiser d'un coup
d'œil les hauts et les bas entre deux injections
@ -243,4 +252,7 @@ Pics de référence (dose unique de 5 mg, IM) :
- **Tout est local** : base Room sur le téléphone, aucun serveur, aucun compte
- Sauvegarde = fichier JSON que tu choisis où stocker (Owncloud, etc.)
- **L'auto-backup quotidien (v1.7.0) n'écrit QUE dans le dossier que tu as
choisi** (sélecteur système, permission révocable dans les réglages) —
rien ne part ailleurs
- La désinstallation supprime les données → pense à exporter régulièrement