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 2c58418604
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 > à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
> sang et aux consignes de ton endocrinologue. > 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) - **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.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) - **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 canal dédié) — évaluées sur le taux **estimé**, opt-in
- **Sauvegarde JSON complète** : traitements + doses + analyses + réglages T - **Sauvegarde JSON complète** : traitements + doses + analyses + réglages T
**+ paramètres (langue, auto-calibration, seuils d'alerte)** (v1.4.2) **+ 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 - **Nouveautés à chaque mise à jour** : dialog de changelog automatique
(fermé = ne réapparaît pas avant la prochaine version) (fermé = ne réapparaît pas avant la prochaine version)
- **Temps sous THS** affiché en haut de la page Doses (depuis la 1re prise) ; - **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 git clone <repo> && cd HormoneTrack
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk ./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 ./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é 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)) 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.) - 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 - `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é ; - 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 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" // tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans // (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
// les APK publiés (Paramètres + titre du dialog « Nouveautés »). // les APK publiés (Paramètres + titre du dialog « Nouveautés »).
versionCode = 32 versionCode = 33
versionName = "1.6.0" versionName = "1.7.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

View File

@ -30,3 +30,9 @@
# vérifier le mapping (`app/build/outputs/mapping/release/mapping.txt`) # vérifier le mapping (`app/build/outputs/mapping/release/mapping.txt`)
# et étendre cette garde si besoin. # et étendre cette garde si besoin.
-keep class com.hormonetrack.pk.LabTrajectoryModel { *; } -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. // démarrage ne change rien ; WorkManager survit aux reboots.
AlertNotifier.createChannel(this) AlertNotifier.createChannel(this)
AlertNotifier.schedulePeriodic(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 package com.hormonetrack.data.backup
import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.provider.DocumentsContract
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import com.hormonetrack.data.model.DoseLog 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.model.Treatment
import com.hormonetrack.data.repository.HormoneRepository import com.hormonetrack.data.repository.HormoneRepository
import com.hormonetrack.pk.TConfig 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 * Paramètres utilisateur embarqués dans le backup (v1.4.2) — la sauvegarde
@ -133,4 +137,98 @@ object BackupManager {
} catch (e: Exception) { } catch (e: Exception) {
null 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.booleanPreferencesKey
import androidx.datastore.preferences.core.doublePreferencesKey import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit 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.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.hormonetrack.pk.Alerts 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). * (ex. "Europe/Paris") ; null/absent = fuseau du téléphone (auto).
*/ */
val CHART_TIMEZONE = stringPreferencesKey("chart_timezone") 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 -> 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?) { suspend fun setAlertNotifiedState(encoded: String?) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
if (encoded.isNullOrEmpty()) prefs.remove(Keys.ALERT_NOTIFIED_STATE) if (encoded.isNullOrEmpty()) prefs.remove(Keys.ALERT_NOTIFIED_STATE)

View File

@ -309,7 +309,10 @@ fun CurveChart(
if (allPoints.size < 2) return@Canvas if (allPoints.size < 2) return@Canvas
val padLeft = 42.dp.toPx() val padLeft = 42.dp.toPx()
val padRight = 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 padBottom = 26.dp.toPx()
val w = size.width - padLeft - padRight val w = size.width - padLeft - padRight
@ -342,8 +345,18 @@ fun CurveChart(
drawGrid(gridColor, padLeft, padTop, w, h, rows = 4) drawGrid(gridColor, padLeft, padTop, w, h, rows = 4)
drawLeftYLabels(labelColor, e2Max, padLeft, padTop, h) 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 }) { if (options.showT && series.any { it.tStyle != null }) {
drawRightYLabels(labelColor, tMax, padLeft + w, padTop, h) 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) 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) { 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 val y = padTop + h * i / rows
drawLine(color, Offset(padLeft, y), Offset(padLeft + w, y), strokeWidth = 1f) 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( Column(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@ -548,6 +601,104 @@ fun SettingsScreen(onBack: () -> Unit) {
} }
Spacer(Modifier.height(12.dp)) 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) --- // --- Logs de diagnostic (v1.3.1) ---
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) { Column(Modifier.padding(12.dp)) {
@ -669,3 +820,18 @@ fun SettingsScreen(onBack: () -> Unit) {
private fun formatDoubles(d: Double): String = private fun formatDoubles(d: Double): String =
if (d == d.toLong().toDouble()) d.toLong().toString() else "%.3f".format(Locale.US, d) 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 LOG_FMT = DateTimeFormatter.ofPattern("yyyyMMdd-HHmm")
private val BACKUP_FMT = DateTimeFormatter.ofPattern("yyyyMMdd") 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 : * Nom du fichier d'export des logs de diagnostic :
* `hormonetrack-logs-20260906-1234.txt`. * `hormonetrack-logs-20260906-1234.txt`.
@ -43,4 +58,34 @@ object ExportFileNames {
*/ */
fun backupFileName(today: LocalDate = LocalDate.now()): String = fun backupFileName(today: LocalDate = LocalDate.now()): String =
"hormonetrack-backup-${BACKUP_FMT.format(today)}.json" "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="import_json">Importer</string>
<string name="export_ok">Sauvegarde exportée.</string> <string name="export_ok">Sauvegarde exportée.</string>
<string name="export_fail">L\'export a échoué.</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_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_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> <string name="import_confirm">Effacer &amp; restaurer</string>

View File

@ -204,6 +204,17 @@
<string name="import_json">Import</string> <string name="import_json">Import</string>
<string name="export_ok">Backup exported.</string> <string name="export_ok">Backup exported.</string>
<string name="export_fail">Export failed.</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_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_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> <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")) .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/). 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`, …). 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) ## [1.6.0] — 2026-09-12 (versionCode 32)
### « Tracé labs » prolongé au-delà du dernier lab ### « Tracé labs » prolongé au-delà du dernier lab

View File

@ -63,6 +63,7 @@ Fonctionnalités v1 :
| Date | Événement | | 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.** | | 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). | | 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). | | 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 ## 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, 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. 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 = 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). dans le code (le scanner refuserait, et ce serait une fuite).
- les tests font `Assume.assumeTrue(file.exists())` dans le `@Before` : **sans le - 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 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, 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) ; 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 - le workdir des tests Gradle est le dossier du module (`app/`) → les tests
cherchent les fichiers à plusieurs chemins (`../local-test-data/…` en premier) ; cherchent les fichiers à plusieurs chemins (`../local-test-data/…` en premier) ;
- **pour les lancer** : exporter un backup JSON depuis l'app → l'enregistrer sous - **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 - Transport : SAF (`CreateDocument("application/json")` / `OpenDocument`), écriture
`openOutputStream(uri, "wt")` ; ⚠️ pas de `return` dans un expression body `= try{}` `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 ## 14. Bugs corrigés
Historique complet — **à ne pas réintroduire** (utile pour diff/revert) : 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 courbe en pic ; désactiver `Prolonger` → retour à la fenêtre
[1er ; dernier lab] exacte ; `Tracé labs` OFF → le chip `Prolonger` [1er ; dernier lab] exacte ; `Tracé labs` OFF → le chip `Prolonger`
est désactivé ; l'avertissement disparaît avec la série 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 à - [ ] **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 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 — 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) 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 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 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 maintenance (permissions agenda, export logs IO, bump de version) ; v1.3.4 = 2 crashs

View File

@ -141,6 +141,12 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
- **Sauvegarde JSON** : Export / Import (traitements + doses + analyses + - **Sauvegarde JSON** : Export / Import (traitements + doses + analyses +
réglages T **+ paramètres : langue, auto-calibration, seuils d'alerte** — réglages T **+ paramètres : langue, auto-calibration, seuils d'alerte** —
v1.4.2) 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 ## 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 - **Fuseau horaire du graphique** (v1.4.5) : Paramètres → « Fuseau horaire du
graphique » — les jours s'alignent sur minuit du fuseau choisi (vide = celui graphique » — les jours s'alignent sur minuit du fuseau choisi (vide = celui
du téléphone) 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 - **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 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 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 - **Tout est local** : base Room sur le téléphone, aucun serveur, aucun compte
- Sauvegarde = fichier JSON que tu choisis où stocker (Owncloud, etc.) - 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 - La désinstallation supprime les données → pense à exporter régulièrement