Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f382eeb4fe | ||
|
|
11d7ed1fa6 | ||
|
|
1d707f8785 | ||
|
|
e269e9932a |
No files matched your search
@@ -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
|
||||
@@ -1,5 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## v0.1.10 – 2026-10-25
|
||||
|
||||
- 🐛 **Corrigé** — Configuration Firebase (Réglages → Contrôle parental) : le bloc collé est accepté même si le copier-coller a remplacé les guillemets par des guillemets typographiques (“ ” « »), et le contenu du fichier google-services.json de l'application Android est aussi reconnu.
|
||||
|
||||
## v0.1.9 – 2026-10-25
|
||||
|
||||
- 🔒 **Sécurité** — Retirer l'autorisation VPN ou activer un autre VPN ne désactive plus le contrôle : la notification passe en rouge, le parent est alerté, le tunnel est remonté après 30 s si possible et l'autorisation est redemandée à l'ouverture.
|
||||
- 🔒 **Sécurité** — Changer à la main la date, l'heure ou le fuseau du téléphone ne contourne plus les plages horaires ni le temps d'écran : le changement est ignoré et signalé au parent.
|
||||
- 🔒 **Sécurité** — Mot de passe haché avec PBKDF2 ; après 3 essais ratés, attente croissante (30 s à 15 min). Les mots de passe existants restent valides.
|
||||
- 🔒 **Sécurité** — Effacer le journal demande le mot de passe ; les écrans protégés ne s'affichent plus un instant avant le verrou.
|
||||
- 🔒 **Sécurité** — Le code d'appairage ne sert qu'une fois ; réglages, appairage et journal ne sont plus inclus dans la sauvegarde Google.
|
||||
- 🐛 **Corrigé** — L'alerte « protection anti-désinstallation retirée » ne peut plus se perdre ; si le système refuse de relancer la surveillance, une notification invite à rouvrir l'application et le parent est prévenu.
|
||||
- 🐛 **Corrigé** — La liste des applications est renvoyée au parent à chaque installation ou désinstallation ; le bouton Retour de l'écran de blocage renvoie toujours à l'accueil.
|
||||
- 🛠️ **Modifié** — Journal plus léger (la liste n'est reconstruite que lorsque l'onglet est ouvert) et horodatage fiable.
|
||||
- ⚙️ **Technique** — Signature des releases via `keystore.properties` (même clé qu'avant) ; premiers tests unitaires ; README et page « À propos » alignés sur le comportement réel (la coupure cyclique tourne en permanence sur données mobiles).
|
||||
|
||||
## v0.1.8 – 2026-09-21
|
||||
|
||||
- Contrôle parental : la configuration Firebase se **saisit dans l'application** (Réglages → Contrôle parental → Configuration Firebase), en collant la chaîne « fc1:… » partagée par FamilyControl ou le bloc « firebaseConfig » de la console. Plus besoin de recompiler ni de fichier firebase.properties (qui reste possible comme valeur par défaut).
|
||||
|
||||
@@ -15,7 +15,7 @@ GITEA_REPO_NAME := DataControl
|
||||
GITEA_TOKEN ?= $(shell sed -n 's/^giteaToken=//p' gitea-token.properties 2>/dev/null | tr -d '[:space:]')
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
.PHONY: help build release install push version-bump changelog clean tag _check-token _check-apk publish deploy
|
||||
.PHONY: help build release install push version version-bump changelog clean tag _check-token _check-apk publish deploy
|
||||
|
||||
BOLD := \033[1m
|
||||
DIM := \033[2m
|
||||
@@ -34,6 +34,7 @@ help:
|
||||
@printf " $(GREEN)make install$(RESET) 📲 Installer l'APK debug sur l'appareil connecté\n"
|
||||
@printf "\n"
|
||||
@printf " $(BOLD)🚀 Versions & publication$(RESET)\n"
|
||||
@printf " $(GREEN)make version$(RESET) 🏷️ Afficher la version courante\n"
|
||||
@printf " $(GREEN)make version-bump$(RESET) 🔖 Incrémenter versionCode et le patch de versionName\n"
|
||||
@printf " $(GREEN)make changelog$(RESET) 📝 Ajouter au CHANGELOG les commits depuis le dernier tag\n"
|
||||
@printf " $(GREEN)make push$(RESET) 📤 Commit, tag et push de la version courante\n"
|
||||
@@ -131,6 +132,10 @@ publish: _check-apk _check-token tag
|
||||
deploy: release publish
|
||||
@printf "$(GREEN)🚢 Déploiement terminé.$(RESET)\n"
|
||||
|
||||
version:
|
||||
@printf "$(YELLOW)🏷️ Version courante...$(RESET)\n"
|
||||
@echo "v$(VERSION_NAME) (build $(VERSION_CODE))"
|
||||
|
||||
version-bump:
|
||||
@printf "$(YELLOW)🔖 Incrémentation de la version...$(RESET)\n"
|
||||
@NEW_CODE=$$(( $(VERSION_CODE) + 1 )); \
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# DataControl
|
||||
|
||||
🚀 v0.1.10 · 🤖 Android · 🟣 Kotlin · 🎨 Jetpack Compose · 🔥 Firebase · 🔒 pare-feu VPN local · 👨👩👧 contrôle parental · 🧪 tests · 📄 licence non déclarée
|
||||
|
||||
**DataControl** est une application Android qui surveille en arrière-plan la consommation de données mobiles et coupe automatiquement l'accès à Internet en cas de dépassement du forfait — sans jamais toucher au Wi-Fi.
|
||||
|
||||
> JT-Tools by Johnny — version courante et historique : voir [CHANGELOG.md](CHANGELOG.md) (aussi lisible dans l'onglet *À propos* de l'application).
|
||||
@@ -13,13 +15,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 +33,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 +47,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 +101,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
|
||||
|
||||
+24
-1
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
try {
|
||||
remoteSync.pushEvent("admin_disabled", "Protection anti-désinstallation désactivée")
|
||||
} finally {
|
||||
pending.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:<itérations>:<hex>`. 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() }
|
||||
}
|
||||
@@ -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<AppSettings> = 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<Long, String?> {
|
||||
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."
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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<Str
|
||||
*
|
||||
* L'appelant ([AppGuardService]) ne signale JAMAIS les applications exemptées (lanceur, téléphone,
|
||||
* clavier, DataControl) : elles ne comptent pas et ne sont jamais bloquées.
|
||||
*
|
||||
* Horloge de référence : changer la date ou l'heure du téléphone ne doit ni ouvrir une plage horaire
|
||||
* ni remettre à zéro le temps d'écran. Les sauts d'horloge sont repérés en comparant l'heure système
|
||||
* au temps monotone ([SystemClock.elapsedRealtime], non modifiable). Si l'heure automatique (réseau)
|
||||
* est désactivée, le saut est neutralisé (décalage mémorisé) et signalé au parent ; si elle est
|
||||
* activée, l'heure système fait foi. Même principe pour le fuseau horaire.
|
||||
*/
|
||||
@Singleton
|
||||
class GuardEngine @Inject constructor(private val settings: SettingsRepository) {
|
||||
class GuardEngine @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val settings: SettingsRepository,
|
||||
private val remoteSync: RemoteSync
|
||||
) {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val lock = Any()
|
||||
@@ -38,18 +59,51 @@ class GuardEngine @Inject constructor(private val settings: SettingsRepository)
|
||||
private set
|
||||
|
||||
private var started = false
|
||||
private var day = LocalDate.now().toString()
|
||||
private var totalMs = 0L
|
||||
private val appMs = HashMap<String, Long>()
|
||||
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<String, Long>()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<Int>, 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()
|
||||
|
||||
|
||||
@@ -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<Entry>()
|
||||
private val _entries = MutableStateFlow<List<String>>(emptyList())
|
||||
val entries: StateFlow<List<String>> = _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<List<String>> = 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. */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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). */
|
||||
@@ -8,19 +7,27 @@ data class FirebaseConfig(val apiKey: String, val appId: String, val projectId:
|
||||
companion object {
|
||||
/**
|
||||
* Lit ce que l'utilisateur colle : le bloc `firebaseConfig` de la console (JavaScript), un JSON,
|
||||
* ou une chaîne « fc1:… » partagée par FamilyControl. Renvoie null si apiKey, appId ou projectId manque.
|
||||
* le fichier google-services.json, ou une chaîne « fc1:… » partagée par FamilyControl. Renvoie null si apiKey, appId ou projectId manque.
|
||||
*/
|
||||
fun parse(input: String): FirebaseConfig? {
|
||||
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()
|
||||
}
|
||||
fun pick(name: String) =
|
||||
Regex("""["']?$name["']?\s*[:=]\s*["']([^"']+)["']""").find(text)?.groupValues?.get(1)
|
||||
return build(pick("apiKey"), pick("appId"), pick("projectId"))
|
||||
// Guillemets typographiques (souvent insérés par un copier-coller sur téléphone) → droits.
|
||||
val plain = text.replace(Regex("[“”„«»]"), "\"").replace(Regex("[‘’]"), "'")
|
||||
// Noms du bloc Web (apiKey…) ou du fichier google-services.json de l'application Android.
|
||||
fun pick(vararg names: String) = names.firstNotNullOfOrNull { name ->
|
||||
Regex("""["']?\b$name["']?\s*[:=]\s*["']\s*([^"'\s]+)\s*["']""").find(plain)?.groupValues?.get(1)
|
||||
}
|
||||
return build(
|
||||
pick("apiKey", "current_key"),
|
||||
pick("appId", "mobilesdk_app_id"),
|
||||
pick("projectId", "project_id")
|
||||
)
|
||||
}
|
||||
|
||||
private fun build(apiKey: String?, appId: String?, projectId: String?): FirebaseConfig? =
|
||||
|
||||
@@ -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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<String> = 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 (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) {
|
||||
|
||||
@@ -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 openApp = PendingIntent.getActivity(
|
||||
this, 0, Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
|
||||
@@ -22,7 +22,8 @@ object AboutContent {
|
||||
AboutEntry("Reprise automatique", "Le suivi redémarre après un redémarrage de l'appareil et est relancé périodiquement si le système l'a arrêté.")
|
||||
)),
|
||||
AboutSection("Protection", listOf(
|
||||
AboutEntry("Mot de passe de verrouillage", "Protège l'accès aux Réglages et la désactivation du contrôle."),
|
||||
AboutEntry("Mot de passe de verrouillage", "Protège les Réglages, la Liste blanche, l'effacement du journal et la désactivation du contrôle."),
|
||||
AboutEntry("Date et heure", "Changer à la main la date, l'heure ou le fuseau ne contourne pas les plages horaires ni le temps d'écran : le changement est ignoré et signalé au parent. Laissez l'heure automatique activée."),
|
||||
AboutEntry("Anti-désinstallation", "DataControl peut devenir administrateur de l'appareil : Android refuse alors de le désinstaller tant que la protection est active."),
|
||||
AboutEntry("Journal de diagnostic", "L'onglet Journal montre l'état du VPN, les décisions du pare-feu et, si le service d'accessibilité est activé, chaque application utilisée avec son verdict réseau et lancement. Conservé 15 jours, avec copie et partage."),
|
||||
AboutEntry("Mise à jour intégrée", "Recherche et installation de la dernière version publiée, depuis l'onglet Journal de cette page.")
|
||||
@@ -53,8 +54,8 @@ object AboutContent {
|
||||
|
||||
val troubleshooting = listOf(
|
||||
AboutEntry("Je ne vois pas l'icône VPN", "Le pare-feu ne se monte que sur données mobiles, contrôle activé. Sur Wi-Fi, il n'y en a jamais (sauf filtrage Wi-Fi activé). Consultez l'onglet Journal : il donne la raison exacte."),
|
||||
AboutEntry("« PARE-FEU INACTIF » dans la notification", "Les données mobiles ne sont pas filtrées : autorisation VPN retirée ou tunnel non monté. Réactivez le contrôle et lisez le Journal."),
|
||||
AboutEntry("Le contrôle s'est désactivé tout seul", "Un seul VPN peut être actif sous Android : si un autre VPN prend la main, DataControl est révoqué et se désactive."),
|
||||
AboutEntry("« PARE-FEU INACTIF » dans la notification", "Les données mobiles ne sont pas filtrées : autorisation VPN retirée ou tunnel non monté. Rouvrez DataControl pour redonner l'autorisation VPN, et lisez le Journal."),
|
||||
AboutEntry("Un autre VPN a pris la main", "Un seul VPN peut être actif sous Android. Le contrôle reste activé mais ne filtre plus : la notification passe en rouge, le parent est alerté et DataControl retente toutes les 30 s ; à l'ouverture, l'autorisation VPN est redemandée."),
|
||||
AboutEntry("Le service d'accessibilité est grisé (Android 13+)", "Ouvrez Infos sur l'application → ⋮ → « Autoriser les paramètres restreints », puis réessayez.")
|
||||
)
|
||||
|
||||
@@ -84,11 +85,11 @@ object AboutContent {
|
||||
AboutEntry("DataControl jamais bloqué", "L'application est toujours exclue du tunnel : la liaison avec le parent ne peut pas être coupée par le pare-feu.")
|
||||
)),
|
||||
AboutSection("Accès à l'application", listOf(
|
||||
AboutEntry("Mot de passe", "Haché en SHA-256 avec un sel aléatoire de 16 octets (SecureRandom), jamais stocké en clair. Limite : un hachage simple, sans étirement de type bcrypt ou Argon2."),
|
||||
AboutEntry("Mot de passe", "Haché avec PBKDF2-HMAC-SHA256 (100 000 itérations) et un sel aléatoire de 16 octets, jamais stocké en clair. Après 3 essais ratés, attente croissante de 30 s à 15 min."),
|
||||
AboutEntry("Anti-désinstallation", "Administrateur d'appareil. Limite : la protection peut être retirée depuis les Réglages Android ; si l'appareil est associé, le parent en est alerté. La demande de mot de passe ne s'applique qu'au retrait fait depuis DataControl.")
|
||||
)),
|
||||
AboutSection("Liaison FamilyControl", listOf(
|
||||
AboutEntry("Appairage", "Code aléatoire à durée limitée (10 minutes) créé côté parent. Connexion anonyme Firebase côté appareil, compte e-mail côté parent."),
|
||||
AboutEntry("Appairage", "Code aléatoire à usage unique et à durée limitée (10 minutes) créé côté parent. Connexion anonyme Firebase côté appareil, compte e-mail côté parent."),
|
||||
AboutEntry("Règles Firestore", "Fournies avec FamilyControl (firebase/firestore.rules) : l'appareil n'écrit que dans son propre espace et ne peut pas modifier les consignes du parent."),
|
||||
AboutEntry("Service d'accessibilité", "Détecte uniquement l'application au premier plan : la lecture du contenu des écrans est désactivée (canRetrieveWindowContent = false)."),
|
||||
AboutEntry("Applications exemptées", "Lanceur, téléphone, clavier et DataControl ne sont jamais bloqués, pour que l'appareil reste utilisable.")
|
||||
|
||||
@@ -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<String?>(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)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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<String?>(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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Boolean?> = settingsRepository.settingsFlow.map<AppSettings, Boolean?> { 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)
|
||||
}
|
||||
@@ -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<String?>(null) }
|
||||
var pwd by remember { mutableStateOf("") }
|
||||
var pwdError by remember { mutableStateOf(false) }
|
||||
var pwdError by remember { mutableStateOf<String?>(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)) }
|
||||
},
|
||||
|
||||
@@ -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<Boolean?> = settingsRepository.settingsFlow.map<AppSettings, Boolean?> { 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)
|
||||
}
|
||||
@@ -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<String?>(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,7 +88,27 @@ 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)}")
|
||||
|
||||
@@ -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<Boolean?> = settingsRepository.settingsFlow.map<AppSettings, Boolean?> { it.hasPassword }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
suspend fun checkPassword(password: String): String? = settingsRepository.checkPassword(password)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Boolean?> = settingsRepository.settingsFlow.map<AppSettings, Boolean?> { 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Boolean?> = settingsRepository.settingsFlow.map<AppSettings, Boolean?> { 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)
|
||||
}
|
||||
@@ -12,10 +12,8 @@
|
||||
<!-- Dashboard -->
|
||||
<string name="dashboard_title">Tableau de bord</string>
|
||||
<string name="dashboard_consumed">Données consommées</string>
|
||||
<string name="dashboard_plan_label">Forfait</string>
|
||||
<string name="dashboard_remaining">Restant</string>
|
||||
<string name="dashboard_overage">Dépassement</string>
|
||||
<string name="dashboard_state_label">État des données</string>
|
||||
<string name="dashboard_state_ok">Sous le forfait</string>
|
||||
<string name="dashboard_state_warning">Proche du forfait</string>
|
||||
<string name="dashboard_state_cut">Coupure totale en cours</string>
|
||||
@@ -30,7 +28,6 @@
|
||||
<string name="dashboard_reset_now">Réinitialiser le forfait maintenant</string>
|
||||
<string name="dashboard_reset_confirm_title">Réinitialiser le forfait ?</string>
|
||||
<string name="dashboard_reset_confirm_message">Le compteur de données consommées sera remis à zéro immédiatement.</string>
|
||||
<string name="dashboard_permissions_needed">Autorisations requises pour fonctionner correctement</string>
|
||||
<string name="dashboard_control_disabled">Contrôle désactivé</string>
|
||||
<string name="dashboard_next_cutoff">Prochaine coupure</string>
|
||||
<string name="dashboard_cutoff_wifi">Wi-Fi actif (pas de coupure)</string>
|
||||
@@ -52,8 +49,6 @@
|
||||
<string name="settings_cutoff_interval">Intervalle de coupure (minutes)</string>
|
||||
<string name="settings_cutoff_help">Une coupure totale (même pour la liste blanche) se déclenche cycliquement à cet intervalle, que le forfait soit dépassé ou non.</string>
|
||||
<string name="settings_section_permissions">Autorisations</string>
|
||||
<string name="settings_perm_usage_access">Accès aux statistiques d\'utilisation</string>
|
||||
<string name="settings_perm_usage_access_desc">Nécessaire pour mesurer la consommation de données mobiles</string>
|
||||
<string name="settings_perm_vpn">Pare-feu local (VPN)</string>
|
||||
<string name="settings_perm_vpn_desc">Nécessaire pour filtrer les applications sur les données mobiles</string>
|
||||
<string name="settings_perm_notifications">Notifications</string>
|
||||
@@ -64,7 +59,6 @@
|
||||
<string name="settings_perm_missing">Manquante</string>
|
||||
<string name="settings_save">Enregistrer</string>
|
||||
<string name="settings_edit_date">Modifier</string>
|
||||
<string name="settings_saved">Réglages enregistrés</string>
|
||||
<string name="settings_section_security">Sécurité</string>
|
||||
<string name="settings_password_active">Mot de passe actif — protège la désactivation et les réglages</string>
|
||||
<string name="settings_password_inactive">Aucun mot de passe défini</string>
|
||||
@@ -80,11 +74,9 @@
|
||||
<!-- Verrouillage par mot de passe -->
|
||||
<string name="password_gate_title">Zone protégée</string>
|
||||
<string name="password_field_label">Mot de passe</string>
|
||||
<string name="password_incorrect">Mot de passe incorrect</string>
|
||||
<string name="password_unlock">Déverrouiller</string>
|
||||
|
||||
<!-- About -->
|
||||
<string name="about_title">À propos</string>
|
||||
<string name="about_version">Version %1$s</string>
|
||||
<string name="update_section">Mise à jour</string>
|
||||
<string name="update_check">Rechercher une mise à jour</string>
|
||||
@@ -116,8 +108,6 @@
|
||||
<string name="notification_title">DataControl</string>
|
||||
|
||||
<!-- Common -->
|
||||
<string name="unit_mo">Mo</string>
|
||||
<string name="unit_euro">€</string>
|
||||
<string name="common_cancel">Annuler</string>
|
||||
<string name="common_confirm">Confirmer</string>
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Android 11 et avant. Réglages (DataStore), appairage, mot de passe et journal sont propres à cet
|
||||
appareil : restaurés ailleurs, l'appareil se croirait associé alors que Firestore refuse ses écritures. -->
|
||||
<full-backup-content>
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="file" path="datastore/" />
|
||||
<exclude domain="file" path="journal.txt" />
|
||||
</full-backup-content>
|
||||
@@ -1,6 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Android 12+. Voir backup_rules.xml : rien de propre à l'appareil ne doit être sauvegardé ni transféré. -->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="file" path="datastore/" />
|
||||
<exclude domain="file" path="journal.txt" />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="file" path="datastore/" />
|
||||
<exclude domain="file" path="journal.txt" />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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 `guillemets typographiques`() {
|
||||
val js = "apiKey: “AIzaTest”, projectId: «mon-projet», appId: ‘1:123:android:abc’"
|
||||
assertEquals(expected, FirebaseConfig.parse(js))
|
||||
}
|
||||
|
||||
@Test fun `fichier google-services json`() {
|
||||
val gs = """
|
||||
{
|
||||
"project_info": { "project_number": "123", "project_id": "mon-projet", "storage_bucket": "mon-projet.appspot.com" },
|
||||
"client": [ {
|
||||
"client_info": { "mobilesdk_app_id": "1:123:android:abc",
|
||||
"android_client_info": { "package_name": "com.jttools.datacontrol" } },
|
||||
"api_key": [ { "current_key": "AIzaTest" } ]
|
||||
} ]
|
||||
}
|
||||
""".trimIndent()
|
||||
assertEquals(expected, FirebaseConfig.parse(gs))
|
||||
}
|
||||
|
||||
@Test fun `champ manquant ou illisible`() {
|
||||
assertNull(FirebaseConfig.parse("""{"apiKey":"AIzaTest","projectId":"mon-projet"}"""))
|
||||
assertNull(FirebaseConfig.parse("fc1:!!!"))
|
||||
assertNull(FirebaseConfig.parse(""))
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -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/<utilisateur>/.android/datacontrol-release.keystore
|
||||
storePassword=
|
||||
keyAlias=datacontrol
|
||||
keyPassword=
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
versionName=0.1.8
|
||||
versionCode=9
|
||||
versionName=0.1.10
|
||||
versionCode=11
|
||||
Reference in new issue
Block a user