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:
parent
76a9c0eb63
commit
2c58418604
15
README.md
15
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 <repo> && cd HormoneTrack
|
||||
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
|
||||
./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk
|
||||
./gradlew testDebugUnitTest # 191 tests (168 sans les données locales)
|
||||
./gradlew testDebugUnitTest # 200 tests (177 sans les données locales)
|
||||
./gradlew lint # lint vert obligatoire avant release
|
||||
```
|
||||
|
||||
@ -184,6 +192,9 @@ forme. L'auto-calibration est optionnelle (désactivée par défaut).
|
||||
depuis `local-test-data/` (**gitignoré**, hors dépôt — et l'historique a été
|
||||
nettoyé avant le premier push, cf [docs/DEVELOPPEMENT.md §8.bis](docs/DEVELOPPEMENT.md))
|
||||
- Sauvegarde = fichier JSON que tu stockes où tu veux (Owncloud, etc.)
|
||||
- **L'auto-backup quotidien (v1.7.0) n'écrit QUE dans le dossier que tu as
|
||||
toi-même choisi** (sélecteur système SAF, permission révocable) — rien
|
||||
ne part ailleurs, aucun serveur
|
||||
- `allowBackup=false` (données sensibles) ; verrou biométrique prévu en Phase 2
|
||||
- Le dépôt est **privé** : les releases APK se téléchargent en étant connecté ;
|
||||
passer le dépôt en public rend les APK téléchargeables sans compte (sans risque
|
||||
|
||||
@ -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 {
|
||||
|
||||
6
app/proguard-rules.pro
vendored
6
app/proguard-rules.pro
vendored
@ -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 { *; }
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,9 @@
|
||||
package com.hormonetrack.data.backup
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.hormonetrack.data.model.DoseLog
|
||||
@ -9,6 +11,8 @@ import com.hormonetrack.data.model.LabResult
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.repository.HormoneRepository
|
||||
import com.hormonetrack.pk.TConfig
|
||||
import com.hormonetrack.util.AppLog
|
||||
import com.hormonetrack.util.ExportFileNames
|
||||
|
||||
/**
|
||||
* Paramètres utilisateur embarqués dans le backup (v1.4.2) — la sauvegarde
|
||||
@ -133,4 +137,98 @@ object BackupManager {
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
// ── Auto-backup journalier (v1.7.0) ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Écrit UN auto-backup dans le DOSSIER SAF persisté ([treeUriString] = tree
|
||||
* URI choisi par l'utilisatrice via ACTION_OPEN_DOCUMENT_TREE, ex. un
|
||||
* dossier Owncloud synchronisé), puis applique la rétention
|
||||
* ([AutoBackupRetention] : garde les N plus récents, ne supprime QUE les
|
||||
* fichiers `hormonetrack-auto-*.json` — jamais les exports manuels ni les
|
||||
* fichiers personnels du dossier).
|
||||
*
|
||||
* Étapes :
|
||||
* 1. tree URI → document URI du dossier (DocumentsContract, SANS la lib
|
||||
* androidx.documentfile : pas de dépendance ajoutée) ;
|
||||
* 2. `createDocument` d'un nouveau fichier horodaté
|
||||
* ([ExportFileNames.autoBackupFileName]) — jamais d'écrasement :
|
||||
* la rétention garde les N dernières copies saines ;
|
||||
* 3. écriture du JSON via [writeBackup] TEL QUEL (leçon §14 #45 : même
|
||||
* IO éprouvée que l'export manuel — openOutputStream "wt") ;
|
||||
* 4. purge des vieux auto-backups (échec NON bloquant : un refus de
|
||||
* suppression ne doit pas faire échouer le backup écrit).
|
||||
*
|
||||
* @return true si le fichier a été écrit (la purge peut avoir échoué sans
|
||||
* invalider le succès).
|
||||
*/
|
||||
suspend fun writeAutoBackup(
|
||||
context: Context,
|
||||
treeUriString: String,
|
||||
json: String,
|
||||
keepCount: Int
|
||||
): Boolean {
|
||||
return try {
|
||||
val resolver = context.contentResolver
|
||||
val treeUri = Uri.parse(treeUriString)
|
||||
val dirUri = DocumentsContract.buildDocumentUriUsingTree(
|
||||
treeUri, DocumentsContract.getTreeDocumentId(treeUri)
|
||||
)
|
||||
val fileUri = DocumentsContract.createDocument(
|
||||
resolver, dirUri, "application/json", ExportFileNames.autoBackupFileName()
|
||||
) ?: return false
|
||||
val written = writeBackup(context, fileUri, json)
|
||||
if (written) {
|
||||
// Purge best-effort : le backup est écrit, l'échec de purge ne
|
||||
// l'invalide pas (l'utilisatrice le verra dans les AppLog)
|
||||
try {
|
||||
purgeOldAutoBackups(resolver, treeUri, keepCount)
|
||||
} catch (e: Exception) {
|
||||
AppLog.log("autobackup", "purge impossible : ${e.message}")
|
||||
}
|
||||
}
|
||||
written
|
||||
} catch (e: Exception) {
|
||||
AppLog.log("autobackup", "ERREUR écriture : ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Énumère le dossier cible (SAF children) et supprime les auto-backups au-delà
|
||||
* de [keepCount] — décision dans [AutoBackupRetention] (pur, testé) : ne
|
||||
* touche JAMAIS aux exports manuels ni aux fichiers étrangers du dossier.
|
||||
*/
|
||||
private fun purgeOldAutoBackups(
|
||||
resolver: ContentResolver,
|
||||
treeUri: Uri,
|
||||
keepCount: Int
|
||||
) {
|
||||
val dirId = DocumentsContract.getTreeDocumentId(treeUri)
|
||||
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, dirId)
|
||||
// Une seule requête : (nom affiché, id document) — le delete SAF exige
|
||||
// l'URI de DOCUMENT, pas le nom.
|
||||
val byName = LinkedHashMap<String, String>()
|
||||
resolver.query(
|
||||
childrenUri,
|
||||
arrayOf(
|
||||
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||
DocumentsContract.Document.COLUMN_DOCUMENT_ID
|
||||
),
|
||||
null, null, null
|
||||
)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val name = cursor.getString(0) ?: continue
|
||||
byName[name] = cursor.getString(1)
|
||||
}
|
||||
}
|
||||
AutoBackupRetention.filesToDelete(byName.keys, keepCount).forEach { name ->
|
||||
byName[name]?.let { docId ->
|
||||
DocumentsContract.deleteDocument(
|
||||
resolver, DocumentsContract.buildDocumentUriUsingTree(treeUri, docId)
|
||||
)
|
||||
AppLog.log("autobackup", "rétention : $name supprimé")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,8 @@ import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.doublePreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.hormonetrack.pk.Alerts
|
||||
@ -52,6 +54,31 @@ class AppSettings(private val context: Context) {
|
||||
* (ex. "Europe/Paris") ; null/absent = fuseau du téléphone (auto).
|
||||
*/
|
||||
val CHART_TIMEZONE = stringPreferencesKey("chart_timezone")
|
||||
|
||||
// ── Auto-backup journalier (v1.7.0) ────────────────────────────────
|
||||
/** Activer/désactiver la sauvegarde automatique (défaut : false). */
|
||||
val AUTO_BACKUP_ENABLED = booleanPreferencesKey("auto_backup_enabled")
|
||||
|
||||
/**
|
||||
* Dossier cible SAF (v1.7.0) : tree URI issu d'ACTION_OPEN_DOCUMENT_TREE,
|
||||
* persisté via takePersistableUriPermission (la permission SURVIT au
|
||||
* reboot — le worker peut écrire sans intervention). null = pas de
|
||||
* dossier choisi → l'auto-backup ne peut pas tourner (worker no-op).
|
||||
*/
|
||||
val AUTO_BACKUP_TREE_URI = stringPreferencesKey("auto_backup_tree_uri")
|
||||
|
||||
/**
|
||||
* Nombre de copies d'auto-backup conservées dans le dossier (1..30,
|
||||
* défaut 7) — les plus vieilles sont supprimées après chaque run
|
||||
* (cf AutoBackupRetention ; l'export MANUEL n'est jamais touché).
|
||||
*/
|
||||
val AUTO_BACKUP_KEEP = intPreferencesKey("auto_backup_keep")
|
||||
|
||||
/** Résultat du DERNIER run auto : "ok" / "error" (null = jamais tourné). */
|
||||
val AUTO_BACKUP_LAST_RESULT = stringPreferencesKey("auto_backup_last_result")
|
||||
|
||||
/** Horodatage du dernier run auto tenté (millisecondes, 0/absent = jamais). */
|
||||
val AUTO_BACKUP_LAST_AT = longPreferencesKey("auto_backup_last_at")
|
||||
}
|
||||
|
||||
val tConfig: Flow<TConfig> = context.dataStore.data.map { prefs ->
|
||||
@ -136,6 +163,57 @@ class AppSettings(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auto-backup journalier (v1.7.0) ─────────────────────────────────────
|
||||
|
||||
/** Auto-backup activé (défaut DÉSACTIVÉ — opt-in, comme tout ce qui écrit). */
|
||||
val autoBackupEnabled: Flow<Boolean> = context.dataStore.data.map { prefs ->
|
||||
prefs[Keys.AUTO_BACKUP_ENABLED] ?: false
|
||||
}
|
||||
|
||||
/** Dossier cible SAF persisté (null = pas de dossier → worker no-op). */
|
||||
val autoBackupTreeUri: Flow<String?> = context.dataStore.data.map { prefs ->
|
||||
prefs[Keys.AUTO_BACKUP_TREE_URI]
|
||||
}
|
||||
|
||||
/** Copies conservées (1..30, défaut 7 — borné au SET, cf setAutoBackupKeep). */
|
||||
val autoBackupKeep: Flow<Int> = context.dataStore.data.map { prefs ->
|
||||
prefs[Keys.AUTO_BACKUP_KEEP] ?: 7
|
||||
}
|
||||
|
||||
/** Résultat du dernier run : Pair(résultat, horodatage) ; null = jamais tourné. */
|
||||
val autoBackupLastRun: Flow<Pair<String?, Long?>> = context.dataStore.data.map { prefs ->
|
||||
prefs[Keys.AUTO_BACKUP_LAST_RESULT] to prefs[Keys.AUTO_BACKUP_LAST_AT]
|
||||
}
|
||||
|
||||
suspend fun setAutoBackupEnabled(enabled: Boolean) {
|
||||
context.dataStore.edit { prefs -> prefs[Keys.AUTO_BACKUP_ENABLED] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setAutoBackupTreeUri(uri: String?) {
|
||||
context.dataStore.edit { prefs ->
|
||||
if (uri.isNullOrBlank()) prefs.remove(Keys.AUTO_BACKUP_TREE_URI)
|
||||
else prefs[Keys.AUTO_BACKUP_TREE_URI] = uri
|
||||
}
|
||||
}
|
||||
|
||||
/** Copies conservées — borné [1 ; 30] (un `0` supprimerait la dernière copie). */
|
||||
suspend fun setAutoBackupKeep(keep: Int) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[Keys.AUTO_BACKUP_KEEP] = keep.coerceIn(1, 30)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persiste le résultat du dernier run (appelé par [com.hormonetrack.data.backup.AutoBackupWorker])
|
||||
* — affiché dans Paramètres (« dernier : réussi/échec, date »).
|
||||
*/
|
||||
suspend fun setAutoBackupLastRun(ok: Boolean, atMs: Long) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[Keys.AUTO_BACKUP_LAST_RESULT] = if (ok) "ok" else "error"
|
||||
prefs[Keys.AUTO_BACKUP_LAST_AT] = atMs
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setAlertNotifiedState(encoded: String?) {
|
||||
context.dataStore.edit { prefs ->
|
||||
if (encoded.isNullOrEmpty()) prefs.remove(Keys.ALERT_NOTIFIED_STATE)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -179,6 +179,59 @@ fun SettingsScreen(onBack: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sauvegarde automatique (v1.7.0) : dossier SAF persisté ──────────────
|
||||
// L'utilisateur choisit le dossier UNE FOIS (ACTION_OPEN_DOCUMENT_TREE) :
|
||||
// la permission persistable survit au reboot, le worker WorkManager écrit
|
||||
// ensuite sans intervention — AUCUNE permission de stockage déclarée.
|
||||
// pickFolderToEnable distingue les deux entrées du picker :
|
||||
// - via le SWITCH quand aucun dossier n'existe encore → la sélection
|
||||
// ACTIVE la sauvegarde (+ premier run immédiat) : le switch sans
|
||||
// dossier ne peut rien faire, le picker est la suite logique ;
|
||||
// - via « Choisir le dossier » (changement) → on enregistre le dossier
|
||||
// SANS changer l'état activé/désactivé.
|
||||
val autoBackupEnabled by container.settings.autoBackupEnabled
|
||||
.collectAsState(initial = false)
|
||||
val autoBackupTreeUri by container.settings.autoBackupTreeUri
|
||||
.collectAsState(initial = null as String?)
|
||||
val autoBackupKeep by container.settings.autoBackupKeep.collectAsState(initial = 7)
|
||||
val autoBackupLastRun by container.settings.autoBackupLastRun
|
||||
.collectAsState(initial = null as Pair<String?, Long?>?)
|
||||
var autoBackupKeepText by remember(autoBackupKeep) {
|
||||
mutableStateOf(autoBackupKeep.toString())
|
||||
}
|
||||
var pickFolderToEnable by remember { mutableStateOf(false) }
|
||||
|
||||
val folderPickerLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenDocumentTree()
|
||||
) { uri ->
|
||||
uri ?: return@rememberLauncherForActivityResult
|
||||
// Persiste la permission : sans takePersistableUriPermission, le tree
|
||||
// URI serait mort au prochain démarrage (worker sans droit d'écrire).
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
uri,
|
||||
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
)
|
||||
val toEnable = pickFolderToEnable
|
||||
pickFolderToEnable = false
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setAutoBackupTreeUri(uri.toString())
|
||||
if (toEnable) {
|
||||
container.settings.setAutoBackupEnabled(true)
|
||||
// Premier backup IMMÉDIAT (feedback instantané + validation
|
||||
// émulateur directe), puis le périodique 24 h prend le relais
|
||||
com.hormonetrack.data.backup.AutoBackupScheduler.runNow(context)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
message = if (toEnable) {
|
||||
context.getString(R.string.autobackup_first_run)
|
||||
} else {
|
||||
context.getString(R.string.saved)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
@ -548,6 +601,104 @@ fun SettingsScreen(onBack: () -> Unit) {
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// --- Sauvegarde automatique (v1.7.0) : en PLUS de l'export manuel ---
|
||||
// Opt-in pur : désactivée par défaut ; nécessite un dossier choisi
|
||||
// (SAF tree persisté) ; un run immédiat est déclenché à l'activation.
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.autobackup_section), style = MaterialTheme.typography.titleMedium)
|
||||
Switch(
|
||||
checked = autoBackupEnabled,
|
||||
onCheckedChange = { wantEnabled ->
|
||||
if (!wantEnabled) {
|
||||
// Désactivation : le périodique reste planifié
|
||||
// mais le worker sort immédiatement (no-op)
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setAutoBackupEnabled(false)
|
||||
}
|
||||
} else if (autoBackupTreeUri == null) {
|
||||
// Pas de dossier → le picker EST la suite :
|
||||
// la sélection activera (+ premier run)
|
||||
pickFolderToEnable = true
|
||||
folderPickerLauncher.launch(null)
|
||||
} else {
|
||||
// Dossier existant → activation directe + run
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setAutoBackupEnabled(true)
|
||||
com.hormonetrack.data.backup.AutoBackupScheduler.runNow(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.autobackup_hint),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
// Dossier choisi : nom lisible (documents "primary:Download" →
|
||||
// "Download") + bouton pour le changer (sans toucher au switch)
|
||||
Text(
|
||||
stringResource(R.string.autobackup_folder) + " : " +
|
||||
(autoBackupTreeUri?.let { folderDisplayName(it) }
|
||||
?: stringResource(R.string.autobackup_no_folder)),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
TextButton(onClick = {
|
||||
pickFolderToEnable = false
|
||||
folderPickerLauncher.launch(null)
|
||||
}) { Text(stringResource(R.string.autobackup_choose_folder)) }
|
||||
|
||||
// Dernier run (statut alimenté par le worker) : réussi/échec + date
|
||||
autoBackupLastRun?.let { (result, atMs) ->
|
||||
if (atMs != null && atMs > 0L) {
|
||||
val date = java.text.SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.getDefault())
|
||||
.format(java.util.Date(atMs))
|
||||
Text(
|
||||
text = context.getString(
|
||||
if (result == "ok") R.string.autobackup_last_ok
|
||||
else R.string.autobackup_last_fail,
|
||||
date
|
||||
),
|
||||
color = if (result == "ok") MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(6.dp))
|
||||
// Rétention : copies conservées (1..30, borné au set) — Save
|
||||
// persiste ; le worker relit DataStore à CHAQUE run (aucun
|
||||
// re-enqueue nécessaire, cf AutoBackupScheduler)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = autoBackupKeepText,
|
||||
onValueChange = { autoBackupKeepText = it },
|
||||
label = { Text(stringResource(R.string.autobackup_keep)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Button(onClick = {
|
||||
val keep = autoBackupKeepText.trim().toIntOrNull() ?: return@Button
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setAutoBackupKeep(keep) // borné [1;30]
|
||||
withContext(Dispatchers.Main) {
|
||||
message = context.getString(R.string.saved)
|
||||
}
|
||||
}
|
||||
}) { Text(stringResource(R.string.save)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// --- Logs de diagnostic (v1.3.1) ---
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
@ -669,3 +820,18 @@ fun SettingsScreen(onBack: () -> Unit) {
|
||||
|
||||
private fun formatDoubles(d: Double): String =
|
||||
if (d == d.toLong().toDouble()) d.toLong().toString() else "%.3f".format(Locale.US, d)
|
||||
|
||||
/**
|
||||
* Nom lisible d'un dossier SAF tree URI (v1.7.0) : l'id d'un tree document
|
||||
* est de la forme « primary:Download » (stockage principal) ou
|
||||
* « <provider>:<chemin » pour les providers tiers (Owncloud…) — on affiche
|
||||
* la partie APRÈS le ':', sinon l'ID brut. Pur et défensif : un URI non
|
||||
* parsable s'affiche tel quel plutôt que de faire crasher l'écran.
|
||||
*/
|
||||
private fun folderDisplayName(treeUriString: String): String =
|
||||
try {
|
||||
val id = android.provider.DocumentsContract.getTreeDocumentId(Uri.parse(treeUriString))
|
||||
id.substringAfter(':', id)
|
||||
} catch (_: Exception) {
|
||||
treeUriString
|
||||
}
|
||||
|
||||
@ -26,6 +26,21 @@ object ExportFileNames {
|
||||
private val LOG_FMT = DateTimeFormatter.ofPattern("yyyyMMdd-HHmm")
|
||||
private val BACKUP_FMT = DateTimeFormatter.ofPattern("yyyyMMdd")
|
||||
|
||||
/**
|
||||
* Pattern des AUTO-BACKUPS journaliers (v1.7.0) : `yyyyMMdd-HHmm` — même
|
||||
* règle que [LOG_FMT] (contient HHmm → type temporel complet requis).
|
||||
* L'heure permet plusieurs runs le même jour (activation + tick journalier,
|
||||
* ou plusieurs activations successives) SANS écrasement : chaque fichier
|
||||
* a un nom unique → la rétention ([com.hormonetrack.data.backup.AutoBackupRetention])
|
||||
* trie sur l'horodatage du NOM (jamais sur la mtime du document SAF, non
|
||||
* fiable sur tous les providers).
|
||||
*/
|
||||
private val AUTO_BACKUP_FMT = DateTimeFormatter.ofPattern("yyyyMMdd-HHmm")
|
||||
|
||||
/** Préfixe d'un fichier d'AUTO-backup — partagé avec la rétention (PUR). */
|
||||
const val AUTO_BACKUP_PREFIX = "hormonetrack-auto-"
|
||||
private const val AUTO_BACKUP_SUFFIX = ".json"
|
||||
|
||||
/**
|
||||
* Nom du fichier d'export des logs de diagnostic :
|
||||
* `hormonetrack-logs-20260906-1234.txt`.
|
||||
@ -43,4 +58,34 @@ object ExportFileNames {
|
||||
*/
|
||||
fun backupFileName(today: LocalDate = LocalDate.now()): String =
|
||||
"hormonetrack-backup-${BACKUP_FMT.format(today)}.json"
|
||||
|
||||
/**
|
||||
* Nom d'un AUTO-backup journalier (v1.7.0) :
|
||||
* `hormonetrack-auto-20260913-1830.json`. Pattern AVEC heure →
|
||||
* [LocalDateTime] obligatoire (règle anti-#48 en tête de fichier).
|
||||
* Pur (déterministe sur [now]) → testé en JVM.
|
||||
*/
|
||||
fun autoBackupFileName(now: LocalDateTime = LocalDateTime.now()): String =
|
||||
"$AUTO_BACKUP_PREFIX${AUTO_BACKUP_FMT.format(now)}$AUTO_BACKUP_SUFFIX"
|
||||
|
||||
/**
|
||||
* Re-trouve l'horodatage dans un NOM de fichier d'auto-backup (round-trip
|
||||
* de [autoBackupFileName]) — PUR, utilisé par la rétention pour trier par
|
||||
* l'horodatage du nom. Retourne `null` pour tout ce qui n'est PAS un
|
||||
* auto-backup (exports manuels `hormonetrack-backup-…`, logs, fichiers
|
||||
* étrangers du dossier choisi par l'utilisatrice) : la rétention ne
|
||||
* doit JAMAIS les toucher.
|
||||
*/
|
||||
fun parseAutoBackupTimestamp(fileName: String): LocalDateTime? {
|
||||
if (!fileName.startsWith(AUTO_BACKUP_PREFIX)) return null
|
||||
if (!fileName.endsWith(AUTO_BACKUP_SUFFIX)) return null
|
||||
return try {
|
||||
LocalDateTime.parse(
|
||||
fileName.removePrefix(AUTO_BACKUP_PREFIX).removeSuffix(AUTO_BACKUP_SUFFIX),
|
||||
AUTO_BACKUP_FMT
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
null // nom presque-correct mais malformé : JAMAIS supprimé
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -204,6 +204,17 @@
|
||||
<string name="import_json">Importer</string>
|
||||
<string name="export_ok">Sauvegarde exportée.</string>
|
||||
<string name="export_fail">L\'export a échoué.</string>
|
||||
<!-- v1.7.0 : sauvegarde automatique journalière (SAF tree + WorkManager) -->
|
||||
<string name="autobackup_section">Sauvegarde automatique quotidienne</string>
|
||||
<string name="autobackup_enable">Activer</string>
|
||||
<string name="autobackup_hint">Chaque jour, l\'app écrit une sauvegarde JSON complète (même format que l\'export manuel) dans le dossier que tu choisis — par exemple un dossier Owncloud synchronisé, pour que le backup atterrisse dans ton cloud. Aucune permission de stockage nécessaire : tu choisis le dossier une fois et tu peux le révoquer à tout moment dans les réglages système. Seules les copies les plus récentes sont conservées (les vieilles copies automatiques sont supprimées ; tes exports manuels ne sont jamais touchés).</string>
|
||||
<string name="autobackup_folder">Dossier</string>
|
||||
<string name="autobackup_no_folder">Pas encore de dossier choisi — choisis-en un pour activer.</string>
|
||||
<string name="autobackup_choose_folder">Choisir le dossier</string>
|
||||
<string name="autobackup_keep">Copies conservées (1–30)</string>
|
||||
<string name="autobackup_last_ok">Dernière sauvegarde : OK (%1$s)</string>
|
||||
<string name="autobackup_last_fail">Dernière sauvegarde : ÉCHEC (%1$s) — vérifie le dossier puis réactive pour réessayer.</string>
|
||||
<string name="autobackup_first_run">Activée — une première sauvegarde a été écrite dans le dossier.</string>
|
||||
<string name="import_fail">L\'import a échoué : fichier invalide.</string>
|
||||
<string name="import_ok">Restauré : %1$d traitements, %2$d doses, %3$d analyses (les données actuelles ont été remplacées, rappels reprogrammés).</string>
|
||||
<string name="import_confirm">Effacer & restaurer</string>
|
||||
|
||||
@ -204,6 +204,17 @@
|
||||
<string name="import_json">Import</string>
|
||||
<string name="export_ok">Backup exported.</string>
|
||||
<string name="export_fail">Export failed.</string>
|
||||
<!-- v1.7.0 : sauvegarde automatique journalière (SAF tree + WorkManager) -->
|
||||
<string name="autobackup_section">Automatic daily backup</string>
|
||||
<string name="autobackup_enable">Enable</string>
|
||||
<string name="autobackup_hint">Each day, the app writes a full JSON backup (same format as the manual export) into the folder you choose — e.g. a synced Owncloud folder, so it lands in your cloud. No storage permission is needed: you pick the folder once and can revoke it anytime in the system settings. Only the newest copies are kept (older automatic copies are deleted; your manual exports are never touched).</string>
|
||||
<string name="autobackup_folder">Folder</string>
|
||||
<string name="autobackup_no_folder">No folder selected yet — pick one to enable.</string>
|
||||
<string name="autobackup_choose_folder">Choose folder</string>
|
||||
<string name="autobackup_keep">Copies kept (1–30)</string>
|
||||
<string name="autobackup_last_ok">Last backup: OK (%1$s)</string>
|
||||
<string name="autobackup_last_fail">Last backup: FAILED (%1$s) — check the folder, then re-enable to retry.</string>
|
||||
<string name="autobackup_first_run">Enabled — a first backup was written to the folder.</string>
|
||||
<string name="import_fail">Import failed: invalid file.</string>
|
||||
<string name="import_ok">Restored: %1$d treatments, %2$d doses, %3$d labs (current data was replaced, reminders rescheduled).</string>
|
||||
<string name="import_confirm">Erase & restore</string>
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -141,6 +141,12 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
|
||||
- **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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user