diff --git a/README.md b/README.md index 8c2f155..b647c61 100644 --- a/README.md +++ b/README.md @@ -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 && 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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4197b1a..f6dd031 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 8b3fdd2..d659c0e 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -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 { *; } diff --git a/app/src/main/java/com/hormonetrack/HormoneTrackApp.kt b/app/src/main/java/com/hormonetrack/HormoneTrackApp.kt index 1067c92..0e21451 100644 --- a/app/src/main/java/com/hormonetrack/HormoneTrackApp.kt +++ b/app/src/main/java/com/hormonetrack/HormoneTrackApp.kt @@ -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) } } diff --git a/app/src/main/java/com/hormonetrack/data/backup/AutoBackupRetention.kt b/app/src/main/java/com/hormonetrack/data/backup/AutoBackupRetention.kt new file mode 100644 index 0000000..2dfa812 --- /dev/null +++ b/app/src/main/java/com/hormonetrack/data/backup/AutoBackupRetention.kt @@ -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, keepCount: Int): List { + 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 } + } +} diff --git a/app/src/main/java/com/hormonetrack/data/backup/AutoBackupWorker.kt b/app/src/main/java/com/hormonetrack/data/backup/AutoBackupWorker.kt new file mode 100644 index 0000000..48bed7e --- /dev/null +++ b/app/src/main/java/com/hormonetrack/data/backup/AutoBackupWorker.kt @@ -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(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().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() + } +} diff --git a/app/src/main/java/com/hormonetrack/data/backup/BackupManager.kt b/app/src/main/java/com/hormonetrack/data/backup/BackupManager.kt index 2815ee2..00937b5 100644 --- a/app/src/main/java/com/hormonetrack/data/backup/BackupManager.kt +++ b/app/src/main/java/com/hormonetrack/data/backup/BackupManager.kt @@ -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() + 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é") + } + } + } } diff --git a/app/src/main/java/com/hormonetrack/settings/AppSettings.kt b/app/src/main/java/com/hormonetrack/settings/AppSettings.kt index a086cd2..a33196c 100644 --- a/app/src/main/java/com/hormonetrack/settings/AppSettings.kt +++ b/app/src/main/java/com/hormonetrack/settings/AppSettings.kt @@ -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 = 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 = 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 = 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 = 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> = 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) diff --git a/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt b/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt index 16da4ab..89ae032 100644 --- a/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt +++ b/app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt @@ -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) diff --git a/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt index 6ad680d..6a8d385 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt @@ -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?) + 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 + * « :Importer Sauvegarde exportée. L\'export a échoué. + + Sauvegarde automatique quotidienne + Activer + 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). + Dossier + Pas encore de dossier choisi — choisis-en un pour activer. + Choisir le dossier + Copies conservées (1–30) + Dernière sauvegarde : OK (%1$s) + Dernière sauvegarde : ÉCHEC (%1$s) — vérifie le dossier puis réactive pour réessayer. + Activée — une première sauvegarde a été écrite dans le dossier. L\'import a échoué : fichier invalide. Restauré : %1$d traitements, %2$d doses, %3$d analyses (les données actuelles ont été remplacées, rappels reprogrammés). Effacer & restaurer diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 21d5398..a315fdd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -204,6 +204,17 @@ Import Backup exported. Export failed. + + Automatic daily backup + Enable + 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). + Folder + No folder selected yet — pick one to enable. + Choose folder + Copies kept (1–30) + Last backup: OK (%1$s) + Last backup: FAILED (%1$s) — check the folder, then re-enable to retry. + Enabled — a first backup was written to the folder. Import failed: invalid file. Restored: %1$d treatments, %2$d doses, %3$d labs (current data was replaced, reminders rescheduled). Erase & restore diff --git a/app/src/test/java/com/hormonetrack/data/backup/AutoBackupRetentionTest.kt b/app/src/test/java/com/hormonetrack/data/backup/AutoBackupRetentionTest.kt new file mode 100644 index 0000000..1de51f4 --- /dev/null +++ b/app/src/test/java/com/hormonetrack/data/backup/AutoBackupRetentionTest.kt @@ -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--.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()) + } +} diff --git a/app/src/test/java/com/hormonetrack/util/ExportFileNamesTest.kt b/app/src/test/java/com/hormonetrack/util/ExportFileNamesTest.kt index 856f7a5..dcb9b90 100644 --- a/app/src/test/java/com/hormonetrack/util/ExportFileNamesTest.kt +++ b/app/src/test/java/com/hormonetrack/util/ExportFileNamesTest.kt @@ -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")) + } } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 713e917..cabcac8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index c56d224..4370c5f 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -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 diff --git a/docs/GUIDE_INSTALLATION.md b/docs/GUIDE_INSTALLATION.md index cd510d4..c8ac9bc 100644 --- a/docs/GUIDE_INSTALLATION.md +++ b/docs/GUIDE_INSTALLATION.md @@ -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