From e269e9932a7a5ee7d82e35dd4c3f9c36332b8586 Mon Sep 17 00:00:00 2001 From: tuxgyver Date: Sun, 25 Oct 2026 10:49:43 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20corrections=20de=20l'audit=20de=20s?= =?UTF-8?q?=C3=A9curit=C3=A9=20et=20de=20robustesse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VPN retiré : le contrôle reste activé (alerte parent, tunnel remonté après 30 s, autorisation redemandée à l'ouverture) - Horloge de référence : changer date/heure/fuseau ne contourne plus les plages horaires ni le temps d'écran (signalé au parent) - Mot de passe : PBKDF2 + attente croissante après 3 échecs ; conversion automatique des anciens hachages - Écrans protégés : aucun affichage avant chargement des réglages ; effacement du journal protégé - Signature release via keystore.properties (même clé que les versions publiées) - Sauvegarde Google : réglages, appairage et journal exclus - Alerte admin fiable (goAsync), relance refusée signalée, liste d'applis renvoyée à chaque installation, code d'appairage consommé - Journal thread-safe et sans recopie à chaque ligne ; correctifs lint - Tests unitaires (mot de passe, règles, versions, config Firebase) ; README aligné sur le comportement réel Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ --- .gitignore | 5 + README.md | 14 +- app/build.gradle.kts | 25 ++- .../com/jttools/datacontrol/MainActivity.kt | 26 +++ .../admin/AppDeviceAdminReceiver.kt | 8 +- .../datacontrol/data/PasswordHasher.kt | 43 ++++- .../datacontrol/data/SettingsRepository.kt | 92 +++++++++- .../datacontrol/guard/BlockedActivity.kt | 7 +- .../jttools/datacontrol/guard/GuardEngine.kt | 164 ++++++++++++++---- .../com/jttools/datacontrol/guard/Rules.kt | 25 +++ .../com/jttools/datacontrol/log/AppLog.kt | 14 +- .../datacontrol/remote/DeviceReporter.kt | 27 +++ .../datacontrol/remote/FirebaseConfig.kt | 3 +- .../jttools/datacontrol/remote/RemoteSync.kt | 24 ++- .../service/DataMonitorRestartWorker.kt | 44 ++++- .../datacontrol/service/DataMonitorService.kt | 49 ++++-- .../datacontrol/service/FirewallVpnService.kt | 15 +- .../ui/components/PasswordComponents.kt | 45 +++-- .../ui/dashboard/DashboardScreen.kt | 14 +- .../ui/dashboard/DashboardViewModel.kt | 9 +- .../datacontrol/ui/family/FamilySection.kt | 17 +- .../datacontrol/ui/family/FamilyViewModel.kt | 8 +- .../datacontrol/ui/journal/JournalScreen.kt | 38 +++- .../ui/journal/JournalViewModel.kt | 24 +++ .../datacontrol/ui/settings/SettingsScreen.kt | 3 +- .../ui/settings/SettingsViewModel.kt | 8 +- .../ui/whitelist/WhitelistScreen.kt | 3 +- .../ui/whitelist/WhitelistViewModel.kt | 9 +- app/src/main/res/values/strings.xml | 10 -- app/src/main/res/xml/backup_rules.xml | 4 + .../main/res/xml/data_extraction_rules.xml | 8 + .../datacontrol/data/PasswordHasherTest.kt | 38 ++++ .../datacontrol/data/UpdateManagerTest.kt | 17 ++ .../jttools/datacontrol/guard/RulesTest.kt | 64 +++++++ .../datacontrol/remote/FirebaseConfigTest.kt | 43 +++++ gradle/libs.versions.toml | 2 + keystore.properties.example | 7 + 37 files changed, 809 insertions(+), 147 deletions(-) create mode 100644 app/src/main/java/com/jttools/datacontrol/ui/journal/JournalViewModel.kt create mode 100644 app/src/test/java/com/jttools/datacontrol/data/PasswordHasherTest.kt create mode 100644 app/src/test/java/com/jttools/datacontrol/data/UpdateManagerTest.kt create mode 100644 app/src/test/java/com/jttools/datacontrol/guard/RulesTest.kt create mode 100644 app/src/test/java/com/jttools/datacontrol/remote/FirebaseConfigTest.kt create mode 100644 keystore.properties.example diff --git a/.gitignore b/.gitignore index 831639b..02e9a63 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,8 @@ gitea-token.properties # Configuration Firebase locale (clés du projet). firebase.properties + +# Clé de signature des releases (chemin + mots de passe) : local, jamais versionné. +keystore.properties +*.keystore +*.jks diff --git a/README.md b/README.md index 53cac5f..6561489 100644 --- a/README.md +++ b/README.md @@ -13,13 +13,13 @@ - **Estimation du coût** — Calcul automatique : prix du forfait + (Mo hors-forfait × prix/Mo), affiché en temps réel sur le tableau de bord. - **Réinitialisation** — Automatique chaque mois à la date anniversaire du début de forfait, ou manuelle à tout moment depuis le tableau de bord. - **Liste blanche d'applications** — Les applications cochées gardent toujours accès aux données mobiles ; toutes les autres en sont privées en permanence (voir *Comportement du pare-feu* ci-dessous). Le Wi-Fi n'est jamais filtré. -- **Coupure cyclique en cas de dépassement** — Une fois le forfait dépassé, une coupure totale (même pour la liste blanche) se déclenche automatiquement toutes les X minutes (paramétrable), pendant une courte pulsation, avant de revenir en mode "liste blanche seule". +- **Coupure cyclique** — Sur données mobiles, une coupure totale (même pour la liste blanche) se déclenche automatiquement toutes les X minutes (paramétrable), pendant une courte pulsation de 30 s, avant de revenir en mode "liste blanche seule". Elle tourne en permanence, que le forfait soit dépassé ou non : le forfait sert au suivi et à l'estimation du coût, pas au déclenchement du filtrage. - **Notification permanente** — Affiche la consommation (Mo / forfait / %), l'état des données avec un code couleur (🟢 sous le forfait, 🟠 proche du forfait, 🔴 hors-forfait/coupure, 🔵 Wi-Fi actif), et le coût estimé. Rafraîchie toutes les 20 secondes et à chaque changement de réseau. - **Démarrage au boot** — Le service redémarre automatiquement après un redémarrage de l'appareil si le contrôle était actif. -- **Alerte pare-feu inactif** — Si le contrôle est actif sur données mobiles mais que le tunnel VPN n'est pas monté, la notification passe en rouge (« PARE-FEU INACTIF ») : les données ne sont alors pas filtrées. +- **Alerte pare-feu inactif** — Si le contrôle est actif sur données mobiles mais que le tunnel VPN n'est pas monté, la notification passe en rouge (« PARE-FEU INACTIF ») : les données ne sont alors pas filtrées. Retirer l'autorisation VPN (ou activer un autre VPN) ne désactive **pas** le contrôle : le parent est alerté, le tunnel est remonté après 30 s si possible, et l'autorisation est redemandée à l'ouverture de l'application. - **Journal de diagnostic** — Onglet *Journal* : état du VPN en direct, décisions du pare-feu, et, avec le service d'accessibilité, chaque application utilisée avec son verdict réseau/lancement. Conservé 15 jours. - **Mise à jour intégrée** — Recherche et installation de la dernière Release Gitea depuis l'onglet *À propos*. -- **Mot de passe de verrouillage** — Protège les Réglages et la désactivation du contrôle. +- **Mot de passe de verrouillage** — Protège les Réglages, la Liste blanche, l'effacement du journal et la désactivation du contrôle. Haché avec PBKDF2 ; après 3 essais ratés, attente croissante (30 s, 1 min, 2 min… jusqu'à 15 min). - **Interface Material 3** — Thème clair/sombre avec couleurs dynamiques (Android 12+), 5 onglets : Tableau de bord, Réglages, Liste blanche, Journal, À propos. --- @@ -31,7 +31,7 @@ DataControl peut être associé à **FamilyControl** (application parent en Type - **Appairage** par code à usage court, saisi dans *Réglages → Contrôle parental*. - **Remontées** : batterie, position, état du pare-feu, consommation, trafic quotidien mobile/Wi-Fi, temps d'écran, événements (VPN révoqué, protection retirée…). - **Consignes du parent** : contrôle activé/désactivé, applications autorisées (mobile et Wi-Fi), filtrage Wi-Fi. -- **Règles d'usage** (service d'accessibilité) : plages horaires (avec coupure optionnelle des données mobiles hors plage), temps d'écran quotidien global ou par application, ajout de temps, applications interdites. Le lanceur, le téléphone, le clavier et DataControl ne sont jamais bloqués. +- **Règles d'usage** (service d'accessibilité) : plages horaires (avec coupure optionnelle des données mobiles hors plage), temps d'écran quotidien global ou par application, ajout de temps, applications interdites. Le lanceur, le téléphone, le clavier et DataControl ne sont jamais bloqués. Changer à la main la date, l'heure ou le fuseau du téléphone ne contourne pas ces règles (le changement est ignoré et signalé au parent) ; laisser l'heure automatique activée est recommandé. - **Anti-désinstallation** par administrateur d'appareil (retirable depuis les Réglages Android : le parent est alors alerté ; le mot de passe protège le retrait depuis l'application). --- @@ -45,7 +45,9 @@ Règles appliquées : | Réseau actif | Sous le forfait | Forfait dépassé | |---|---|---| | **Wi-Fi** | Aucun filtrage, jamais | Aucun filtrage, jamais | -| **Données mobiles** | Liste blanche active en permanence : seules les apps cochées ont accès | Liste blanche active **+** coupure totale cyclique toutes les X minutes (pulsation de 30 s, même les apps de la liste blanche sont coupées pendant la pulsation) | +| **Données mobiles** | Liste blanche active en permanence **+** coupure totale cyclique toutes les X minutes | Identique (le dépassement ne change pas le filtrage, seulement l'état affiché et le coût) | + +Pendant la pulsation de coupure (30 s), même les applications de la liste blanche sont coupées. Le pare-feu est démarré/arrêté automatiquement selon le transport réseau actif détecté (`ConnectivityManager.NetworkCallback`) : dès que l'appareil bascule sur le Wi-Fi, le tunnel VPN est entièrement fermé. @@ -97,6 +99,8 @@ make deploy # APK release + publication en Release Gitea (voir `make help`) Prérequis : Android SDK (API 34), JDK 17. Le wrapper Gradle télécharge Gradle 8.13 au premier lancement. +Signature : la release est signée avec la clé décrite dans `keystore.properties` (non versionné, modèle dans `keystore.properties.example`). Cette clé doit rester celle des versions déjà publiées, sinon les téléphones refusent la mise à jour : sauvegarder le keystore et ce fichier. Sans `keystore.properties`, la compilation retombe sur la clé de débogage, avec un avertissement. + --- ## Licence diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3eaf312..c065e1d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,6 +16,13 @@ val firebaseProps = Properties().apply { if (f.exists()) f.inputStream().use { load(it) } } +// Clé de signature des releases : fichier local non versionné (voir keystore.properties.example). +// Sans lui, la release est signée avec la clé de débogage (avertissement à la compilation). +val keystoreProps = Properties().apply { + val f = rootProject.file("keystore.properties") + if (f.exists()) f.inputStream().use { load(it) } +} + val versionProps = Properties().apply { load(rootProject.file("version.properties").inputStream()) } @@ -42,6 +49,17 @@ android { buildConfigField("String", "BUILD_DATE", "\"${SimpleDateFormat("yyyy-MM-dd").format(Date())}\"") } + signingConfigs { + if (keystoreProps.getProperty("storeFile") != null) { + create("release") { + storeFile = file(keystoreProps.getProperty("storeFile")) + storePassword = keystoreProps.getProperty("storePassword") + keyAlias = keystoreProps.getProperty("keyAlias") + keyPassword = keystoreProps.getProperty("keyPassword") + } + } + } + buildTypes { release { isMinifyEnabled = true @@ -49,7 +67,10 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) - signingConfig = signingConfigs.getByName("debug") + signingConfig = signingConfigs.findByName("release") ?: run { + logger.warn("⚠️ keystore.properties absent : release signée avec la clé de débogage") + signingConfigs.getByName("debug") + } } } @@ -115,6 +136,8 @@ dependencies { implementation(libs.kotlinx.coroutines.play.services) testImplementation(libs.junit) + // org.json est fourni par Android à l'exécution, mais absent (bouchonné) des tests JVM. + testImplementation(libs.org.json) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/main/java/com/jttools/datacontrol/MainActivity.kt b/app/src/main/java/com/jttools/datacontrol/MainActivity.kt index 3fb8590..c008424 100644 --- a/app/src/main/java/com/jttools/datacontrol/MainActivity.kt +++ b/app/src/main/java/com/jttools/datacontrol/MainActivity.kt @@ -4,16 +4,25 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.lifecycle.lifecycleScope +import com.jttools.datacontrol.data.SettingsRepository +import com.jttools.datacontrol.log.AppLog import com.jttools.datacontrol.service.DataMonitorService import com.jttools.datacontrol.ui.SystemActions import com.jttools.datacontrol.ui.navigation.DataControlNavHost import com.jttools.datacontrol.ui.theme.DataControlTheme import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { + @Inject lateinit var settingsRepository: SettingsRepository + private lateinit var systemActions: SystemActions + private var vpnAskedThisSession = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -29,4 +38,21 @@ class MainActivity : ComponentActivity() { } } } + + override fun onResume() { + super.onResume() + // Contrôle actif mais autorisation VPN perdue (retirée, ou prise par un autre VPN) : + // on la redemande à l'ouverture, une seule fois par session pour ne pas boucler. + if (vpnAskedThisSession) return + lifecycleScope.launch { + if (settingsRepository.settingsFlow.first().controlEnabled && !systemActions.isVpnPrepared()) { + vpnAskedThisSession = true + AppLog.w("Pare-feu", "Contrôle actif sans autorisation VPN : nouvelle demande") + systemActions.requestVpnPermission { granted -> + AppLog.i("Pare-feu", "Autorisation VPN ${if (granted) "rétablie" else "REFUSÉE"}") + if (granted) DataMonitorService.start(this@MainActivity) + } + } + } + } } diff --git a/app/src/main/java/com/jttools/datacontrol/admin/AppDeviceAdminReceiver.kt b/app/src/main/java/com/jttools/datacontrol/admin/AppDeviceAdminReceiver.kt index 238f8aa..0767894 100644 --- a/app/src/main/java/com/jttools/datacontrol/admin/AppDeviceAdminReceiver.kt +++ b/app/src/main/java/com/jttools/datacontrol/admin/AppDeviceAdminReceiver.kt @@ -20,8 +20,14 @@ class AppDeviceAdminReceiver : DeviceAdminReceiver() { override fun onDisabled(context: Context, intent: Intent) { // Prévient le parent : la protection anti-désinstallation vient d'être retirée. + // goAsync : sans lui, le processus peut être arrêté avant que l'alerte soit partie. + val pending = goAsync() CoroutineScope(Dispatchers.IO).launch { - remoteSync.pushEvent("admin_disabled", "Protection anti-désinstallation désactivée") + try { + remoteSync.pushEvent("admin_disabled", "Protection anti-désinstallation désactivée") + } finally { + pending.finish() + } } } } diff --git a/app/src/main/java/com/jttools/datacontrol/data/PasswordHasher.kt b/app/src/main/java/com/jttools/datacontrol/data/PasswordHasher.kt index f9cecfc..e8fef39 100644 --- a/app/src/main/java/com/jttools/datacontrol/data/PasswordHasher.kt +++ b/app/src/main/java/com/jttools/datacontrol/data/PasswordHasher.kt @@ -2,10 +2,20 @@ package com.jttools.datacontrol.data import java.security.MessageDigest import java.security.SecureRandom +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec -/** Hachage salé (SHA-256) du mot de passe de verrouillage — jamais stocké en clair. */ +/** + * Hachage du mot de passe de verrouillage (PBKDF2-HMAC-SHA256, salé) — jamais stocké en clair. + * + * Format stocké : `pbkdf2::`. Les anciens hachages (SHA-256 simple, v0.1.8 et avant) + * restent vérifiables et sont convertis au prochain déverrouillage réussi. + */ object PasswordHasher { private const val SALT_BYTES = 16 + private const val ITERATIONS = 100_000 + private const val KEY_BITS = 256 + private const val PREFIX = "pbkdf2:" fun newSalt(): String { val bytes = ByteArray(SALT_BYTES) @@ -13,11 +23,40 @@ object PasswordHasher { return bytes.toHex() } - fun hash(password: String, saltHex: String): String { + fun hash(password: String, saltHex: String): String = + "$PREFIX$ITERATIONS:${pbkdf2(password, saltHex, ITERATIONS)}" + + fun verify(password: String, saltHex: String, stored: String): Boolean { + val computed = if (stored.startsWith(PREFIX)) { + val iterations = stored.removePrefix(PREFIX).substringBefore(':').toIntOrNull() ?: return false + "$PREFIX$iterations:${pbkdf2(password, saltHex, iterations)}" + } else { + legacySha256(password, saltHex) + } + // Comparaison à temps constant. + return MessageDigest.isEqual(computed.toByteArray(Charsets.UTF_8), stored.toByteArray(Charsets.UTF_8)) + } + + /** Vrai pour un hachage ancien format, à recalculer avec [hash]. */ + fun needsUpgrade(stored: String): Boolean = !stored.startsWith("$PREFIX$ITERATIONS:") + + private fun pbkdf2(password: String, saltHex: String, iterations: Int): String { + val spec = PBEKeySpec(password.toCharArray(), saltHex.hexToBytes(), iterations, KEY_BITS) + try { + return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).encoded.toHex() + } finally { + spec.clearPassword() + } + } + + private fun legacySha256(password: String, saltHex: String): String { val digest = MessageDigest.getInstance("SHA-256") digest.update(saltHex.toByteArray(Charsets.UTF_8)) return digest.digest(password.toByteArray(Charsets.UTF_8)).toHex() } private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + + private fun String.hexToBytes(): ByteArray = + ByteArray(length / 2) { i -> substring(2 * i, 2 * i + 2).toInt(16).toByte() } } diff --git a/app/src/main/java/com/jttools/datacontrol/data/SettingsRepository.kt b/app/src/main/java/com/jttools/datacontrol/data/SettingsRepository.kt index 088eb30..24e46c8 100644 --- a/app/src/main/java/com/jttools/datacontrol/data/SettingsRepository.kt +++ b/app/src/main/java/com/jttools/datacontrol/data/SettingsRepository.kt @@ -1,6 +1,10 @@ package com.jttools.datacontrol.data import android.content.Context +import android.os.SystemClock +import com.jttools.datacontrol.log.AppLog +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.doublePreferencesKey import androidx.datastore.preferences.core.edit @@ -56,6 +60,16 @@ class SettingsRepository @Inject constructor( val TRAFFIC_WIFI_ACC = longPreferencesKey("traffic_wifi_acc") val TRAFFIC_LAST_MOBILE = longPreferencesKey("traffic_last_mobile") val TRAFFIC_LAST_OTHER = longPreferencesKey("traffic_last_other") + val PASSWORD_FAILS = intPreferencesKey("password_fails") + val PASSWORD_LOCK_UNTIL = longPreferencesKey("password_lock_until") + val CLOCK_SKEW_MS = longPreferencesKey("clock_skew_ms") + val CLOCK_ZONE = stringPreferencesKey("clock_zone") + } + + private companion object { + const val FREE_ATTEMPTS = 3 + const val LOCK_BASE_MS = 30_000L + const val LOCK_MAX_MS = 15 * 60_000L } val settingsFlow: Flow = context.dataStore.data.map { prefs -> @@ -168,14 +182,29 @@ class SettingsRepository @Inject constructor( } } else { val salt = PasswordHasher.newSalt() - val hash = PasswordHasher.hash(password, salt) + val hash = withContext(Dispatchers.Default) { PasswordHasher.hash(password, salt) } context.dataStore.edit { prefs -> prefs[Keys.PASSWORD_HASH] = hash prefs[Keys.PASSWORD_SALT] = salt + prefs.remove(Keys.PASSWORD_FAILS) + prefs.remove(Keys.PASSWORD_LOCK_UNTIL) } } } + /** Horloge de référence de [com.jttools.datacontrol.guard.GuardEngine] : décalage (ms) et fuseau retenus. */ + suspend fun trustedClock(): Pair { + val prefs = context.dataStore.data.first() + return (prefs[Keys.CLOCK_SKEW_MS] ?: 0L) to prefs[Keys.CLOCK_ZONE] + } + + suspend fun setTrustedClock(skewMs: Long, zoneId: String) { + context.dataStore.edit { + if (skewMs == 0L) it.remove(Keys.CLOCK_SKEW_MS) else it[Keys.CLOCK_SKEW_MS] = skewMs + it[Keys.CLOCK_ZONE] = zoneId + } + } + /** * Réinitialisation unique (v0.1.2) du mot de passe de verrouillage : le supprime une seule fois, * puis mémorise qu'elle a eu lieu pour ne pas effacer un mot de passe défini ensuite. @@ -190,12 +219,63 @@ class SettingsRepository @Inject constructor( } } - /** Vrai si aucun mot de passe n'est défini, ou si [password] correspond au mot de passe stocké. */ - suspend fun verifyPassword(password: String): Boolean { + /** Fin du blocage en temps monotone : avancer l'horloge du téléphone ne raccourcit pas l'attente. */ + @Volatile private var lockUntilElapsed = 0L + + /** + * Vérifie [password]. Renvoie null s'il est correct (ou si aucun mot de passe n'est défini), + * sinon le message d'erreur à afficher. Après [FREE_ATTEMPTS] échecs, chaque nouvel essai + * impose une attente qui double (30 s, 1 min, 2 min… jusqu'à 15 min). + */ + suspend fun checkPassword(password: String): String? { val prefs = context.dataStore.data.first() - val hash = prefs[Keys.PASSWORD_HASH] ?: return true - val salt = prefs[Keys.PASSWORD_SALT] ?: return true - return PasswordHasher.hash(password, salt) == hash + val hash = prefs[Keys.PASSWORD_HASH] ?: return null + val salt = prefs[Keys.PASSWORD_SALT] ?: return null + + val remaining = maxOf( + (prefs[Keys.PASSWORD_LOCK_UNTIL] ?: 0L) - System.currentTimeMillis(), + lockUntilElapsed - SystemClock.elapsedRealtime() + ) + if (remaining > 0) return lockMessage(remaining) + + val ok = withContext(Dispatchers.Default) { PasswordHasher.verify(password, salt, hash) } + if (ok) { + val upgraded = if (PasswordHasher.needsUpgrade(hash)) { + withContext(Dispatchers.Default) { PasswordHasher.newSalt().let { it to PasswordHasher.hash(password, it) } } + } else null + lockUntilElapsed = 0L + context.dataStore.edit { + it.remove(Keys.PASSWORD_FAILS) + it.remove(Keys.PASSWORD_LOCK_UNTIL) + upgraded?.let { (newSalt, newHash) -> + it[Keys.PASSWORD_SALT] = newSalt + it[Keys.PASSWORD_HASH] = newHash + } + } + return null + } + + var lockMs = 0L + context.dataStore.edit { + val fails = (it[Keys.PASSWORD_FAILS] ?: 0) + 1 + it[Keys.PASSWORD_FAILS] = fails + if (fails >= FREE_ATTEMPTS) { + lockMs = (LOCK_BASE_MS shl (fails - FREE_ATTEMPTS).coerceAtMost(10)).coerceAtMost(LOCK_MAX_MS) + it[Keys.PASSWORD_LOCK_UNTIL] = System.currentTimeMillis() + lockMs + } + } + if (lockMs > 0) { + lockUntilElapsed = SystemClock.elapsedRealtime() + lockMs + AppLog.w("Mot de passe", "Mot de passe erroné, saisie bloquée ${lockMs / 1000} s") + return "Mot de passe incorrect. " + lockMessage(lockMs) + } + AppLog.w("Mot de passe", "Mot de passe erroné") + return "Mot de passe incorrect." + } + + private fun lockMessage(ms: Long): String { + val s = (ms + 999) / 1000 + return if (s < 60) "Trop d'essais : réessayez dans $s s." else "Trop d'essais : réessayez dans ${(s + 59) / 60} min." } /** diff --git a/app/src/main/java/com/jttools/datacontrol/guard/BlockedActivity.kt b/app/src/main/java/com/jttools/datacontrol/guard/BlockedActivity.kt index e50d357..82f2f69 100644 --- a/app/src/main/java/com/jttools/datacontrol/guard/BlockedActivity.kt +++ b/app/src/main/java/com/jttools/datacontrol/guard/BlockedActivity.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.Arrangement @@ -32,6 +33,10 @@ class BlockedActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() + // Retour : renvoie à l'accueil, jamais vers l'application bloquée. + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() = goHome() + }) val label = intent.getStringExtra(EXTRA_LABEL).orEmpty() val message = intent.getStringExtra(EXTRA_MESSAGE).orEmpty() setContent { @@ -55,8 +60,6 @@ class BlockedActivity : ComponentActivity() { } } - @Deprecated("Deprecated in Java") - override fun onBackPressed() = goHome() private fun goHome() { startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) diff --git a/app/src/main/java/com/jttools/datacontrol/guard/GuardEngine.kt b/app/src/main/java/com/jttools/datacontrol/guard/GuardEngine.kt index e8ea8f7..0573acd 100644 --- a/app/src/main/java/com/jttools/datacontrol/guard/GuardEngine.kt +++ b/app/src/main/java/com/jttools/datacontrol/guard/GuardEngine.kt @@ -1,7 +1,16 @@ package com.jttools.datacontrol.guard +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.os.SystemClock +import android.provider.Settings +import androidx.core.content.ContextCompat import com.jttools.datacontrol.data.SettingsRepository +import com.jttools.datacontrol.log.AppLog +import com.jttools.datacontrol.remote.RemoteSync +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -11,10 +20,12 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.json.JSONObject -import java.time.LocalDate +import java.time.Instant import java.time.LocalDateTime +import java.time.ZoneId import javax.inject.Inject import javax.inject.Singleton +import kotlin.math.abs data class Block(val message: String) @@ -27,9 +38,19 @@ data class UsageSnapshot(val date: String, val totalMs: Long, val appMs: Map() private var currentPkg: String? = null private var lastMark = SystemClock.elapsedRealtime() private var screenOn = true private var dirty = false + // Horloge de référence. + private var skewMs = 0L + private var zone: ZoneId = ZoneId.systemDefault() + private var refSysMs = System.currentTimeMillis() + private var refElapsedMs = SystemClock.elapsedRealtime() + + private var day = today() + private var totalMs = 0L + private val appMs = HashMap() + + private val clockReceiver = object : BroadcastReceiver() { + override fun onReceive(c: Context?, intent: Intent?) { + synchronized(lock) { + if (intent?.action == Intent.ACTION_TIMEZONE_CHANGED) onZoneChanged() + flush() + } + } + } + fun start() { synchronized(lock) { if (started) return; started = true } scope.launch { + val (savedSkew, savedZone) = settings.trustedClock() + synchronized(lock) { + skewMs = if (autoTime()) 0L else savedSkew + zone = savedZone?.takeIf { !autoZone() }?.let { runCatching { ZoneId.of(it) }.getOrNull() } + ?: ZoneId.systemDefault() + refSysMs = System.currentTimeMillis() + refElapsedMs = SystemClock.elapsedRealtime() + day = today() + } settings.loadUsage()?.let { (savedDay, json) -> restore(savedDay, json) } + ContextCompat.registerReceiver( + context, clockReceiver, + IntentFilter().apply { + addAction(Intent.ACTION_TIME_CHANGED) + addAction(Intent.ACTION_TIMEZONE_CHANGED) + }, + ContextCompat.RECEIVER_NOT_EXPORTED + ) settings.settingsFlow.map { it.rulesJson }.distinctUntilChanged().collect { rules = Rules.parse(it) } } scope.launch { @@ -62,7 +116,7 @@ class GuardEngine @Inject constructor(private val settings: SettingsRepository) } private fun restore(savedDay: String, json: String) = synchronized(lock) { - if (savedDay != LocalDate.now().toString()) return@synchronized + if (savedDay != today()) return@synchronized runCatching { val o = JSONObject(json) day = savedDay @@ -89,9 +143,71 @@ class GuardEngine @Inject constructor(private val settings: SettingsRepository) fun tick() = synchronized(lock) { flush() } + /** Heure de référence (heure système corrigée des réglages manuels repérés). À appeler sous verrou. */ + private fun nowLocal(): LocalDateTime { + checkClockJump() + return LocalDateTime.ofInstant(Instant.ofEpochMilli(System.currentTimeMillis() + skewMs), zone) + } + + private fun today(): String = nowLocal().toLocalDate().toString() + + /** Repère un saut de l'heure système par rapport au temps monotone. À appeler sous verrou. */ + private fun checkClockJump() { + val sys = System.currentTimeMillis() + val elapsed = SystemClock.elapsedRealtime() + val jump = sys - (refSysMs + (elapsed - refElapsedMs)) + // La référence ne bouge qu'après un saut : des petits réglages successifs s'additionnent + // et finissent par être repérés. + if (abs(jump) < CLOCK_JUMP_TOLERANCE_MS) return + refSysMs = sys + refElapsedMs = elapsed + val auto = autoTime() + val newSkew = if (auto) 0L else skewMs - jump + if (newSkew == skewMs) return + skewMs = newSkew + saveClock() + val minutes = jump / 60_000 + if (auto) { + AppLog.i("Horloge", "Heure corrigée par le réseau (${signed(minutes)} min)") + } else { + val detail = "Heure du téléphone modifiée à la main (${signed(minutes)} min) : ignorée pour les plages horaires et le temps d'écran" + AppLog.w("Horloge", detail) + scope.launch { remoteSync.pushEvent("clock_changed", detail) } + } + } + + /** À appeler sous verrou. */ + private fun onZoneChanged() { + val system = ZoneId.systemDefault() + if (system == zone) return + if (autoZone()) { + AppLog.i("Horloge", "Fuseau horaire : $zone → $system (automatique)") + zone = system + saveClock() + } else { + val detail = "Fuseau horaire changé à la main ($zone → $system) : ignoré pour les plages horaires et le temps d'écran" + AppLog.w("Horloge", detail) + scope.launch { remoteSync.pushEvent("clock_changed", detail) } + } + } + + private fun saveClock() { + val s = skewMs + val z = zone.id + scope.launch { settings.setTrustedClock(s, z) } + } + + private fun signed(v: Long) = if (v >= 0) "+$v" else "$v" + + private fun autoTime(): Boolean = + Settings.Global.getInt(context.contentResolver, Settings.Global.AUTO_TIME, 1) == 1 + + private fun autoZone(): Boolean = + Settings.Global.getInt(context.contentResolver, Settings.Global.AUTO_TIME_ZONE, 1) == 1 + private fun flush() { val now = SystemClock.elapsedRealtime() - val today = LocalDate.now().toString() + val today = today() if (today != day) { day = today; totalMs = 0; appMs.clear(); dirty = true } @@ -111,22 +227,22 @@ class GuardEngine @Inject constructor(private val settings: SettingsRepository) /** Vrai si l'appareil est hors de toutes les plages horaires autorisées. */ fun scheduleLocked(): Boolean = synchronized(lock) { val r = rules - r.scheduleEnabled && !inWindow(r, LocalDateTime.now()) + r.scheduleEnabled && !r.isOpenAt(nowLocal()) } fun cutsMobileNow(): Boolean = synchronized(lock) { val r = rules - r.scheduleEnabled && r.scheduleCutsMobile && !inWindow(r, LocalDateTime.now()) + r.scheduleEnabled && r.scheduleCutsMobile && !r.isOpenAt(nowLocal()) } fun check(pkg: String): Block? = synchronized(lock) { flush() val r = rules if (r.isEmpty) return null - val now = LocalDateTime.now() + val now = nowLocal() if (pkg in r.blocked) return Block("Cette application est bloquée par vos parents.") - if (r.scheduleEnabled && !inWindow(r, now)) { - return Block("L'appareil est verrouillé en dehors des plages horaires. ${nextOpening(r, now)}") + if (r.scheduleEnabled && !r.isOpenAt(now)) { + return Block("L'appareil est verrouillé en dehors des plages horaires. ${r.nextOpeningText(now)}") } val bonusToday = r.bonusDate == day if (r.dailyLimitMin > 0) { @@ -142,31 +258,11 @@ class GuardEngine @Inject constructor(private val settings: SettingsRepository) null } - private fun inWindow(r: Rules, now: LocalDateTime): Boolean { - val dow = now.dayOfWeek.value - val prev = if (dow == 1) 7 else dow - 1 - val minute = now.hour * 60 + now.minute - return r.windows.any { w -> - if (w.startMin <= w.endMin) dow in w.days && minute >= w.startMin && minute < w.endMin - else (dow in w.days && minute >= w.startMin) || (prev in w.days && minute < w.endMin) - } - } - - private fun nextOpening(r: Rules, now: LocalDateTime): String { - val minute = now.hour * 60 + now.minute - for (offset in 0..7) { - val dow = ((now.dayOfWeek.value - 1 + offset) % 7) + 1 - val start = r.windows.filter { dow in it.days && (offset > 0 || it.startMin > minute) } - .minOfOrNull { it.startMin } ?: continue - val label = "%02d:%02d".format(start / 60, start % 60) - return if (offset == 0) "Reprise à $label." else "Reprise à $label (dans $offset j)." - } - return "" - } - private companion object { const val MIN_MS = 60_000L const val MAX_DELTA_MS = 120_000L const val PERSIST_INTERVAL_MS = 30_000L + /** Écart cumulé toléré entre heure système et temps monotone (resynchronisations NTP ordinaires). */ + const val CLOCK_JUMP_TOLERANCE_MS = 60_000L } } diff --git a/app/src/main/java/com/jttools/datacontrol/guard/Rules.kt b/app/src/main/java/com/jttools/datacontrol/guard/Rules.kt index 6fc77ce..d4a5dc2 100644 --- a/app/src/main/java/com/jttools/datacontrol/guard/Rules.kt +++ b/app/src/main/java/com/jttools/datacontrol/guard/Rules.kt @@ -2,6 +2,7 @@ package com.jttools.datacontrol.guard import org.json.JSONArray import org.json.JSONObject +import java.time.LocalDateTime /** Plage pendant laquelle l'appareil est utilisable. Minutes depuis minuit ; jours ISO (1 = lundi … 7 = dimanche). */ data class TimeWindow(val days: Set, val startMin: Int, val endMin: Int) @@ -26,6 +27,30 @@ data class Rules( val isEmpty: Boolean get() = !scheduleEnabled && dailyLimitMin <= 0 && appLimits.isEmpty() && blocked.isEmpty() + /** Vrai si [now] tombe dans une plage autorisée (une plage qui passe minuit déborde sur le lendemain). */ + fun isOpenAt(now: LocalDateTime): Boolean { + val dow = now.dayOfWeek.value + val prev = if (dow == 1) 7 else dow - 1 + val minute = now.hour * 60 + now.minute + return windows.any { w -> + if (w.startMin <= w.endMin) dow in w.days && minute >= w.startMin && minute < w.endMin + else (dow in w.days && minute >= w.startMin) || (prev in w.days && minute < w.endMin) + } + } + + /** Heure de la prochaine ouverture, en texte (« Reprise à 08:00. »), ou "" s'il n'y en a pas. */ + fun nextOpeningText(now: LocalDateTime): String { + val minute = now.hour * 60 + now.minute + for (offset in 0..7) { + val dow = ((now.dayOfWeek.value - 1 + offset) % 7) + 1 + val start = windows.filter { dow in it.days && (offset > 0 || it.startMin > minute) } + .minOfOrNull { it.startMin } ?: continue + val label = "%02d:%02d".format(start / 60, start % 60) + return if (offset == 0) "Reprise à $label." else "Reprise à $label (dans $offset j)." + } + return "" + } + companion object { val NONE = Rules() diff --git a/app/src/main/java/com/jttools/datacontrol/log/AppLog.kt b/app/src/main/java/com/jttools/datacontrol/log/AppLog.kt index 9983683..4e81a5d 100644 --- a/app/src/main/java/com/jttools/datacontrol/log/AppLog.kt +++ b/app/src/main/java/com/jttools/datacontrol/log/AppLog.kt @@ -2,9 +2,9 @@ package com.jttools.datacontrol.log import android.content.Context import android.util.Log +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map import java.io.File import java.text.SimpleDateFormat import java.util.Date @@ -31,8 +31,9 @@ object AppLog { private val io = Executors.newSingleThreadExecutor() private val fmt = SimpleDateFormat("MM-dd HH:mm:ss", Locale.FRANCE) private val lines = ArrayDeque() - private val _entries = MutableStateFlow>(emptyList()) - val entries: StateFlow> = _entries.asStateFlow() + /** Incrémenté à chaque changement : la liste n'est reconstruite que si l'onglet Journal l'observe. */ + private val version = MutableStateFlow(0L) + val entries: Flow> = version.map { synchronized(lines) { lines.map { it.text } } } @Volatile private var file: File? = null private var prunedSinceCompact = 0 @@ -65,8 +66,9 @@ object AppLog { private fun add(level: String, tag: String, message: String) { Log.println(when (level) { "E" -> Log.ERROR; "W" -> Log.WARN; else -> Log.INFO }, "DataControl/$tag", message) val now = System.currentTimeMillis() - val entry = Entry(now, "${fmt.format(Date(now))} $level $tag: $message") synchronized(lines) { + // SimpleDateFormat n'est pas sûr entre threads : formaté sous le verrou. + val entry = Entry(now, "${fmt.format(Date(now))} $level $tag: $message") lines.addLast(entry) prune(now) publish() @@ -87,7 +89,7 @@ object AppLog { } private fun publish() { - _entries.value = lines.map { it.text } + version.value++ } /** Réécrit le fichier avec les seules entrées conservées. À appeler sous verrou. */ diff --git a/app/src/main/java/com/jttools/datacontrol/remote/DeviceReporter.kt b/app/src/main/java/com/jttools/datacontrol/remote/DeviceReporter.kt index 2323b95..452f4cb 100644 --- a/app/src/main/java/com/jttools/datacontrol/remote/DeviceReporter.kt +++ b/app/src/main/java/com/jttools/datacontrol/remote/DeviceReporter.kt @@ -22,6 +22,7 @@ import com.jttools.datacontrol.service.FirewallVpnService import com.jttools.datacontrol.service.MonitorStateHolder import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.isActive @@ -51,6 +52,7 @@ class DeviceReporter @Inject constructor( private var locationRegistered = false private var lastGuardEnabled: Boolean? = null private var lastUsageSentTotal = -1L + private var appsPushJob: Job? = null fun start(scope: CoroutineScope) { if (started) return @@ -67,6 +69,27 @@ class DeviceReporter @Inject constructor( context, batteryReceiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED), ContextCompat.RECEIVER_NOT_EXPORTED ) + // Application installée ou désinstallée : le parent doit voir la liste à jour pour choisir. + val packageReceiver = object : BroadcastReceiver() { + override fun onReceive(c: Context?, intent: Intent?) { + if (intent?.getBooleanExtra(Intent.EXTRA_REPLACING, false) == true) return // simple mise à jour + appsPushJob?.cancel() + appsPushJob = scope.launch { + delay(APPS_PUSH_DEBOUNCE_MS) // regroupe les installations en rafale + if (settingsRepository.settingsFlow.first().isPaired) remote.pushApps() + } + } + } + ContextCompat.registerReceiver( + context, packageReceiver, + IntentFilter().apply { + addAction(Intent.ACTION_PACKAGE_ADDED) + addAction(Intent.ACTION_PACKAGE_REMOVED) + addDataScheme("package") + }, + ContextCompat.RECEIVER_NOT_EXPORTED + ) + scope.launch { while (isActive) { val s = settingsRepository.settingsFlow.first() @@ -113,6 +136,9 @@ class DeviceReporter @Inject constructor( "tunnelActive" to FirewallVpnService.tunnelActive, "adminActive" to AdminController.isActive(context), "guardEnabled" to guardEnabled, + "autoTime" to (android.provider.Settings.Global.getInt( + context.contentResolver, android.provider.Settings.Global.AUTO_TIME, 1 + ) == 1), "rulesActive" to !guardEngine.rules.isEmpty, "consumedMo" to snap.consumedMo, "planLimitMo" to s.planLimitMo, @@ -192,5 +218,6 @@ class DeviceReporter @Inject constructor( const val LOCATION_INTERVAL_MS = 5 * 60_000L const val LOCATION_MIN_DISTANCE_M = 50f const val LOCATION_MIN_SEND_MS = 60_000L + const val APPS_PUSH_DEBOUNCE_MS = 5_000L } } diff --git a/app/src/main/java/com/jttools/datacontrol/remote/FirebaseConfig.kt b/app/src/main/java/com/jttools/datacontrol/remote/FirebaseConfig.kt index 9218fda..4798b7d 100644 --- a/app/src/main/java/com/jttools/datacontrol/remote/FirebaseConfig.kt +++ b/app/src/main/java/com/jttools/datacontrol/remote/FirebaseConfig.kt @@ -1,6 +1,5 @@ package com.jttools.datacontrol.remote -import android.util.Base64 import org.json.JSONObject /** Identifiants d'un projet Firebase (ceux du bloc `firebaseConfig` de la console). */ @@ -14,7 +13,7 @@ data class FirebaseConfig(val apiKey: String, val appId: String, val projectId: val text = input.trim() if (text.startsWith("fc1:")) { return runCatching { - val json = JSONObject(String(Base64.decode(text.removePrefix("fc1:"), Base64.DEFAULT), Charsets.UTF_8)) + val json = JSONObject(String(java.util.Base64.getMimeDecoder().decode(text.removePrefix("fc1:")), Charsets.UTF_8)) build(json.optString("apiKey"), json.optString("appId"), json.optString("projectId")) }.getOrNull() } diff --git a/app/src/main/java/com/jttools/datacontrol/remote/RemoteSync.kt b/app/src/main/java/com/jttools/datacontrol/remote/RemoteSync.kt index 754ce68..d3697ca 100644 --- a/app/src/main/java/com/jttools/datacontrol/remote/RemoteSync.kt +++ b/app/src/main/java/com/jttools/datacontrol/remote/RemoteSync.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withTimeoutOrNull import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -75,6 +76,10 @@ class RemoteSync @Inject constructor( "pairedAt" to System.currentTimeMillis() ) ).await() + // Code à usage unique : supprimé une fois consommé (sans effet si les règles Firestore déployées + // ne l'autorisent pas encore — il expire de toute façon au bout de 10 min). + runCatching { db.collection("pairings").document(clean).delete().await() } + .onFailure { AppLog.w("FamilyControl", "Code d'appairage non supprimé : ${it.message}") } settingsRepository.setPairing(familyId, deviceId) } @@ -168,7 +173,8 @@ class RemoteSync @Inject constructor( ) } - private suspend fun pushApps() { + /** Envoie la liste des applications installées (au parent de choisir lesquelles autoriser). */ + suspend fun pushApps() { val s = settingsRepository.settingsFlow.first() val ref = deviceDoc(s) ?: return val apps = installedApps.listInstalledApps(withIcons = false).map { @@ -206,10 +212,15 @@ class RemoteSync @Inject constructor( suspend fun pushEvent(type: String, detail: String) { val s = settingsRepository.settingsFlow.first() val ref = deviceDoc(s) ?: return + if (gateway.ensureSignedIn() == null) return + // Attente bornée de l'accusé du serveur : appelé depuis un récepteur (goAsync), le processus + // peut être arrêté juste après ; hors ligne, l'écriture reste en file dans le SDK. runCatching { - ref.collection("events").add( - mapOf("type" to type, "detail" to detail, "at" to System.currentTimeMillis()) - ) + withTimeoutOrNull(EVENT_TIMEOUT_MS) { + ref.collection("events").add( + mapOf("type" to type, "detail" to detail, "at" to System.currentTimeMillis()) + ).await() + } } } @@ -219,5 +230,8 @@ class RemoteSync @Inject constructor( ref.set(data, SetOptions.merge()) } - companion object { private const val TAG = "RemoteSync" } + companion object { + private const val TAG = "RemoteSync" + private const val EVENT_TIMEOUT_MS = 8_000L + } } diff --git a/app/src/main/java/com/jttools/datacontrol/service/DataMonitorRestartWorker.kt b/app/src/main/java/com/jttools/datacontrol/service/DataMonitorRestartWorker.kt index 20c19b8..a5e85c6 100644 --- a/app/src/main/java/com/jttools/datacontrol/service/DataMonitorRestartWorker.kt +++ b/app/src/main/java/com/jttools/datacontrol/service/DataMonitorRestartWorker.kt @@ -1,9 +1,17 @@ package com.jttools.datacontrol.service +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat import androidx.work.CoroutineWorker import androidx.work.WorkerParameters +import com.jttools.datacontrol.MainActivity +import com.jttools.datacontrol.R import com.jttools.datacontrol.data.SettingsRepository +import com.jttools.datacontrol.remote.RemoteSync import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors @@ -16,6 +24,10 @@ import kotlinx.coroutines.flow.first * (voir [BootReceiver]) ne suffisent pas toujours sur les ROM qui tuent agressivement les apps en * arrière-plan (ex: ColorOS) sans exemption d'optimisation batterie. Démarrer un service déjà en * cours d'exécution est sans effet, cet appel est donc sûr à répéter à chaque déclenchement. + * + * Sur Android 12+, ce démarrage depuis l'arrière-plan est refusé si l'application n'est pas exemptée + * d'optimisation de la batterie : on affiche alors une notification pour rouvrir l'application, et + * le parent est prévenu. */ class DataMonitorRestartWorker( context: Context, @@ -26,17 +38,47 @@ class DataMonitorRestartWorker( @InstallIn(SingletonComponent::class) interface RestartWorkerEntryPoint { fun settingsRepository(): SettingsRepository + fun remoteSync(): RemoteSync } override suspend fun doWork(): Result { val appContext = applicationContext val entryPoint = EntryPointAccessors.fromApplication(appContext, RestartWorkerEntryPoint::class.java) val enabled = entryPoint.settingsRepository().settingsFlow.first().controlEnabled - if (enabled) DataMonitorService.start(appContext) + if (enabled && !DataMonitorService.start(appContext)) { + notifyRestartNeeded(appContext) + entryPoint.remoteSync().pushEvent( + "service_stopped", + "Surveillance arrêtée par le système et non relançable en arrière-plan (optimisation de la batterie active)" + ) + } return Result.success() } + private fun notifyRestartNeeded(context: Context) { + val nm = context.getSystemService(NotificationManager::class.java) + nm.createNotificationChannel( + NotificationChannel(ALERT_CHANNEL_ID, "Alertes DataControl", NotificationManager.IMPORTANCE_HIGH) + ) + val open = PendingIntent.getActivity( + context, 0, Intent(context, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE + ) + nm.notify( + ALERT_NOTIF_ID, + NotificationCompat.Builder(context, ALERT_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle("DataControl est arrêté") + .setContentText("Touchez pour relancer la surveillance.") + .setContentIntent(open) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .build() + ) + } + companion object { const val WORK_NAME = "data_monitor_restart_watchdog" + private const val ALERT_CHANNEL_ID = "datacontrol_alerts" + private const val ALERT_NOTIF_ID = 3001 } } diff --git a/app/src/main/java/com/jttools/datacontrol/service/DataMonitorService.kt b/app/src/main/java/com/jttools/datacontrol/service/DataMonitorService.kt index 0212880..4ec0ae3 100644 --- a/app/src/main/java/com/jttools/datacontrol/service/DataMonitorService.kt +++ b/app/src/main/java/com/jttools/datacontrol/service/DataMonitorService.kt @@ -9,7 +9,7 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.graphics.Color -import android.os.Build +import android.os.SystemClock import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleService @@ -55,7 +55,14 @@ class DataMonitorService : LifecycleService() { private var latestSettings: AppSettings = AppSettings() private var latestTransport: NetworkTransport = NetworkTransport.NONE - private var vpnRevoked = false + /** + * Instant (temps monotone) du dernier retrait du VPN. Le contrôle reste ACTIVÉ : retirer le VPN + * ne doit pas suffire à lever la protection sans mot de passe. On laisse un délai avant de + * remonter le tunnel, pour ne pas se battre en boucle avec un autre VPN. + */ + private var vpnRevokedAt: Long? = null + private val vpnRevoked: Boolean + get() = vpnRevokedAt?.let { SystemClock.elapsedRealtime() - it < VPN_RETRY_AFTER_REVOKE_MS } ?: false private var lastAppliedMode: VpnMode? = null private var lastAppliedWhitelist: Set = emptySet() @@ -70,11 +77,12 @@ class DataMonitorService : LifecycleService() { private val vpnRevokedReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { - vpnRevoked = true + vpnRevokedAt = SystemClock.elapsedRealtime() + AppLog.w("Pare-feu", "VPN retiré : contrôle maintenu, nouvelle tentative dans ${VPN_RETRY_AFTER_REVOKE_MS / 1000} s") lifecycleScope.launch { - settingsRepository.setControlEnabled(false) - remoteSync.pushEvent("vpn_revoked", "Autorisation VPN retirée : le filtrage est désactivé") + remoteSync.pushEvent("vpn_revoked", "Autorisation VPN retirée : données mobiles non filtrées tant qu'elle n'est pas rétablie") } + tick() } } @@ -117,7 +125,7 @@ class DataMonitorService : LifecycleService() { } } lastControlEnabled = it.controlEnabled - if (!it.controlEnabled) vpnRevoked = false + if (!it.controlEnabled) vpnRevokedAt = null tick() } } @@ -179,7 +187,7 @@ class DataMonitorService : LifecycleService() { when { latestTransport == NetworkTransport.WIFI -> { - if (settings.wifiFilterEnabled && FirewallVpnService.isPrepared(this)) { + if (settings.wifiFilterEnabled && !vpnRevoked && FirewallVpnService.isPrepared(this)) { applyVpnMode(VpnMode.FILTER, settings.whitelistedPackagesWifi) } else { applyVpnMode(VpnMode.STOPPED, emptySet()) @@ -332,14 +340,12 @@ class DataMonitorService : LifecycleService() { private fun buildNotification(snapshot: MonitorSnapshot): Notification { val nm = getSystemService(NotificationManager::class.java) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - getString(R.string.notification_channel_name), - NotificationManager.IMPORTANCE_LOW - ).apply { description = getString(R.string.notification_channel_description) } - nm.createNotificationChannel(channel) - } + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.notification_channel_name), + NotificationManager.IMPORTANCE_LOW + ).apply { description = getString(R.string.notification_channel_description) } + nm.createNotificationChannel(channel) val (emoji, stateLabel, color) = when { snapshot.firewallFault -> Triple("⚠️", getString(R.string.dashboard_state_warning), Color.parseColor("#C62828")) @@ -353,7 +359,7 @@ class DataMonitorService : LifecycleService() { val percentText = if (snapshot.planLimitMo > 0) { "${(snapshot.consumedMo * 100 / snapshot.planLimitMo)}%" } else "—" - val priceText = String.format("%.2f", snapshot.estimatedTotalPriceEuro) + val priceText = String.format(java.util.Locale.FRENCH, "%.2f", snapshot.estimatedTotalPriceEuro) val warn = if (snapshot.firewallFault) "⚠️ PARE-FEU INACTIF — données non filtrées · " else "" val usageText = "$warn$emoji $stateLabel · ${snapshot.consumedMo}/${snapshot.planLimitMo} Mo ($percentText)" @@ -396,10 +402,19 @@ class DataMonitorService : LifecycleService() { private const val TICK_INTERVAL_MS = 20_000L private const val PULSE_DURATION_MS = 30_000L private const val TUNNEL_RETRY_DELAY_MS = 5_000L + private const val VPN_RETRY_AFTER_REVOKE_MS = 30_000L private const val WARNING_THRESHOLD = 0.8f - fun start(context: Context) { + /** + * Démarre le service. Renvoie faux si Android refuse (Android 12+ interdit de démarrer un service + * de premier plan depuis l'arrière-plan sans exemption d'optimisation de la batterie). + */ + fun start(context: Context): Boolean = try { ContextCompat.startForegroundService(context, Intent(context, DataMonitorService::class.java)) + true + } catch (e: Exception) { + AppLog.e("Service", "Démarrage de DataMonitorService refusé par le système", e) + false } fun stop(context: Context) { diff --git a/app/src/main/java/com/jttools/datacontrol/service/FirewallVpnService.kt b/app/src/main/java/com/jttools/datacontrol/service/FirewallVpnService.kt index f54fe9c..8c8ef13 100644 --- a/app/src/main/java/com/jttools/datacontrol/service/FirewallVpnService.kt +++ b/app/src/main/java/com/jttools/datacontrol/service/FirewallVpnService.kt @@ -8,7 +8,6 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.VpnService -import android.os.Build import android.os.ParcelFileDescriptor import android.util.Log import androidx.core.app.NotificationCompat @@ -143,14 +142,12 @@ class FirewallVpnService : VpnService() { private fun buildNotification(): Notification { val nm = getSystemService(NotificationManager::class.java) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - getString(R.string.notification_channel_name), - NotificationManager.IMPORTANCE_MIN - ) - nm.createNotificationChannel(channel) - } + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.notification_channel_name), + NotificationManager.IMPORTANCE_MIN + ) + nm.createNotificationChannel(channel) val openApp = PendingIntent.getActivity( this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE diff --git a/app/src/main/java/com/jttools/datacontrol/ui/components/PasswordComponents.kt b/app/src/main/java/com/jttools/datacontrol/ui/components/PasswordComponents.kt index d3d75d7..8a67263 100644 --- a/app/src/main/java/com/jttools/datacontrol/ui/components/PasswordComponents.kt +++ b/app/src/main/java/com/jttools/datacontrol/ui/components/PasswordComponents.kt @@ -35,14 +35,18 @@ import kotlinx.coroutines.launch /** * Verrouille [content] derrière une saisie de mot de passe tant que [passwordSet] est vrai. + * [passwordSet] null = réglages pas encore chargés : rien n'est affiché (sinon l'écran protégé + * apparaîtrait le temps d'une image avant le verrou). + * [verify] renvoie null si le mot de passe est bon, sinon le message d'erreur. * Le déverrouillage n'est valable que pour la composition courante (redemandé à chaque visite de l'écran). */ @Composable fun PasswordGate( - passwordSet: Boolean, - verify: suspend (String) -> Boolean, + passwordSet: Boolean?, + verify: suspend (String) -> String?, content: @Composable () -> Unit ) { + if (passwordSet == null) return var unlocked by remember(passwordSet) { mutableStateOf(!passwordSet) } if (unlocked) { content() @@ -50,7 +54,8 @@ fun PasswordGate( } var password by remember { mutableStateOf("") } - var error by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() Column( @@ -64,31 +69,27 @@ fun PasswordGate( Spacer(Modifier.height(16.dp)) OutlinedTextField( value = password, - onValueChange = { password = it; error = false }, + onValueChange = { password = it; error = null }, label = { Text(stringResource(R.string.password_field_label)) }, visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - isError = error, + isError = error != null, singleLine = true, modifier = Modifier.fillMaxWidth() ) - if (error) { + error?.let { Spacer(Modifier.height(4.dp)) - Text( - stringResource(R.string.password_incorrect), - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall - ) + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } Spacer(Modifier.height(16.dp)) Button( + enabled = !busy, onClick = { + busy = true scope.launch { - if (verify(password)) { - unlocked = true - } else { - error = true - } + val result = verify(password) + busy = false + if (result == null) unlocked = true else error = result } }, modifier = Modifier.fillMaxWidth() @@ -100,7 +101,7 @@ fun PasswordGate( @Composable fun PasswordPromptDialog( title: String, - error: Boolean, + error: String?, onDismiss: () -> Unit, onConfirm: (String) -> Unit ) { @@ -117,17 +118,13 @@ fun PasswordPromptDialog( label = { Text(stringResource(R.string.password_field_label)) }, visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - isError = error, + isError = error != null, singleLine = true, modifier = Modifier.fillMaxWidth() ) - if (error) { + error?.let { Spacer(Modifier.height(4.dp)) - Text( - stringResource(R.string.password_incorrect), - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall - ) + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } } }, diff --git a/app/src/main/java/com/jttools/datacontrol/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/jttools/datacontrol/ui/dashboard/DashboardScreen.kt index aaa2c81..a84f148 100644 --- a/app/src/main/java/com/jttools/datacontrol/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/jttools/datacontrol/ui/dashboard/DashboardScreen.kt @@ -56,9 +56,10 @@ fun DashboardScreen(systemActions: SystemActions) { val viewModel: DashboardViewModel = hiltViewModel() val settings by viewModel.settings.collectAsStateWithLifecycle() val snapshot by viewModel.snapshot.collectAsStateWithLifecycle() + val passwordSet by viewModel.passwordSet.collectAsStateWithLifecycle() var showResetDialog by remember { mutableStateOf(false) } var showDisablePasswordDialog by remember { mutableStateOf(false) } - var disablePasswordError by remember { mutableStateOf(false) } + var disablePasswordError by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() val stateColor = when { @@ -170,9 +171,9 @@ fun DashboardScreen(systemActions: SystemActions) { } } } - } else { - if (settings.hasPassword) { - disablePasswordError = false + } else if (passwordSet != null) { // null : réglages pas encore chargés + if (passwordSet == true) { + disablePasswordError = null showDisablePasswordDialog = true } else { viewModel.setControlEnabled(false) @@ -243,11 +244,12 @@ fun DashboardScreen(systemActions: SystemActions) { onDismiss = { showDisablePasswordDialog = false }, onConfirm = { password -> scope.launch { - if (viewModel.verifyPassword(password)) { + val error = viewModel.checkPassword(password) + if (error == null) { viewModel.setControlEnabled(false) showDisablePasswordDialog = false } else { - disablePasswordError = true + disablePasswordError = error } } } diff --git a/app/src/main/java/com/jttools/datacontrol/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/com/jttools/datacontrol/ui/dashboard/DashboardViewModel.kt index 77c9a7d..3886c91 100644 --- a/app/src/main/java/com/jttools/datacontrol/ui/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/jttools/datacontrol/ui/dashboard/DashboardViewModel.kt @@ -10,6 +10,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map import javax.inject.Inject @HiltViewModel @@ -33,5 +35,10 @@ class DashboardViewModel @Inject constructor( viewModelScope.launch { settingsRepository.resetPlan(usageRepository.totalMobileBytes()) } } - suspend fun verifyPassword(password: String): Boolean = settingsRepository.verifyPassword(password) + /** null tant que les réglages ne sont pas chargés, sinon vrai si un mot de passe est défini. */ + val passwordSet: StateFlow = settingsRepository.settingsFlow.map { it.hasPassword } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + /** null si le mot de passe est bon, sinon le message d'erreur. */ + suspend fun checkPassword(password: String): String? = settingsRepository.checkPassword(password) } diff --git a/app/src/main/java/com/jttools/datacontrol/ui/family/FamilySection.kt b/app/src/main/java/com/jttools/datacontrol/ui/family/FamilySection.kt index 6ac6e5c..e23c72c 100644 --- a/app/src/main/java/com/jttools/datacontrol/ui/family/FamilySection.kt +++ b/app/src/main/java/com/jttools/datacontrol/ui/family/FamilySection.kt @@ -58,7 +58,7 @@ fun FamilySection(systemActions: SystemActions) { // Action sensible en attente de confirmation par mot de passe : "admin" ou "unpair". var pendingAction by remember { mutableStateOf(null) } var pwd by remember { mutableStateOf("") } - var pwdError by remember { mutableStateOf(false) } + var pwdError by remember { mutableStateOf(null) } // Au retour des écrans système (accessibilité, permissions, admin), on relit l'état réel. LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { @@ -69,7 +69,7 @@ fun FamilySection(systemActions: SystemActions) { } fun runProtected(action: String, block: () -> Unit) { - if (settings.hasPassword) { pendingAction = action; pwd = ""; pwdError = false } else block() + if (settings.hasPassword) { pendingAction = action; pwd = ""; pwdError = null } else block() } fun execute(action: String) { @@ -227,22 +227,23 @@ fun FamilySection(systemActions: SystemActions) { text = { Column { OutlinedTextField( - value = pwd, onValueChange = { pwd = it; pwdError = false }, + value = pwd, onValueChange = { pwd = it; pwdError = null }, label = { Text(stringResource(R.string.password_field_label)) }, visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - isError = pwdError, singleLine = true, modifier = Modifier.fillMaxWidth() + isError = pwdError != null, singleLine = true, modifier = Modifier.fillMaxWidth() ) - if (pwdError) Text( - stringResource(R.string.password_incorrect), + pwdError?.let { Text( + it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall - ) + ) } } }, confirmButton = { Button(onClick = { scope.launch { - if (vm.verifyPassword(pwd)) { pendingAction = null; execute(action) } else pwdError = true + val err = vm.checkPassword(pwd) + if (err == null) { pendingAction = null; execute(action) } else pwdError = err } }) { Text(stringResource(R.string.common_confirm)) } }, diff --git a/app/src/main/java/com/jttools/datacontrol/ui/family/FamilyViewModel.kt b/app/src/main/java/com/jttools/datacontrol/ui/family/FamilyViewModel.kt index 9d17547..03272ef 100644 --- a/app/src/main/java/com/jttools/datacontrol/ui/family/FamilyViewModel.kt +++ b/app/src/main/java/com/jttools/datacontrol/ui/family/FamilyViewModel.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.map import javax.inject.Inject data class PairingUiState(val busy: Boolean = false, val error: String? = null) @@ -54,5 +55,10 @@ class FamilyViewModel @Inject constructor( viewModelScope.launch { remoteSync.unpair() } } - suspend fun verifyPassword(password: String): Boolean = settingsRepository.verifyPassword(password) + /** null tant que les réglages ne sont pas chargés, sinon vrai si un mot de passe est défini. */ + val passwordSet: StateFlow = settingsRepository.settingsFlow.map { it.hasPassword } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + /** null si le mot de passe est bon, sinon le message d'erreur. */ + suspend fun checkPassword(password: String): String? = settingsRepository.checkPassword(password) } diff --git a/app/src/main/java/com/jttools/datacontrol/ui/journal/JournalScreen.kt b/app/src/main/java/com/jttools/datacontrol/ui/journal/JournalScreen.kt index 292debb..4c926ee 100644 --- a/app/src/main/java/com/jttools/datacontrol/ui/journal/JournalScreen.kt +++ b/app/src/main/java/com/jttools/datacontrol/ui/journal/JournalScreen.kt @@ -33,12 +33,21 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.jttools.datacontrol.log.AppLog import com.jttools.datacontrol.service.FirewallVpnService +import androidx.compose.runtime.rememberCoroutineScope +import androidx.hilt.navigation.compose.hiltViewModel +import com.jttools.datacontrol.ui.components.PasswordPromptDialog import kotlinx.coroutines.delay +import kotlinx.coroutines.launch @Composable fun JournalScreen() { val context = LocalContext.current - val entries by AppLog.entries.collectAsStateWithLifecycle() + val entries by AppLog.entries.collectAsStateWithLifecycle(initialValue = emptyList()) + val viewModel: JournalViewModel = hiltViewModel() + val passwordSet by viewModel.passwordSet.collectAsStateWithLifecycle() + var askPassword by remember { mutableStateOf(false) } + var passwordError by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() // État en direct du pare-feu, rafraîchi chaque seconde : première chose à regarder si le VPN n'apparaît pas. var live by remember { mutableStateOf(liveStatus(context)) } @@ -61,7 +70,12 @@ fun JournalScreen() { .putExtra(Intent.EXTRA_TEXT, live + "\n\n" + AppLog.asText()) context.startActivity(Intent.createChooser(send, "Partager le journal").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }) { Text("Partager") } - OutlinedButton(onClick = { AppLog.clear() }) { Text("Effacer") } + OutlinedButton( + enabled = passwordSet != null, + onClick = { + if (passwordSet == true) { passwordError = null; askPassword = true } else AppLog.clear() + } + ) { Text("Effacer") } } LazyColumn(Modifier.fillMaxWidth().weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { items(entries.asReversed()) { line -> @@ -74,8 +88,28 @@ fun JournalScreen() { } } } + + if (askPassword) { + ClearPasswordDialog( + error = passwordError, + onDismiss = { askPassword = false }, + onConfirm = { password -> + scope.launch { + val error = viewModel.checkPassword(password) + if (error == null) { AppLog.clear(); askPassword = false } else passwordError = error + } + } + ) + } } +@Composable +private fun ClearPasswordDialog( + error: String?, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit +) = PasswordPromptDialog(title = "Effacer le journal", error = error, onDismiss = onDismiss, onConfirm = onConfirm) + private fun liveStatus(context: Context): String = buildString { appendLine("VPN autorisé (prepare) : ${FirewallVpnService.isPrepared(context)}") appendLine("Tunnel VPN monté : ${FirewallVpnService.tunnelActive}") diff --git a/app/src/main/java/com/jttools/datacontrol/ui/journal/JournalViewModel.kt b/app/src/main/java/com/jttools/datacontrol/ui/journal/JournalViewModel.kt new file mode 100644 index 0000000..369d245 --- /dev/null +++ b/app/src/main/java/com/jttools/datacontrol/ui/journal/JournalViewModel.kt @@ -0,0 +1,24 @@ +package com.jttools.datacontrol.ui.journal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.jttools.datacontrol.data.AppSettings +import com.jttools.datacontrol.data.SettingsRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +/** Effacer le journal fait disparaître la trace des usages : protégé par le mot de passe. */ +@HiltViewModel +class JournalViewModel @Inject constructor( + private val settingsRepository: SettingsRepository +) : ViewModel() { + + val passwordSet: StateFlow = settingsRepository.settingsFlow.map { it.hasPassword } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + suspend fun checkPassword(password: String): String? = settingsRepository.checkPassword(password) +} diff --git a/app/src/main/java/com/jttools/datacontrol/ui/settings/SettingsScreen.kt b/app/src/main/java/com/jttools/datacontrol/ui/settings/SettingsScreen.kt index d5e492f..0f2fec9 100644 --- a/app/src/main/java/com/jttools/datacontrol/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/jttools/datacontrol/ui/settings/SettingsScreen.kt @@ -53,8 +53,9 @@ import java.util.Locale fun SettingsScreen(systemActions: SystemActions) { val viewModel: SettingsViewModel = hiltViewModel() val settings by viewModel.settings.collectAsStateWithLifecycle() + val passwordSet by viewModel.passwordSet.collectAsStateWithLifecycle() - PasswordGate(passwordSet = settings.hasPassword, verify = { viewModel.verifyPassword(it) }) { + PasswordGate(passwordSet = passwordSet, verify = { viewModel.checkPassword(it) }) { SettingsContent(systemActions, viewModel, settings) } } diff --git a/app/src/main/java/com/jttools/datacontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/jttools/datacontrol/ui/settings/SettingsViewModel.kt index ebbf422..5d4eea2 100644 --- a/app/src/main/java/com/jttools/datacontrol/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/jttools/datacontrol/ui/settings/SettingsViewModel.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.time.LocalDate +import kotlinx.coroutines.flow.StateFlow import javax.inject.Inject @HiltViewModel @@ -55,5 +56,10 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { settingsRepository.setPassword(password) } } - suspend fun verifyPassword(password: String): Boolean = settingsRepository.verifyPassword(password) + /** null tant que les réglages ne sont pas chargés, sinon vrai si un mot de passe est défini. */ + val passwordSet: StateFlow = settingsRepository.settingsFlow.map { it.hasPassword } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + /** null si le mot de passe est bon, sinon le message d'erreur. */ + suspend fun checkPassword(password: String): String? = settingsRepository.checkPassword(password) } diff --git a/app/src/main/java/com/jttools/datacontrol/ui/whitelist/WhitelistScreen.kt b/app/src/main/java/com/jttools/datacontrol/ui/whitelist/WhitelistScreen.kt index 5083930..39140ae 100644 --- a/app/src/main/java/com/jttools/datacontrol/ui/whitelist/WhitelistScreen.kt +++ b/app/src/main/java/com/jttools/datacontrol/ui/whitelist/WhitelistScreen.kt @@ -49,8 +49,9 @@ import com.jttools.datacontrol.ui.components.PasswordGate fun WhitelistScreen() { val viewModel: WhitelistViewModel = hiltViewModel() val settings by viewModel.settings.collectAsStateWithLifecycle() + val passwordSet by viewModel.passwordSet.collectAsStateWithLifecycle() - PasswordGate(passwordSet = settings.hasPassword, verify = { viewModel.verifyPassword(it) }) { + PasswordGate(passwordSet = passwordSet, verify = { viewModel.checkPassword(it) }) { WhitelistContent(viewModel, settings) } } diff --git a/app/src/main/java/com/jttools/datacontrol/ui/whitelist/WhitelistViewModel.kt b/app/src/main/java/com/jttools/datacontrol/ui/whitelist/WhitelistViewModel.kt index d19ba03..09d2dfd 100644 --- a/app/src/main/java/com/jttools/datacontrol/ui/whitelist/WhitelistViewModel.kt +++ b/app/src/main/java/com/jttools/datacontrol/ui/whitelist/WhitelistViewModel.kt @@ -12,6 +12,8 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map import javax.inject.Inject @HiltViewModel @@ -62,5 +64,10 @@ class WhitelistViewModel @Inject constructor( viewModelScope.launch { settingsRepository.setShowSystemApps(show) } } - suspend fun verifyPassword(password: String): Boolean = settingsRepository.verifyPassword(password) + /** null tant que les réglages ne sont pas chargés, sinon vrai si un mot de passe est défini. */ + val passwordSet: StateFlow = settingsRepository.settingsFlow.map { it.hasPassword } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + /** null si le mot de passe est bon, sinon le message d'erreur. */ + suspend fun checkPassword(password: String): String? = settingsRepository.checkPassword(password) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6fa6262..a51ad35 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -12,10 +12,8 @@ Tableau de bord Données consommées - Forfait Restant Dépassement - État des données Sous le forfait Proche du forfait Coupure totale en cours @@ -30,7 +28,6 @@ Réinitialiser le forfait maintenant Réinitialiser le forfait ? Le compteur de données consommées sera remis à zéro immédiatement. - Autorisations requises pour fonctionner correctement Contrôle désactivé Prochaine coupure Wi-Fi actif (pas de coupure) @@ -52,8 +49,6 @@ Intervalle de coupure (minutes) Une coupure totale (même pour la liste blanche) se déclenche cycliquement à cet intervalle, que le forfait soit dépassé ou non. Autorisations - Accès aux statistiques d\'utilisation - Nécessaire pour mesurer la consommation de données mobiles Pare-feu local (VPN) Nécessaire pour filtrer les applications sur les données mobiles Notifications @@ -64,7 +59,6 @@ Manquante Enregistrer Modifier - Réglages enregistrés Sécurité Mot de passe actif — protège la désactivation et les réglages Aucun mot de passe défini @@ -80,11 +74,9 @@ Zone protégée Mot de passe - Mot de passe incorrect Déverrouiller - À propos Version %1$s Mise à jour Rechercher une mise à jour @@ -116,8 +108,6 @@ DataControl - Mo - € Annuler Confirmer diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 5eab9ca..a074f29 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -1,4 +1,8 @@ + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index fa29768..7566878 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -1,6 +1,14 @@ + + + + + + + + diff --git a/app/src/test/java/com/jttools/datacontrol/data/PasswordHasherTest.kt b/app/src/test/java/com/jttools/datacontrol/data/PasswordHasherTest.kt new file mode 100644 index 0000000..682aab6 --- /dev/null +++ b/app/src/test/java/com/jttools/datacontrol/data/PasswordHasherTest.kt @@ -0,0 +1,38 @@ +package com.jttools.datacontrol.data + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.security.MessageDigest + +class PasswordHasherTest { + + @Test fun `bon et mauvais mot de passe`() { + val salt = PasswordHasher.newSalt() + val hash = PasswordHasher.hash("1234", salt) + assertTrue(hash.startsWith("pbkdf2:")) + assertTrue(PasswordHasher.verify("1234", salt, hash)) + assertFalse(PasswordHasher.verify("1235", salt, hash)) + assertFalse(PasswordHasher.needsUpgrade(hash)) + } + + @Test fun `le sel change le hachage`() { + val a = PasswordHasher.hash("1234", PasswordHasher.newSalt()) + val b = PasswordHasher.hash("1234", PasswordHasher.newSalt()) + assertTrue(a != b) + } + + @Test fun `ancien format SHA-256 toujours accepte et a convertir`() { + val salt = "00112233445566778899aabbccddeeff" + val md = MessageDigest.getInstance("SHA-256") + md.update(salt.toByteArray()) + val legacy = md.digest("secret".toByteArray()).joinToString("") { "%02x".format(it) } + assertTrue(PasswordHasher.verify("secret", salt, legacy)) + assertFalse(PasswordHasher.verify("autre", salt, legacy)) + assertTrue(PasswordHasher.needsUpgrade(legacy)) + } + + @Test fun `hachage illisible refuse`() { + assertFalse(PasswordHasher.verify("1234", PasswordHasher.newSalt(), "pbkdf2:abc:00")) + } +} diff --git a/app/src/test/java/com/jttools/datacontrol/data/UpdateManagerTest.kt b/app/src/test/java/com/jttools/datacontrol/data/UpdateManagerTest.kt new file mode 100644 index 0000000..8810ead --- /dev/null +++ b/app/src/test/java/com/jttools/datacontrol/data/UpdateManagerTest.kt @@ -0,0 +1,17 @@ +package com.jttools.datacontrol.data + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class UpdateManagerTest { + @Test fun `comparaison de versions`() { + assertTrue(UpdateManager.isNewer("0.1.9", "0.1.8")) + assertTrue(UpdateManager.isNewer("0.2.0", "0.1.10")) + assertTrue(UpdateManager.isNewer("0.1.10", "0.1.9")) // numérique, pas alphabétique + assertTrue(UpdateManager.isNewer("1.0", "0.9.9")) + assertFalse(UpdateManager.isNewer("0.1.8", "0.1.8")) + assertFalse(UpdateManager.isNewer("0.1.8", "0.1.8.0")) + assertFalse(UpdateManager.isNewer("0.1.7", "0.1.8")) + } +} diff --git a/app/src/test/java/com/jttools/datacontrol/guard/RulesTest.kt b/app/src/test/java/com/jttools/datacontrol/guard/RulesTest.kt new file mode 100644 index 0000000..eff3add --- /dev/null +++ b/app/src/test/java/com/jttools/datacontrol/guard/RulesTest.kt @@ -0,0 +1,64 @@ +package com.jttools.datacontrol.guard + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDateTime + +class RulesTest { + + // 2026-10-26 est un lundi. + private fun at(day: Int, h: Int, m: Int = 0) = LocalDateTime.of(2026, 10, 25, h, m).plusDays(day.toLong()) + + private val json = """ + {"schedule":{"enabled":true,"cutsMobile":false, + "windows":[{"days":[1,2,3,4,5],"start":480,"end":1200},{"days":[5],"start":1320,"end":120}]}, + "dailyLimitMin":90, + "appLimits":[{"pkg":"com.game","min":30}], + "blocked":["com.bad"], + "bonus":{"date":"2026-10-26","globalMin":15,"apps":[{"pkg":"com.game","min":10}]}} + """.trimIndent() + + @Test fun `lecture complete`() { + val r = Rules.parse(json) + assertTrue(r.scheduleEnabled) + assertFalse(r.scheduleCutsMobile) + assertEquals(2, r.windows.size) + assertEquals(90, r.dailyLimitMin) + assertEquals(listOf(AppLimit("com.game", 30)), r.appLimits) + assertEquals(setOf("com.bad"), r.blocked) + assertEquals("2026-10-26", r.bonusDate) + assertEquals(15, r.bonusGlobalMin) + assertFalse(r.isEmpty) + } + + @Test fun `json vide ou invalide donne aucune regle`() { + assertTrue(Rules.parse(null).isEmpty) + assertTrue(Rules.parse("").isEmpty) + assertTrue(Rules.parse("{pas du json").isEmpty) + } + + @Test fun `plage de journee`() { + val r = Rules.parse(json) + assertTrue(r.isOpenAt(at(1, 8))) // lundi 08:00 : début inclus + assertTrue(r.isOpenAt(at(1, 19, 59))) + assertFalse(r.isOpenAt(at(1, 20))) // fin exclue + assertFalse(r.isOpenAt(at(1, 7, 59))) + assertFalse(r.isOpenAt(at(6, 12))) // samedi : pas de plage de journée + } + + @Test fun `plage qui passe minuit deborde sur le lendemain`() { + val r = Rules.parse(json) + assertTrue(r.isOpenAt(at(5, 23))) // vendredi 23:00 + assertTrue(r.isOpenAt(at(6, 1, 30))) // samedi 01:30, suite de la plage du vendredi + assertFalse(r.isOpenAt(at(6, 2))) + assertFalse(r.isOpenAt(at(7, 1))) // dimanche 01:00 : pas de plage samedi soir + } + + @Test fun `prochaine ouverture`() { + val r = Rules.parse(json) + assertEquals("Reprise à 08:00.", r.nextOpeningText(at(1, 6))) + assertEquals("Reprise à 08:00 (dans 2 j).", r.nextOpeningText(at(6, 12))) + } +} diff --git a/app/src/test/java/com/jttools/datacontrol/remote/FirebaseConfigTest.kt b/app/src/test/java/com/jttools/datacontrol/remote/FirebaseConfigTest.kt new file mode 100644 index 0000000..e53f5e4 --- /dev/null +++ b/app/src/test/java/com/jttools/datacontrol/remote/FirebaseConfigTest.kt @@ -0,0 +1,43 @@ +package com.jttools.datacontrol.remote + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.util.Base64 + +class FirebaseConfigTest { + private val expected = FirebaseConfig("AIzaTest", "1:123:android:abc", "mon-projet") + + @Test fun `bloc firebaseConfig de la console`() { + val js = """ + const firebaseConfig = { + apiKey: "AIzaTest", + authDomain: "mon-projet.firebaseapp.com", + projectId: "mon-projet", + appId: "1:123:android:abc" + }; + """.trimIndent() + assertEquals(expected, FirebaseConfig.parse(js)) + } + + @Test fun `json`() { + assertEquals( + expected, + FirebaseConfig.parse("""{"apiKey":"AIzaTest","appId":"1:123:android:abc","projectId":"mon-projet"}""") + ) + } + + @Test fun `chaine fc1 de FamilyControl`() { + val b64 = Base64.getEncoder().encodeToString( + """{"apiKey":"AIzaTest","appId":"1:123:android:abc","projectId":"mon-projet"}""".toByteArray() + ) + assertEquals(expected, FirebaseConfig.parse("fc1:$b64")) + assertEquals(expected, FirebaseConfig.parse(" fc1:${b64.chunked(20).joinToString("\n")} ")) + } + + @Test fun `champ manquant ou illisible`() { + assertNull(FirebaseConfig.parse("""{"apiKey":"AIzaTest","projectId":"mon-projet"}""")) + assertNull(FirebaseConfig.parse("fc1:!!!")) + assertNull(FirebaseConfig.parse("")) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index be0baab..53cbe86 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ agp = "8.5.0" kotlin = "1.9.24" coreKtx = "1.13.1" junit = "4.13.2" +orgJson = "20240303" junitVersion = "1.2.1" espressoCore = "3.6.1" composeBom = "2024.09.00" @@ -22,6 +23,7 @@ coroutinesPlayServices = "1.8.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" } +org-json = { group = "org.json", name = "json", version.ref = "orgJson" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } diff --git a/keystore.properties.example b/keystore.properties.example new file mode 100644 index 0000000..abbeae6 --- /dev/null +++ b/keystore.properties.example @@ -0,0 +1,7 @@ +# Copier en keystore.properties (non versionné) et renseigner. +# La clé doit rester celle des versions déjà publiées, sinon les mises à jour échouent +# (empreinte SHA-256 : 79:88:7C:5D:8F:32:77:DA:66:99:B6:4A:68:D8:DF:C9:AC:65:02:8C:20:E9:1D:B1:FC:8C:09:AF:5C:E8:8B:42). +storeFile=/home//.android/datacontrol-release.keystore +storePassword= +keyAlias=datacontrol +keyPassword=