7 Commits
Author SHA1 Message Date
tuxgyverandClaude Opus 5.5 1d707f8785 chore(release): v0.1.9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ
2026-10-25 10:51:49 +01:00
tuxgyverandClaude Opus 5.5 e269e9932a fix: corrections de l'audit de sécurité et de robustesse
- VPN retiré : le contrôle reste activé (alerte parent, tunnel remonté après 30 s, autorisation redemandée à l'ouverture)
- Horloge de référence : changer date/heure/fuseau ne contourne plus les plages horaires ni le temps d'écran (signalé au parent)
- Mot de passe : PBKDF2 + attente croissante après 3 échecs ; conversion automatique des anciens hachages
- Écrans protégés : aucun affichage avant chargement des réglages ; effacement du journal protégé
- Signature release via keystore.properties (même clé que les versions publiées)
- Sauvegarde Google : réglages, appairage et journal exclus
- Alerte admin fiable (goAsync), relance refusée signalée, liste d'applis renvoyée à chaque installation, code d'appairage consommé
- Journal thread-safe et sans recopie à chaque ligne ; correctifs lint
- Tests unitaires (mot de passe, règles, versions, config Firebase) ; README aligné sur le comportement réel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ
2026-10-25 10:49:43 +01:00
tuxgyverandClaude Sonnet 5 f33776bbb5 release: v0.1.8 — configuration Firebase saisie dans l'application
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 13:39:37 +02:00
tuxgyverandClaude Sonnet 5 eecad06eea release: v0.1.7 — dissociation automatique quand le parent supprime l'appareil
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 21:43:40 +02:00
tuxgyverandClaude Sonnet 5 d7e55dbb91 release: v0.1.6 — retire le bouton de test VPN, README à jour
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 18:48:53 +02:00
tuxgyverandClaude Sonnet 5 790397da0e release: v0.1.5 — test VPN, journal 15 j + verdicts par application, page À propos refondue, Makefile Féalu
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 18:41:37 +02:00
tuxgyverandClaude Sonnet 5 6086e8aeff release: v0.1.4 — onglet Journal (diagnostic VPN), limitation des applications (plages horaires, temps d'écran)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 18:03:00 +02:00
53 changed files with 2421 additions and 326 deletions

No files matched your search

+5
View File
@@ -19,3 +19,8 @@ gitea-token.properties
# Configuration Firebase locale (clés du projet). # Configuration Firebase locale (clés du projet).
firebase.properties firebase.properties
# Clé de signature des releases (chemin + mots de passe) : local, jamais versionné.
keystore.properties
*.keystore
*.jks
+38
View File
@@ -1,5 +1,43 @@
# Changelog # Changelog
## 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).
- La liaison Firebase se relance automatiquement quand la configuration change.
## v0.1.7 – 2026-09-20
- Contrôle parental : si le parent supprime l'appareil depuis FamilyControl, DataControl se dissocie automatiquement (règles et envois arrêtés) et le note dans le journal.
## v0.1.6 – 2026-09-20
- Onglet Journal : le bouton « Tester le VPN » est retiré (le VPN se monte correctement).
- README mis à jour (journal, contrôle parental, permissions, architecture).
## v0.1.5 – 2026-09-20
- Onglet Journal : bouton **Tester le VPN** (10 s) qui monte le tunnel quel que soit le réseau, pour savoir si le VPN peut se monter sur l'appareil ; les erreurs de démarrage du service VPN sont désormais tracées au lieu d'être silencieuses.
- Journal : conservé **15 jours** (plafond de 20 000 lignes) ; l'état de l'en-tête (VPN autorisé, tunnel monté, suivi des applications) est aussi écrit dans le journal à chaque changement.
- Journal : chaque application utilisée est tracée avec son verdict **réseau** (autorisé / bloqué / libre) et **lancement** (autorisé / bloqué). Nécessite le service d'accessibilité de DataControl.
- Page « À propos » refondue : en-tête et sous-onglets (Fonctionnalités, Mode d'emploi, Prérequis, Sécurité, Garanties, Choix techniques, Journal, Auteur) ; le journal des versions est lu depuis le CHANGELOG embarqué.
## v0.1.4 – 2026-09-20
- Nouvel onglet **Journal** : état du VPN en direct (autorisé / tunnel monté) et historique horodaté des décisions du pare-feu (réseau détecté, mode appliqué, échec d'établissement du tunnel…), avec Copier / Partager / Effacer.
- Limitation des applications (plages horaires, temps d'écran, applications interdites) : moteur et service d'accessibilité côté appareil, pilotés par FamilyControl.
## v0.1.3 – 2026-09-20 ## v0.1.3 – 2026-09-20
- **Correctif VPN** : le suivi du réseau ignore désormais le tunnel VPN lui-même (le pare-feu ne s'arrête plus tout seul), le trafic IPv6 est aussi filtré, un tunnel perdu est remonté automatiquement, et la notification signale « PARE-FEU INACTIF » si les données mobiles ne sont pas filtrées. - **Correctif VPN** : le suivi du réseau ignore désormais le tunnel VPN lui-même (le pare-feu ne s'arrête plus tout seul), le trafic IPv6 est aussi filtré, un tunnel perdu est remonté automatiquement, et la notification signale « PARE-FEU INACTIF » si les données mobiles ne sont pas filtrées.
+41 -23
View File
@@ -15,38 +15,55 @@ GITEA_REPO_NAME := DataControl
GITEA_TOKEN ?= $(shell sed -n 's/^giteaToken=//p' gitea-token.properties 2>/dev/null | tr -d '[:space:]') GITEA_TOKEN ?= $(shell sed -n 's/^giteaToken=//p' gitea-token.properties 2>/dev/null | tr -d '[:space:]')
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
.PHONY: help build release install push version version-bump changelog clean tag _check-token _check-apk publish deploy
.PHONY: help build release install push version-bump changelog clean tag _check-token _check-apk publish deploy BOLD := \033[1m
DIM := \033[2m
CYAN := \033[36m
GREEN := \033[32m
YELLOW := \033[33m
RESET := \033[0m
## help: Show this help message
help: help:
@echo "DataControl v$(VERSION_NAME) (build $(VERSION_CODE))" @printf "\n"
@echo "" @printf " $(BOLD)$(CYAN)DataControl$(RESET) $(DIM)— pare-feu de données mobiles et contrôle parental Android $(RESET)$(DIM)(v$(VERSION_NAME), build $(VERSION_CODE))$(RESET)\n"
@echo "Available targets:" @printf "\n"
@grep -E '^## [a-z]' Makefile | sed 's/## / make /' | column -t -s ':' @printf " $(BOLD)🔨 Build & installation$(RESET)\n"
@printf " $(GREEN)make build$(RESET) 🔨 Construire l'APK debug\n"
@printf " $(GREEN)make release$(RESET) 📱 Construire l'APK release et le copier à la racine\n"
@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"
@printf " $(GREEN)make tag$(RESET) 📌 Créer et pousser le tag git de la version courante\n"
@printf " $(GREEN)make publish$(RESET) 🚀 Publier l'APK signé en Release Gitea (idempotent)\n"
@printf " $(GREEN)make deploy$(RESET) 🚢 Construire l'APK release puis le publier\n"
@printf "\n"
@printf " $(BOLD)🧹 Nettoyage$(RESET)\n"
@printf " $(GREEN)make clean$(RESET) 🧽 Supprimer les artefacts de build et les APK racine\n"
@printf "\n"
## build: Build debug APK
build: build:
@echo "[BUILD] Building debug APK..." @printf "$(YELLOW)🔨 Construction de l'APK debug...$(RESET)\n"
$(GRADLEW) assembleDebug $(GRADLEW) assembleDebug
@echo "[BUILD] Done: $(APK_DEBUG)" @echo "[BUILD] Done: $(APK_DEBUG)"
## release: Build release APK and copy to project root
release: release:
@echo "[RELEASE] Building release APK for v$(VERSION_NAME) ($(VERSION_CODE))..." @printf "$(YELLOW)📱 Construction de l'APK release v$(VERSION_NAME) (build $(VERSION_CODE))...$(RESET)\n"
$(GRADLEW) assembleRelease $(GRADLEW) assembleRelease
@cp $(APK_RELEASE) $(OUTPUT_APK) @cp $(APK_RELEASE) $(OUTPUT_APK)
@echo "[RELEASE] Done: $(OUTPUT_APK)" @echo "[RELEASE] Done: $(OUTPUT_APK)"
## install: Install debug APK on connected device
install: install:
@echo "[INSTALL] Installing debug APK..." @printf "$(YELLOW)📲 Installation de l'APK debug...$(RESET)\n"
$(GRADLEW) installDebug $(GRADLEW) installDebug
@echo "[INSTALL] Done." @echo "[INSTALL] Done."
## push: Git add, commit, tag and push (uses current version)
push: push:
@echo "[PUSH] Committing and pushing v$(VERSION_NAME)..." @printf "$(YELLOW)📤 Commit et push de v$(VERSION_NAME)...$(RESET)\n"
git add -A git add -A
git commit -m "Release v$(VERSION_NAME) (build $(VERSION_CODE))" git commit -m "Release v$(VERSION_NAME) (build $(VERSION_CODE))"
git tag -a "v$(VERSION_NAME)" -m "Release v$(VERSION_NAME)" git tag -a "v$(VERSION_NAME)" -m "Release v$(VERSION_NAME)"
@@ -54,8 +71,8 @@ push:
git push origin "v$(VERSION_NAME)" git push origin "v$(VERSION_NAME)"
@echo "[PUSH] Done." @echo "[PUSH] Done."
## tag: Create and push the git tag for the current version
tag: tag:
@printf "$(YELLOW)📌 Tag git v$(VERSION_NAME)...$(RESET)\n"
@if git rev-parse "v$(VERSION_NAME)" >/dev/null 2>&1; then \ @if git rev-parse "v$(VERSION_NAME)" >/dev/null 2>&1; then \
echo "Tag v$(VERSION_NAME) already exists, skipping"; \ echo "Tag v$(VERSION_NAME) already exists, skipping"; \
else \ else \
@@ -76,9 +93,8 @@ _check-apk:
@test $(OUTPUT_APK) -nt version.properties || \ @test $(OUTPUT_APK) -nt version.properties || \
(echo "$(OUTPUT_APK) is older than version.properties — rerun 'make release'." && exit 1) (echo "$(OUTPUT_APK) is older than version.properties — rerun 'make release'." && exit 1)
## publish: Publish the signed APK as a Gitea Release (tag + assets, idempotent)
publish: _check-apk _check-token tag publish: _check-apk _check-token tag
@echo "Publishing Gitea release v$(VERSION_NAME)…" @printf "$(YELLOW)🚀 Publication de la release Gitea v$(VERSION_NAME)...$(RESET)\n"
@EXISTING=$$(curl -s -H "Authorization: token $(GITEA_TOKEN)" \ @EXISTING=$$(curl -s -H "Authorization: token $(GITEA_TOKEN)" \
"$(GITEA_URL)/api/v1/repos/$(GITEA_OWNER)/$(GITEA_REPO_NAME)/releases/tags/v$(VERSION_NAME)"); \ "$(GITEA_URL)/api/v1/repos/$(GITEA_OWNER)/$(GITEA_REPO_NAME)/releases/tags/v$(VERSION_NAME)"); \
REL_ID=$$(printf '%s\n' "$$EXISTING" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['id'] if isinstance(d, dict) and 'id' in d else '')" 2>/dev/null); \ REL_ID=$$(printf '%s\n' "$$EXISTING" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['id'] if isinstance(d, dict) and 'id' in d else '')" 2>/dev/null); \
@@ -113,11 +129,15 @@ publish: _check-apk _check-token tag
echo ""; \ echo ""; \
echo "$(GITEA_URL)/$(GITEA_OWNER)/$(GITEA_REPO_NAME)/releases/tag/v$(VERSION_NAME)" echo "$(GITEA_URL)/$(GITEA_OWNER)/$(GITEA_REPO_NAME)/releases/tag/v$(VERSION_NAME)"
## deploy: Build the signed APK then publish (commit source changes first!)
deploy: release publish 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: Increment versionCode and update versionName patch
version-bump: version-bump:
@printf "$(YELLOW)🔖 Incrémentation de la version...$(RESET)\n"
@NEW_CODE=$$(( $(VERSION_CODE) + 1 )); \ @NEW_CODE=$$(( $(VERSION_CODE) + 1 )); \
PATCH=$$(echo $(VERSION_NAME) | cut -d. -f3); \ PATCH=$$(echo $(VERSION_NAME) | cut -d. -f3); \
MAJOR=$$(echo $(VERSION_NAME) | cut -d. -f1); \ MAJOR=$$(echo $(VERSION_NAME) | cut -d. -f1); \
@@ -128,9 +148,8 @@ version-bump:
sed -i "s/versionCode=.*/versionCode=$${NEW_CODE}/" version.properties; \ sed -i "s/versionCode=.*/versionCode=$${NEW_CODE}/" version.properties; \
echo "[VERSION-BUMP] Bumped to $${NEW_VERSION} (build $${NEW_CODE})" echo "[VERSION-BUMP] Bumped to $${NEW_VERSION} (build $${NEW_CODE})"
## changelog: Generate a changelog entry from git log since last tag
changelog: changelog:
@echo "[CHANGELOG] Generating entries since last tag..." @printf "$(YELLOW)📝 Génération du CHANGELOG depuis le dernier tag...$(RESET)\n"
@LAST_TAG=$$(git describe --tags --abbrev=0 2>/dev/null || echo ""); \ @LAST_TAG=$$(git describe --tags --abbrev=0 2>/dev/null || echo ""); \
if [ -z "$$LAST_TAG" ]; then \ if [ -z "$$LAST_TAG" ]; then \
git log --oneline --pretty=format:"- %s" >> CHANGELOG.md; \ git log --oneline --pretty=format:"- %s" >> CHANGELOG.md; \
@@ -141,9 +160,8 @@ changelog:
fi; \ fi; \
echo "[CHANGELOG] Done." echo "[CHANGELOG] Done."
## clean: Clean build artifacts
clean: clean:
@echo "[CLEAN] Cleaning build artifacts..." @printf "$(YELLOW)🧽 Nettoyage des artefacts de build...$(RESET)\n"
$(GRADLEW) clean $(GRADLEW) clean
@rm -f DataControl-*.apk @rm -f DataControl-*.apk
@echo "[CLEAN] Done." @echo "[CLEAN] Done."
+34 -5
View File
@@ -1,8 +1,10 @@
# DataControl # DataControl
🚀 v0.1.9 · 🤖 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. **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 — v0.1.0 > JT-Tools by Johnny — version courante et historique : voir [CHANGELOG.md](CHANGELOG.md) (aussi lisible dans l'onglet *À propos* de l'application).
--- ---
@@ -13,10 +15,26 @@
- **Estimation du coût** — Calcul automatique : prix du forfait + (Mo hors-forfait × prix/Mo), affiché en temps réel sur le tableau de bord. - **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. - **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é. - **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. - **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. - **Démarrage au boot** — Le service redémarre automatiquement après un redémarrage de l'appareil si le contrôle était actif.
- **Interface Material 3** — Thème clair/sombre avec couleurs dynamiques (Android 12+), 3 écrans : Tableau de bord, Réglages, Liste blanche. - **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, 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.
---
## Contrôle parental (avec FamilyControl)
DataControl peut être associé à **FamilyControl** (application parent en TypeScript/Expo, dépôt séparé) via Firebase (Auth + Firestore). La configuration du projet Firebase se **colle dans l'application** (*Réglages → Contrôle parental → Configuration Firebase*), sans recompiler ; sans elle, ces fonctions sont simplement désactivées (un `firebase.properties` à la compilation reste possible comme valeur par défaut).
- **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. 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).
--- ---
@@ -29,7 +47,9 @@ Règles appliquées :
| Réseau actif | Sous le forfait | Forfait dépassé | | Réseau actif | Sous le forfait | Forfait dépassé |
|---|---|---| |---|---|---|
| **Wi-Fi** | Aucun filtrage, jamais | Aucun filtrage, jamais | | **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é. 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é.
@@ -44,6 +64,9 @@ Le pare-feu est démarré/arrêté automatiquement selon le transport réseau ac
| Ignorer l'optimisation de la batterie | Éviter que le système ne tue le service de surveillance | | Ignorer l'optimisation de la batterie | Éviter que le système ne tue le service de surveillance |
| `QUERY_ALL_PACKAGES` | Lister toutes les applications installées dans l'écran Liste blanche | | `QUERY_ALL_PACKAGES` | Lister toutes les applications installées dans l'écran Liste blanche |
| `RECEIVE_BOOT_COMPLETED` | Relancer la surveillance après redémarrage si le contrôle était actif | | `RECEIVE_BOOT_COMPLETED` | Relancer la surveillance après redémarrage si le contrôle était actif |
| Position (dont arrière-plan) | Facultatif — transmise au parent si l'appareil est associé |
| Administrateur d'appareil | Facultatif — anti-désinstallation |
| Service d'accessibilité | Facultatif — détecte l'application au premier plan (sans lire les écrans) pour les règles d'usage et le journal |
--- ---
@@ -60,7 +83,10 @@ Le pare-feu est démarré/arrêté automatiquement selon le transport réseau ac
- **Kotlin + Jetpack Compose (Material 3)**, Hilt pour l'injection de dépendances. - **Kotlin + Jetpack Compose (Material 3)**, Hilt pour l'injection de dépendances.
- `data/` — `SettingsRepository` (DataStore Preferences), `UsageRepository` (TrafficStats), `InstalledAppsRepository` (PackageManager). - `data/` — `SettingsRepository` (DataStore Preferences), `UsageRepository` (TrafficStats), `InstalledAppsRepository` (PackageManager).
- `service/` — `FirewallVpnService` (VpnService), `ConnectivityObserver` (détection Wi-Fi/cellulaire), `DataMonitorService` (orchestration + notification), `MonitorStateHolder` (pont service ↔ UI), `BootReceiver`. - `service/` — `FirewallVpnService` (VpnService), `ConnectivityObserver` (détection Wi-Fi/cellulaire), `DataMonitorService` (orchestration + notification), `MonitorStateHolder` (pont service ↔ UI), `BootReceiver`.
- `ui/` — écrans Compose (`dashboard`, `settings`, `whitelist`), navigation par `NavigationBar`, thème dynamique. - `remote/` — `FirebaseGateway`, `RemoteSync` (appairage, consignes, remontées), `DeviceReporter` (batterie, position, trafic, état).
- `guard/` — `GuardEngine` (règles d'usage, temps d'écran), `AppGuardService` (accessibilité), `BlockedActivity`.
- `admin/` — administrateur d'appareil (anti-désinstallation). `log/` — journal de diagnostic.
- `ui/` — écrans Compose (`dashboard`, `settings`, `whitelist`, `journal`, `about`, `family`), navigation par `NavigationBar`, thème dynamique.
--- ---
@@ -70,10 +96,13 @@ Le pare-feu est démarré/arrêté automatiquement selon le transport réseau ac
make build # APK debug make build # APK debug
make release # APK release signé, copié en DataControl-<version>.apk make release # APK release signé, copié en DataControl-<version>.apk
make install # Installe le debug sur un appareil connecté (adb) make install # Installe le debug sur un appareil connecté (adb)
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. 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 ## Licence
+36 -1
View File
@@ -16,6 +16,13 @@ val firebaseProps = Properties().apply {
if (f.exists()) f.inputStream().use { load(it) } 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 { val versionProps = Properties().apply {
load(rootProject.file("version.properties").inputStream()) load(rootProject.file("version.properties").inputStream())
} }
@@ -42,6 +49,17 @@ android {
buildConfigField("String", "BUILD_DATE", "\"${SimpleDateFormat("yyyy-MM-dd").format(Date())}\"") 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 { buildTypes {
release { release {
isMinifyEnabled = true isMinifyEnabled = true
@@ -49,7 +67,10 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "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")
}
} }
} }
@@ -78,6 +99,18 @@ android {
} }
} }
// CHANGELOG.md est embarqué dans l'APK (assets) : l'onglet Journal de « À propos » le lit à l'exécution.
val copyChangelog = tasks.register<Copy>("copyChangelog") {
from(rootProject.file("CHANGELOG.md"))
into(layout.buildDirectory.dir("generated/changelogAssets"))
}
android.sourceSets.getByName("main").assets.srcDir(layout.buildDirectory.dir("generated/changelogAssets"))
tasks.configureEach {
if (name.startsWith("merge") && name.endsWith("Assets") || name.contains("lint", ignoreCase = true)) {
dependsOn(copyChangelog)
}
}
dependencies { dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
@@ -103,6 +136,8 @@ dependencies {
implementation(libs.kotlinx.coroutines.play.services) implementation(libs.kotlinx.coroutines.play.services)
testImplementation(libs.junit) 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.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
+21
View File
@@ -65,6 +65,27 @@
android:resource="@xml/file_paths" /> android:resource="@xml/file_paths" />
</provider> </provider>
<!-- Limitation du lancement des applications (détection de l'application au premier plan). -->
<service
android:name=".guard.AppGuardService"
android:exported="true"
android:label="@string/guard_service_label"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_guard" />
</service>
<activity
android:name=".guard.BlockedActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTask"
android:theme="@style/Theme.DataControl" />
<!-- Administrateur d'appareil : bloque la désinstallation tant qu'il est actif. --> <!-- Administrateur d'appareil : bloque la désinstallation tant qu'il est actif. -->
<receiver <receiver
android:name=".admin.AppDeviceAdminReceiver" android:name=".admin.AppDeviceAdminReceiver"
@@ -9,6 +9,8 @@ import com.jttools.datacontrol.service.DataMonitorRestartWorker
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
@@ -17,10 +19,16 @@ import javax.inject.Inject
class DataControlApp : Application() { class DataControlApp : Application() {
@Inject lateinit var settingsRepository: SettingsRepository @Inject lateinit var settingsRepository: SettingsRepository
@Inject lateinit var firebaseGateway: com.jttools.datacontrol.remote.FirebaseGateway
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
com.jttools.datacontrol.log.AppLog.init(this)
CoroutineScope(Dispatchers.IO).launch { settingsRepository.resetPasswordOnce() } CoroutineScope(Dispatchers.IO).launch { settingsRepository.resetPasswordOnce() }
// La config Firebase se saisit dans l'appli : on l'applique au démarrage et à chaque modification.
CoroutineScope(Dispatchers.IO).launch {
settingsRepository.settingsFlow.map { it.firebase }.distinctUntilChanged().collect { firebaseGateway.apply(it) }
}
// Filet de sécurité : relance périodiquement DataMonitorService s'il a été tué en arrière-plan. // Filet de sécurité : relance périodiquement DataMonitorService s'il a été tué en arrière-plan.
// enqueueUniquePeriodicWork + KEEP : sans effet si déjà planifié, donc sûr à appeler à chaque démarrage. // enqueueUniquePeriodicWork + KEEP : sans effet si déjà planifié, donc sûr à appeler à chaque démarrage.
val request = PeriodicWorkRequestBuilder<DataMonitorRestartWorker>(15, TimeUnit.MINUTES).build() val request = PeriodicWorkRequestBuilder<DataMonitorRestartWorker>(15, TimeUnit.MINUTES).build()
@@ -4,16 +4,25 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge 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.service.DataMonitorService
import com.jttools.datacontrol.ui.SystemActions import com.jttools.datacontrol.ui.SystemActions
import com.jttools.datacontrol.ui.navigation.DataControlNavHost import com.jttools.datacontrol.ui.navigation.DataControlNavHost
import com.jttools.datacontrol.ui.theme.DataControlTheme import com.jttools.datacontrol.ui.theme.DataControlTheme
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@Inject lateinit var settingsRepository: SettingsRepository
private lateinit var systemActions: SystemActions private lateinit var systemActions: SystemActions
private var vpnAskedThisSession = false
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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) { override fun onDisabled(context: Context, intent: Intent) {
// Prévient le parent : la protection anti-désinstallation vient d'être retirée. // 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 { CoroutineScope(Dispatchers.IO).launch {
remoteSync.pushEvent("admin_disabled", "Protection anti-désinstallation désactivée") try {
remoteSync.pushEvent("admin_disabled", "Protection anti-désinstallation désactivée")
} finally {
pending.finish()
}
} }
} }
} }
@@ -27,7 +27,11 @@ data class AppSettings(
val familyId: String? = null, val familyId: String? = null,
val deviceId: String? = null, val deviceId: String? = null,
/** Horodatage (ms) de la dernière configuration distante appliquée. */ /** Horodatage (ms) de la dernière configuration distante appliquée. */
val remoteConfigVersion: Long = 0L val remoteConfigVersion: Long = 0L,
/** Règles d'usage (plages horaires, limites de temps, applications interdites), au format JSON. */
val rulesJson: String? = null,
/** Configuration Firebase saisie dans l'application (null : voir firebase.properties de compilation). */
val firebase: com.jttools.datacontrol.remote.FirebaseConfig? = null
) { ) {
val isPaired: Boolean get() = !familyId.isNullOrEmpty() && !deviceId.isNullOrEmpty() val isPaired: Boolean get() = !familyId.isNullOrEmpty() && !deviceId.isNullOrEmpty()
@@ -2,10 +2,20 @@ package com.jttools.datacontrol.data
import java.security.MessageDigest import java.security.MessageDigest
import java.security.SecureRandom 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 { object PasswordHasher {
private const val SALT_BYTES = 16 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 { fun newSalt(): String {
val bytes = ByteArray(SALT_BYTES) val bytes = ByteArray(SALT_BYTES)
@@ -13,11 +23,40 @@ object PasswordHasher {
return bytes.toHex() 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") val digest = MessageDigest.getInstance("SHA-256")
digest.update(saltHex.toByteArray(Charsets.UTF_8)) digest.update(saltHex.toByteArray(Charsets.UTF_8))
return digest.digest(password.toByteArray(Charsets.UTF_8)).toHex() return digest.digest(password.toByteArray(Charsets.UTF_8)).toHex()
} }
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } 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 package com.jttools.datacontrol.data
import android.content.Context 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.booleanPreferencesKey
import androidx.datastore.preferences.core.doublePreferencesKey import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
@@ -45,11 +49,27 @@ class SettingsRepository @Inject constructor(
val FAMILY_ID = stringPreferencesKey("family_id") val FAMILY_ID = stringPreferencesKey("family_id")
val DEVICE_ID = stringPreferencesKey("device_id") val DEVICE_ID = stringPreferencesKey("device_id")
val REMOTE_CONFIG_VERSION = longPreferencesKey("remote_config_version") val REMOTE_CONFIG_VERSION = longPreferencesKey("remote_config_version")
val RULES_JSON = stringPreferencesKey("rules_json")
val FIREBASE_API_KEY = stringPreferencesKey("firebase_api_key")
val FIREBASE_APP_ID = stringPreferencesKey("firebase_app_id")
val FIREBASE_PROJECT_ID = stringPreferencesKey("firebase_project_id")
val USAGE_DAY = stringPreferencesKey("usage_day")
val USAGE_JSON = stringPreferencesKey("usage_json")
val TRAFFIC_DAY = stringPreferencesKey("traffic_day") val TRAFFIC_DAY = stringPreferencesKey("traffic_day")
val TRAFFIC_MOBILE_ACC = longPreferencesKey("traffic_mobile_acc") val TRAFFIC_MOBILE_ACC = longPreferencesKey("traffic_mobile_acc")
val TRAFFIC_WIFI_ACC = longPreferencesKey("traffic_wifi_acc") val TRAFFIC_WIFI_ACC = longPreferencesKey("traffic_wifi_acc")
val TRAFFIC_LAST_MOBILE = longPreferencesKey("traffic_last_mobile") val TRAFFIC_LAST_MOBILE = longPreferencesKey("traffic_last_mobile")
val TRAFFIC_LAST_OTHER = longPreferencesKey("traffic_last_other") 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 -> val settingsFlow: Flow<AppSettings> = context.dataStore.data.map { prefs ->
@@ -74,7 +94,13 @@ class SettingsRepository @Inject constructor(
cutoffPulseStartMillis = prefs[Keys.CUTOFF_PULSE_START_MILLIS] ?: defaults.cutoffPulseStartMillis, cutoffPulseStartMillis = prefs[Keys.CUTOFF_PULSE_START_MILLIS] ?: defaults.cutoffPulseStartMillis,
familyId = prefs[Keys.FAMILY_ID], familyId = prefs[Keys.FAMILY_ID],
deviceId = prefs[Keys.DEVICE_ID], deviceId = prefs[Keys.DEVICE_ID],
remoteConfigVersion = prefs[Keys.REMOTE_CONFIG_VERSION] ?: defaults.remoteConfigVersion remoteConfigVersion = prefs[Keys.REMOTE_CONFIG_VERSION] ?: defaults.remoteConfigVersion,
rulesJson = prefs[Keys.RULES_JSON],
firebase = run {
val k = prefs[Keys.FIREBASE_API_KEY]; val a = prefs[Keys.FIREBASE_APP_ID]; val p = prefs[Keys.FIREBASE_PROJECT_ID]
if (k.isNullOrBlank() || a.isNullOrBlank() || p.isNullOrBlank()) null
else com.jttools.datacontrol.remote.FirebaseConfig(k, a, p)
}
) )
} }
@@ -156,14 +182,29 @@ class SettingsRepository @Inject constructor(
} }
} else { } else {
val salt = PasswordHasher.newSalt() val salt = PasswordHasher.newSalt()
val hash = PasswordHasher.hash(password, salt) val hash = withContext(Dispatchers.Default) { PasswordHasher.hash(password, salt) }
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
prefs[Keys.PASSWORD_HASH] = hash prefs[Keys.PASSWORD_HASH] = hash
prefs[Keys.PASSWORD_SALT] = salt 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, * 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. * puis mémorise qu'elle a eu lieu pour ne pas effacer un mot de passe défini ensuite.
@@ -178,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é. */ /** Fin du blocage en temps monotone : avancer l'horloge du téléphone ne raccourcit pas l'attente. */
suspend fun verifyPassword(password: String): Boolean { @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 prefs = context.dataStore.data.first()
val hash = prefs[Keys.PASSWORD_HASH] ?: return true val hash = prefs[Keys.PASSWORD_HASH] ?: return null
val salt = prefs[Keys.PASSWORD_SALT] ?: return true val salt = prefs[Keys.PASSWORD_SALT] ?: return null
return PasswordHasher.hash(password, salt) == hash
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."
} }
/** /**
@@ -205,6 +297,7 @@ class SettingsRepository @Inject constructor(
it.remove(Keys.FAMILY_ID) it.remove(Keys.FAMILY_ID)
it.remove(Keys.DEVICE_ID) it.remove(Keys.DEVICE_ID)
it.remove(Keys.REMOTE_CONFIG_VERSION) it.remove(Keys.REMOTE_CONFIG_VERSION)
it.remove(Keys.RULES_JSON)
} else { } else {
it[Keys.FAMILY_ID] = familyId it[Keys.FAMILY_ID] = familyId
it[Keys.DEVICE_ID] = deviceId it[Keys.DEVICE_ID] = deviceId
@@ -225,10 +318,12 @@ class SettingsRepository @Inject constructor(
whitelistedPackagesWifi: Set<String>?, whitelistedPackagesWifi: Set<String>?,
wifiFilterEnabled: Boolean?, wifiFilterEnabled: Boolean?,
planLimitMo: Long?, planLimitMo: Long?,
cutoffIntervalMinutes: Int? cutoffIntervalMinutes: Int?,
rulesJson: String?
) { ) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
if ((prefs[Keys.REMOTE_CONFIG_VERSION] ?: 0L) >= version) return@edit if ((prefs[Keys.REMOTE_CONFIG_VERSION] ?: 0L) >= version) return@edit
rulesJson?.let { prefs[Keys.RULES_JSON] = it }
prefs[Keys.REMOTE_CONFIG_VERSION] = version prefs[Keys.REMOTE_CONFIG_VERSION] = version
controlEnabled?.let { prefs[Keys.CONTROL_ENABLED] = it } controlEnabled?.let { prefs[Keys.CONTROL_ENABLED] = it }
whitelistedPackages?.let { prefs[Keys.WHITELISTED_PACKAGES] = it } whitelistedPackages?.let { prefs[Keys.WHITELISTED_PACKAGES] = it }
@@ -265,4 +360,31 @@ class SettingsRepository @Inject constructor(
} }
return result return result
} }
suspend fun loadUsage(): Pair<String, String>? {
val prefs = context.dataStore.data.first()
val day = prefs[Keys.USAGE_DAY] ?: return null
val json = prefs[Keys.USAGE_JSON] ?: return null
return day to json
}
suspend fun saveUsage(day: String, json: String) {
context.dataStore.edit {
it[Keys.USAGE_DAY] = day
it[Keys.USAGE_JSON] = json
}
}
/** Enregistre (ou efface, si null) la configuration Firebase saisie dans l'application. */
suspend fun setFirebaseConfig(config: com.jttools.datacontrol.remote.FirebaseConfig?) {
context.dataStore.edit {
if (config == null) {
it.remove(Keys.FIREBASE_API_KEY); it.remove(Keys.FIREBASE_APP_ID); it.remove(Keys.FIREBASE_PROJECT_ID)
} else {
it[Keys.FIREBASE_API_KEY] = config.apiKey
it[Keys.FIREBASE_APP_ID] = config.appId
it[Keys.FIREBASE_PROJECT_ID] = config.projectId
}
}
}
} }
@@ -0,0 +1,175 @@
package com.jttools.datacontrol.guard
import android.accessibilityservice.AccessibilityService
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.PowerManager
import android.provider.Settings
import android.telecom.TelecomManager
import android.view.accessibility.AccessibilityEvent
import android.view.inputmethod.InputMethodManager
import com.jttools.datacontrol.log.AppLog
import com.jttools.datacontrol.service.MonitorStateHolder
import com.jttools.datacontrol.service.NetworkTransport
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Détecte l'application au premier plan (service d'accessibilité, sans lire le contenu des écrans)
* pour appliquer les règles de [GuardEngine] : interdiction, plages horaires, limites de temps.
* Un blocage renvoie à l'accueil et affiche [BlockedActivity].
*/
@AndroidEntryPoint
class AppGuardService : AccessibilityService() {
@Inject lateinit var engine: GuardEngine
@Inject lateinit var stateHolder: MonitorStateHolder
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var exempt: Set<String> = emptySet()
private var exemptComputedAt = 0L
private var currentPkg: String? = null
private var lastBlockAt = 0L
private var lastLoggedPkg: String? = null
private val screenReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
engine.onScreen(intent?.action != Intent.ACTION_SCREEN_OFF)
}
}
override fun onServiceConnected() {
super.onServiceConnected()
engine.start()
engine.onScreen(getSystemService(PowerManager::class.java).isInteractive)
registerReceiver(screenReceiver, IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_SCREEN_OFF)
})
scope.launch {
// Réévalue l'application courante : la limite peut être atteinte pendant l'utilisation.
while (isActive) {
delay(CHECK_INTERVAL_MS)
engine.tick()
currentPkg?.let { enforce(it) }
}
}
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (event?.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) return
val pkg = event.packageName?.toString() ?: return
if (pkg in exemptPackages()) {
currentPkg = null
lastLoggedPkg = null // revenir ensuite dans la même application compte comme une nouvelle utilisation
engine.onForeground(null)
return
}
currentPkg = pkg
engine.onForeground(pkg)
val block = engine.check(pkg)
if (pkg != lastLoggedPkg) {
lastLoggedPkg = pkg
logUsage(pkg, block)
}
if (block != null) enforce(pkg, block)
}
private fun enforce(pkg: String) {
engine.check(pkg)?.let { enforce(pkg, it) }
}
private fun enforce(pkg: String, block: Block) {
val now = System.currentTimeMillis()
if (now - lastBlockAt < 2_000) return
lastBlockAt = now
AppLog.w("Appli", "Lancement BLOQUÉ : ${labelOf(pkg)} ($pkg) — ${block.message}")
performGlobalAction(GLOBAL_ACTION_HOME)
startActivity(BlockedActivity.intent(this, labelOf(pkg), block.message))
}
/** Une ligne de journal par utilisation : réseau et lancement, autorisés ou bloqués. */
private fun logUsage(pkg: String, block: Block?) {
val (net, netBlocked) = networkVerdict(pkg)
val launch = if (block == null) "lancement autorisé" else "lancement BLOQUÉ (${block.message.trimEnd('.')})"
val line = "${labelOf(pkg)} ($pkg) — réseau $net · $launch"
if (netBlocked || block != null) AppLog.w("Appli", line) else AppLog.i("Appli", line)
}
/** Verdict réseau d'une application d'après l'état réel du pare-feu (mode appliqué + liste blanche). */
private fun networkVerdict(pkg: String): Pair<String, Boolean> {
val snap = stateHolder.snapshot.value
val net = when (snap.transport) {
NetworkTransport.CELLULAR -> "4G/5G"
NetworkTransport.WIFI -> "Wi-Fi"
NetworkTransport.OTHER -> "autre réseau"
NetworkTransport.NONE -> "aucun réseau"
}
if (pkg == packageName) return "AUTORISÉ (DataControl, toujours exempté) [$net]" to false
if (!snap.serviceRunning) return "libre (contrôle désactivé) [$net]" to false
return when (snap.appliedMode) {
"BLOCK_ALL" -> "BLOQUÉ (coupure totale en cours) [$net]" to true
"FILTER" ->
if (pkg in snap.effectiveWhitelist) "AUTORISÉ (liste blanche) [$net]" to false
else "BLOQUÉ (hors liste blanche) [$net]" to true
else -> "libre (aucun filtrage sur ce réseau) [$net]" to false
}
}
private fun labelOf(pkg: String): String = runCatching {
packageManager.getApplicationLabel(packageManager.getApplicationInfo(pkg, 0)).toString()
}.getOrDefault(pkg)
/** Applications jamais bloquées ni comptées : sans elles l'appareil deviendrait inutilisable. */
private fun exemptPackages(): Set<String> {
val now = System.currentTimeMillis()
if (exempt.isNotEmpty() && now - exemptComputedAt < EXEMPT_TTL_MS) return exempt
val set = mutableSetOf(packageName, "android", "com.android.systemui", "com.android.phone", "com.android.emergency")
runCatching {
val home = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
packageManager.queryIntentActivities(home, PackageManager.MATCH_DEFAULT_ONLY)
.forEach { set += it.activityInfo.packageName }
}
runCatching { getSystemService(TelecomManager::class.java).defaultDialerPackage?.let { set += it } }
runCatching {
getSystemService(InputMethodManager::class.java).enabledInputMethodList.forEach { set += it.packageName }
}
exempt = set
exemptComputedAt = now
return set
}
override fun onInterrupt() = Unit
override fun onDestroy() {
runCatching { unregisterReceiver(screenReceiver) }
scope.cancel()
super.onDestroy()
}
companion object {
private const val CHECK_INTERVAL_MS = 10_000L
private const val EXEMPT_TTL_MS = 5 * 60_000L
fun isEnabled(context: Context): Boolean {
val enabled = Settings.Secure.getString(
context.contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES
) ?: return false
val me = ComponentName(context, AppGuardService::class.java)
return enabled.split(':').any {
it.equals(me.flattenToString(), true) || it.equals(me.flattenToShortString(), true)
}
}
}
}
@@ -0,0 +1,79 @@
package com.jttools.datacontrol.guard
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
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.jttools.datacontrol.ui.theme.DataControlTheme
/** Écran affiché par-dessus une application bloquée : explique pourquoi et renvoie à l'accueil. */
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 {
DataControlTheme {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.errorContainer) {
Column(
Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(Icons.Filled.Lock, contentDescription = null, modifier = Modifier.size(72.dp))
Spacer(Modifier.height(20.dp))
Text(label, style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center)
Spacer(Modifier.height(10.dp))
Text(message, style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center)
Spacer(Modifier.height(28.dp))
Button(onClick = { goHome() }) { Text("Retour à l'accueil") }
}
}
}
}
}
private fun goHome() {
startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
finish()
}
companion object {
private const val EXTRA_LABEL = "label"
private const val EXTRA_MESSAGE = "message"
fun intent(context: Context, label: String, message: String): Intent =
Intent(context, BlockedActivity::class.java)
.putExtra(EXTRA_LABEL, label)
.putExtra(EXTRA_MESSAGE, message)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
}
@@ -0,0 +1,268 @@
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
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.json.JSONObject
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)
data class UsageSnapshot(val date: String, val totalMs: Long, val appMs: Map<String, Long>)
/**
* Cœur de la limitation d'usage : cumule le temps d'écran (total et par application) à partir des
* changements d'application au premier plan, et décide si une application doit être bloquée
* (interdite, hors plage horaire, temps quotidien ou temps d'application épuisé).
*
* 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(
@ApplicationContext private val context: Context,
private val settings: SettingsRepository,
private val remoteSync: RemoteSync
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val lock = Any()
@Volatile var rules: Rules = Rules.NONE
private set
private var started = false
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 {
while (isActive) {
delay(PERSIST_INTERVAL_MS)
tick()
persist()
}
}
}
private fun restore(savedDay: String, json: String) = synchronized(lock) {
if (savedDay != today()) return@synchronized
runCatching {
val o = JSONObject(json)
day = savedDay
totalMs = o.optLong("total")
appMs.clear()
o.optJSONObject("apps")?.let { a -> a.keys().forEach { k -> appMs[k] = a.optLong(k) } }
}
}
private suspend fun persist() {
val snap = synchronized(lock) { if (!dirty) return; dirty = false; usageLocked() }
val json = JSONObject().put("total", snap.totalMs).put("apps", JSONObject(snap.appMs as Map<*, *>)).toString()
settings.saveUsage(snap.date, json)
}
fun usage(): UsageSnapshot = synchronized(lock) { flush(); usageLocked() }
private fun usageLocked() = UsageSnapshot(day, totalMs, HashMap(appMs))
/** [pkg] = application désormais au premier plan, ou null si exemptée / aucune. */
fun onForeground(pkg: String?) = synchronized(lock) { flush(); currentPkg = pkg }
fun onScreen(on: Boolean) = synchronized(lock) { flush(); screenOn = on }
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 = today()
if (today != day) {
day = today; totalMs = 0; appMs.clear(); dirty = true
}
val pkg = currentPkg
if (pkg != null && screenOn) {
// Plafonné : si le service a été gelé longtemps, on ne comptabilise pas tout l'intervalle.
val delta = (now - lastMark).coerceIn(0, MAX_DELTA_MS)
if (delta > 0) {
totalMs += delta
appMs[pkg] = (appMs[pkg] ?: 0L) + delta
dirty = true
}
}
lastMark = now
}
/** Vrai si l'appareil est hors de toutes les plages horaires autorisées. */
fun scheduleLocked(): Boolean = synchronized(lock) {
val r = rules
r.scheduleEnabled && !r.isOpenAt(nowLocal())
}
fun cutsMobileNow(): Boolean = synchronized(lock) {
val r = rules
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 = nowLocal()
if (pkg in r.blocked) return Block("Cette application est bloquée par vos parents.")
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) {
val allowedMs = (r.dailyLimitMin + if (bonusToday) r.bonusGlobalMin else 0) * MIN_MS
if (totalMs >= allowedMs) return Block("Le temps d'écran de la journée est écoulé.")
}
r.appLimits.firstOrNull { it.pkg == pkg }?.let { lim ->
val bonus = if (bonusToday) r.bonusApps.firstOrNull { it.pkg == pkg }?.minutes ?: 0 else 0
if ((appMs[pkg] ?: 0L) >= (lim.minutes + bonus) * MIN_MS) {
return Block("Le temps alloué à cette application est écoulé.")
}
}
null
}
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
}
}
@@ -0,0 +1,92 @@
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)
data class AppLimit(val pkg: String, val minutes: Int)
/**
* Règles d'usage envoyées par FamilyControl (champ `rules` de la config distante).
* Les temps s'entendent par jour ; le bonus n'est valable que le jour [bonusDate].
*/
data class Rules(
val scheduleEnabled: Boolean = false,
val scheduleCutsMobile: Boolean = true,
val windows: List<TimeWindow> = emptyList(),
val dailyLimitMin: Int = 0,
val appLimits: List<AppLimit> = emptyList(),
val blocked: Set<String> = emptySet(),
val bonusDate: String? = null,
val bonusGlobalMin: Int = 0,
val bonusApps: List<AppLimit> = emptyList()
) {
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()
fun parse(json: String?): Rules {
if (json.isNullOrBlank()) return NONE
return runCatching {
val o = JSONObject(json)
val sch = o.optJSONObject("schedule")
val bonus = o.optJSONObject("bonus")
Rules(
scheduleEnabled = sch?.optBoolean("enabled", false) ?: false,
scheduleCutsMobile = sch?.optBoolean("cutsMobile", true) ?: true,
windows = sch?.optJSONArray("windows").objects().map { w ->
TimeWindow(
days = w.optJSONArray("days").ints().toSet(),
startMin = w.optInt("start", 0),
endMin = w.optInt("end", 0)
)
},
dailyLimitMin = o.optInt("dailyLimitMin", 0),
appLimits = o.optJSONArray("appLimits").objects().map { AppLimit(it.optString("pkg"), it.optInt("min")) },
blocked = o.optJSONArray("blocked").strings().toSet(),
bonusDate = bonus?.optString("date")?.takeIf { it.isNotEmpty() },
bonusGlobalMin = bonus?.optInt("globalMin", 0) ?: 0,
bonusApps = bonus?.optJSONArray("apps").objects().map { AppLimit(it.optString("pkg"), it.optInt("min")) }
)
}.getOrDefault(NONE)
}
private fun JSONArray?.objects(): List<JSONObject> =
if (this == null) emptyList() else (0 until length()).mapNotNull { optJSONObject(it) }
private fun JSONArray?.ints(): List<Int> =
if (this == null) emptyList() else (0 until length()).map { optInt(it) }
private fun JSONArray?.strings(): List<String> =
if (this == null) emptyList() else (0 until length()).map { optString(it) }
}
}
@@ -0,0 +1,116 @@
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.map
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.Executors
/**
* Journal de diagnostic : conservé [RETENTION_DAYS] jours (avec un plafond de [MAX_LINES] lignes),
* en mémoire et dans un fichier qui survit au redémarrage de l'app. Consultable dans l'onglet « Journal ».
*
* Format du fichier : une ligne par entrée, `horodatage-ms<TAB>texte affiché`. Le fichier est complété
* au fil de l'eau puis réécrit (purgé) de temps en temps, pour ne pas le réécrire à chaque ligne.
*/
object AppLog {
const val RETENTION_DAYS = 15
private const val RETENTION_MS = RETENTION_DAYS * 24L * 3600 * 1000
private const val MAX_LINES = 20_000
private const val COMPACT_EVERY_PRUNED = 200
private const val COMPACT_MIN_INTERVAL_MS = 3600_000L
private const val FILE_NAME = "journal.txt"
private class Entry(val ts: Long, val text: String)
private val io = Executors.newSingleThreadExecutor()
private val fmt = SimpleDateFormat("MM-dd HH:mm:ss", Locale.FRANCE)
private val lines = ArrayDeque<Entry>()
/** 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
private var lastCompactAt = 0L
fun init(context: Context) {
if (file != null) return
val f = File(context.filesDir, FILE_NAME)
file = f
synchronized(lines) {
runCatching {
if (f.exists()) f.readLines().forEach { raw ->
val tab = raw.indexOf('\t')
val ts = if (tab > 0) raw.substring(0, tab).toLongOrNull() else null
if (ts != null) lines.addLast(Entry(ts, raw.substring(tab + 1)))
}
}
prune(System.currentTimeMillis())
publish()
compactLocked()
}
i("App", "Journal ouvert (conservation : $RETENTION_DAYS jours)")
}
fun i(tag: String, message: String) = add("I", tag, message)
fun w(tag: String, message: String) = add("W", tag, message)
fun e(tag: String, message: String, t: Throwable? = null) =
add("E", tag, if (t == null) message else "$message — ${t.javaClass.simpleName}: ${t.message}")
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()
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()
if (prunedSinceCompact >= COMPACT_EVERY_PRUNED && now - lastCompactAt > COMPACT_MIN_INTERVAL_MS) {
compactLocked()
} else {
io.execute { runCatching { file?.appendText("${entry.ts}\t${entry.text}\n") } }
}
}
}
/** Retire les entrées plus vieilles que la rétention et au-delà du plafond. À appeler sous verrou. */
private fun prune(now: Long) {
while (lines.isNotEmpty() && (now - lines.first().ts > RETENTION_MS || lines.size > MAX_LINES)) {
lines.removeFirst()
prunedSinceCompact++
}
}
private fun publish() {
version.value++
}
/** Réécrit le fichier avec les seules entrées conservées. À appeler sous verrou. */
private fun compactLocked() {
prunedSinceCompact = 0
lastCompactAt = System.currentTimeMillis()
val snapshot = lines.toList()
io.execute {
runCatching { file?.writeText(snapshot.joinToString("") { "${it.ts}\t${it.text}\n" }) }
}
}
fun clear() {
synchronized(lines) {
lines.clear()
publish()
compactLocked()
}
}
/** Texte à copier/partager : les [maxLines] dernières lignes (le presse-papiers et le partage ont une taille limite). */
fun asText(maxLines: Int = 3000): String =
synchronized(lines) { lines.takeLast(maxLines).joinToString("\n") { it.text } }
}
@@ -16,10 +16,13 @@ import androidx.core.content.ContextCompat
import com.jttools.datacontrol.BuildConfig import com.jttools.datacontrol.BuildConfig
import com.jttools.datacontrol.admin.AdminController import com.jttools.datacontrol.admin.AdminController
import com.jttools.datacontrol.data.SettingsRepository import com.jttools.datacontrol.data.SettingsRepository
import com.jttools.datacontrol.guard.AppGuardService
import com.jttools.datacontrol.guard.GuardEngine
import com.jttools.datacontrol.service.FirewallVpnService import com.jttools.datacontrol.service.FirewallVpnService
import com.jttools.datacontrol.service.MonitorStateHolder import com.jttools.datacontrol.service.MonitorStateHolder
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
@@ -37,7 +40,8 @@ class DeviceReporter @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val remote: RemoteSync, private val remote: RemoteSync,
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val stateHolder: MonitorStateHolder private val stateHolder: MonitorStateHolder,
private val guardEngine: GuardEngine
) { ) {
private var started = false private var started = false
private var lastBatterySent = -1 to false private var lastBatterySent = -1 to false
@@ -46,9 +50,12 @@ class DeviceReporter @Inject constructor(
private var lastStateSignature = "" private var lastStateSignature = ""
private var lastStateSentAt = 0L private var lastStateSentAt = 0L
private var locationRegistered = false private var locationRegistered = false
private var lastGuardEnabled: Boolean? = null
private var lastUsageSentTotal = -1L
private var appsPushJob: Job? = null
fun start(scope: CoroutineScope) { fun start(scope: CoroutineScope) {
if (started || !remote.available) return if (started) return
started = true started = true
remote.start(scope) remote.start(scope)
@@ -62,6 +69,27 @@ class DeviceReporter @Inject constructor(
context, batteryReceiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED), ContextCompat.RECEIVER_NOT_EXPORTED 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 { scope.launch {
while (isActive) { while (isActive) {
val s = settingsRepository.settingsFlow.first() val s = settingsRepository.settingsFlow.first()
@@ -69,6 +97,7 @@ class DeviceReporter @Inject constructor(
registerLocationUpdates(scope) registerLocationUpdates(scope)
pushState() pushState()
pushTraffic() pushTraffic()
pushUsage()
} }
delay(REPORT_INTERVAL_MS) delay(REPORT_INTERVAL_MS)
} }
@@ -94,6 +123,11 @@ class DeviceReporter @Inject constructor(
private suspend fun pushState() { private suspend fun pushState() {
val s = settingsRepository.settingsFlow.first() val s = settingsRepository.settingsFlow.first()
val snap = stateHolder.snapshot.value val snap = stateHolder.snapshot.value
val guardEnabled = AppGuardService.isEnabled(context)
if (lastGuardEnabled == true && !guardEnabled) {
remote.pushEvent("guard_disabled", "Service d'accessibilité désactivé : les limites d'applications ne sont plus appliquées")
}
lastGuardEnabled = guardEnabled
val state = mapOf( val state = mapOf(
"controlEnabled" to s.controlEnabled, "controlEnabled" to s.controlEnabled,
"transport" to snap.transport.name, "transport" to snap.transport.name,
@@ -101,6 +135,11 @@ class DeviceReporter @Inject constructor(
"vpnPrepared" to FirewallVpnService.isPrepared(context), "vpnPrepared" to FirewallVpnService.isPrepared(context),
"tunnelActive" to FirewallVpnService.tunnelActive, "tunnelActive" to FirewallVpnService.tunnelActive,
"adminActive" to AdminController.isActive(context), "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, "consumedMo" to snap.consumedMo,
"planLimitMo" to s.planLimitMo, "planLimitMo" to s.planLimitMo,
"cutoffIntervalMinutes" to s.cutoffIntervalMinutes, "cutoffIntervalMinutes" to s.cutoffIntervalMinutes,
@@ -127,6 +166,14 @@ class DeviceReporter @Inject constructor(
remote.pushTraffic(day) remote.pushTraffic(day)
} }
private suspend fun pushUsage() {
val u = guardEngine.usage()
// Ne renvoie que si le temps d'écran a évolué (ou changement de jour).
if (u.totalMs == lastUsageSentTotal) return
lastUsageSentTotal = u.totalMs
remote.pushUsage(u.date, u.totalMs, u.appMs)
}
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
private fun registerLocationUpdates(scope: CoroutineScope) { private fun registerLocationUpdates(scope: CoroutineScope) {
if (locationRegistered) return if (locationRegistered) return
@@ -171,5 +218,6 @@ class DeviceReporter @Inject constructor(
const val LOCATION_INTERVAL_MS = 5 * 60_000L const val LOCATION_INTERVAL_MS = 5 * 60_000L
const val LOCATION_MIN_DISTANCE_M = 50f const val LOCATION_MIN_DISTANCE_M = 50f
const val LOCATION_MIN_SEND_MS = 60_000L const val LOCATION_MIN_SEND_MS = 60_000L
const val APPS_PUSH_DEBOUNCE_MS = 5_000L
} }
} }
@@ -0,0 +1,29 @@
package com.jttools.datacontrol.remote
import org.json.JSONObject
/** Identifiants d'un projet Firebase (ceux du bloc `firebaseConfig` de la console). */
data class FirebaseConfig(val apiKey: String, val appId: String, val projectId: String) {
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.
*/
fun parse(input: String): FirebaseConfig? {
val text = input.trim()
if (text.startsWith("fc1:")) {
return runCatching {
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"))
}
private fun build(apiKey: String?, appId: String?, projectId: String?): FirebaseConfig? =
if (apiKey.isNullOrBlank() || appId.isNullOrBlank() || projectId.isNullOrBlank()) null
else FirebaseConfig(apiKey.trim(), appId.trim(), projectId.trim())
}
}
@@ -6,37 +6,65 @@ import com.google.firebase.FirebaseOptions
import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.FirebaseFirestore
import com.jttools.datacontrol.BuildConfig import com.jttools.datacontrol.BuildConfig
import com.jttools.datacontrol.log.AppLog
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.tasks.await import kotlinx.coroutines.tasks.await
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** État du lien Firebase : [generation] change à chaque (ré)initialisation, pour relancer les écoutes. */
data class GatewayState(val generation: Int = 0, val enabled: Boolean = false, val projectId: String? = null)
/** /**
* Accès à Firebase (Auth anonyme + Firestore) initialisé depuis `firebase.properties` * Accès à Firebase (Auth anonyme + Firestore). La configuration se saisit dans l'application
* (voir build.gradle.kts), sans plugin google-services : si la config est absente, * (Réglages → Contrôle parental) et peut changer à tout moment ; à défaut, on retombe sur
* [enabled] est faux et toutes les fonctions distantes restent inertes. * `firebase.properties` (compilation). Sans configuration, [enabled] est faux et tout reste inerte.
*/ */
@Singleton @Singleton
class FirebaseGateway @Inject constructor( class FirebaseGateway @Inject constructor(
@ApplicationContext private val context: Context @ApplicationContext private val context: Context
) { ) {
val enabled: Boolean = BuildConfig.FIREBASE_API_KEY.isNotBlank() && private val _state = MutableStateFlow(GatewayState())
BuildConfig.FIREBASE_APP_ID.isNotBlank() && val state: StateFlow<GatewayState> = _state.asStateFlow()
BuildConfig.FIREBASE_PROJECT_ID.isNotBlank() val enabled: Boolean get() = _state.value.enabled
private val app: FirebaseApp? by lazy { @Volatile private var app: FirebaseApp? = null
if (!enabled) return@lazy null private var currentKey: String? = null
runCatching {
FirebaseApp.getApps(context).firstOrNull() private fun buildConfigFallback(): FirebaseConfig? =
?: FirebaseApp.initializeApp( if (BuildConfig.FIREBASE_API_KEY.isNotBlank() && BuildConfig.FIREBASE_APP_ID.isNotBlank() &&
BuildConfig.FIREBASE_PROJECT_ID.isNotBlank()
) FirebaseConfig(BuildConfig.FIREBASE_API_KEY, BuildConfig.FIREBASE_APP_ID, BuildConfig.FIREBASE_PROJECT_ID)
else null
/** Applique la configuration saisie [saved] (ou, à défaut, celle de compilation). Sans effet si elle n'a pas changé. */
@Synchronized
fun apply(saved: FirebaseConfig?) {
val cfg = saved ?: buildConfigFallback()
val key = cfg?.let { "${it.apiKey}|${it.appId}|${it.projectId}" }
if (key == currentKey && (key == null || app != null)) return
app?.let { runCatching { it.delete() } }
app = null
currentKey = key
if (cfg != null) {
app = runCatching {
FirebaseApp.initializeApp(
context, context,
FirebaseOptions.Builder() FirebaseOptions.Builder()
.setApiKey(BuildConfig.FIREBASE_API_KEY) .setApiKey(cfg.apiKey)
.setApplicationId(BuildConfig.FIREBASE_APP_ID) .setApplicationId(cfg.appId)
.setProjectId(BuildConfig.FIREBASE_PROJECT_ID) .setProjectId(cfg.projectId)
.build() .build()
) )
}.getOrNull() }.onFailure { AppLog.e("Firebase", "Initialisation impossible", it) }.getOrNull()
}
val ok = app != null
AppLog.i("Firebase", if (ok) "Configuré (projet ${cfg?.projectId})" else "Non configuré")
_state.value = GatewayState(_state.value.generation + 1, ok, cfg?.projectId?.takeIf { ok })
} }
val db: FirebaseFirestore? get() = app?.let { FirebaseFirestore.getInstance(it) } val db: FirebaseFirestore? get() = app?.let { FirebaseFirestore.getInstance(it) }
@@ -46,6 +74,8 @@ class FirebaseGateway @Inject constructor(
suspend fun ensureSignedIn(): String? { suspend fun ensureSignedIn(): String? {
val a = auth ?: return null val a = auth ?: return null
a.currentUser?.let { return it.uid } a.currentUser?.let { return it.uid }
return runCatching { a.signInAnonymously().await().user?.uid }.getOrNull() return runCatching { a.signInAnonymously().await().user?.uid }
.onFailure { AppLog.w("Firebase", "Connexion anonyme impossible : ${it.message}") }
.getOrNull()
} }
} }
@@ -8,7 +8,10 @@ import com.jttools.datacontrol.data.AppSettings
import com.jttools.datacontrol.data.InstalledAppsRepository import com.jttools.datacontrol.data.InstalledAppsRepository
import com.jttools.datacontrol.data.SettingsRepository import com.jttools.datacontrol.data.SettingsRepository
import com.jttools.datacontrol.data.TrafficDay import com.jttools.datacontrol.data.TrafficDay
import com.jttools.datacontrol.log.AppLog
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
@@ -19,6 +22,7 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withTimeoutOrNull
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -43,6 +47,7 @@ class RemoteSync @Inject constructor(
private val installedApps: InstalledAppsRepository private val installedApps: InstalledAppsRepository
) { ) {
val available: Boolean get() = gateway.enabled val available: Boolean get() = gateway.enabled
val gatewayState = gateway.state
private fun deviceDoc(s: AppSettings): DocumentReference? { private fun deviceDoc(s: AppSettings): DocumentReference? {
val db = gateway.db ?: return null val db = gateway.db ?: return null
@@ -71,6 +76,10 @@ class RemoteSync @Inject constructor(
"pairedAt" to System.currentTimeMillis() "pairedAt" to System.currentTimeMillis()
) )
).await() ).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) settingsRepository.setPairing(familyId, deviceId)
} }
@@ -81,27 +90,59 @@ class RemoteSync @Inject constructor(
settingsRepository.setPairing(null, null) settingsRepository.setPairing(null, null)
} }
/** Écoute les consignes du parent et les applique aux réglages locaux. */ /** Écoute les consignes du parent et les applique ; relancé à chaque (ré)initialisation de Firebase. */
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
fun start(scope: CoroutineScope) { fun start(scope: CoroutineScope) {
if (!gateway.enabled) return
scope.launch { scope.launch {
settingsRepository.settingsFlow gateway.state.collectLatest { st ->
.map { it.familyId to it.deviceId } if (!st.enabled) return@collectLatest
.distinctUntilChanged() coroutineScope {
.flatMapLatest { (fid, did) -> launch {
if (fid == null || did == null) flowOf(null) else configFlow(fid, did) settingsRepository.settingsFlow
.map { it.familyId to it.deviceId }
.distinctUntilChanged()
.flatMapLatest { (fid, did) ->
if (fid == null || did == null) flowOf(null) else configFlow(fid, did)
}
.collect { cfg -> if (cfg != null) apply(cfg) }
}
launch {
// Si le parent supprime l'appareil de la famille, on se dissocie localement (plus de règles,
// plus d'envois qui échoueraient en silence).
settingsRepository.settingsFlow
.map { it.familyId to it.deviceId }
.distinctUntilChanged()
.flatMapLatest { (fid, did) -> if (fid == null || did == null) flowOf(null) else existenceFlow(fid, did) }
.collect { exists ->
if (exists == false) {
AppLog.w("FamilyControl", "Appareil supprimé par le parent : dissociation locale")
settingsRepository.setPairing(null, null)
}
}
}
launch {
// Liste des applications installées, pour que le parent puisse choisir lesquelles autoriser.
settingsRepository.settingsFlow.map { it.isPaired }.distinctUntilChanged().collect { paired ->
if (paired) pushApps()
}
}
} }
.collect { cfg -> if (cfg != null) apply(cfg) }
}
scope.launch {
// Liste des applications installées, pour que le parent puisse choisir lesquelles autoriser.
settingsRepository.settingsFlow.map { it.isPaired }.distinctUntilChanged().collect { paired ->
if (paired) pushApps()
} }
} }
} }
/** Vrai/faux selon que le document de l'appareil existe côté serveur (ignore le cache local). */
private fun existenceFlow(fid: String, did: String) = callbackFlow<Boolean?> {
val db = gateway.db
if (db == null || gateway.ensureSignedIn() == null) { trySend(null); close(); return@callbackFlow }
val reg = db.collection("families").document(fid).collection("devices").document(did)
.addSnapshotListener { snap, err ->
if (err != null || snap == null || snap.metadata.isFromCache) return@addSnapshotListener
trySend(snap.exists())
}
awaitClose { reg.remove() }
}
private class Config(val map: Map<String, Any?>) private class Config(val map: Map<String, Any?>)
private fun configFlow(fid: String, did: String) = callbackFlow<Config?> { private fun configFlow(fid: String, did: String) = callbackFlow<Config?> {
@@ -127,11 +168,13 @@ class RemoteSync @Inject constructor(
whitelistedPackagesWifi = strings("whitelistedPackagesWifi"), whitelistedPackagesWifi = strings("whitelistedPackagesWifi"),
wifiFilterEnabled = m["wifiFilterEnabled"] as? Boolean, wifiFilterEnabled = m["wifiFilterEnabled"] as? Boolean,
planLimitMo = (m["planLimitMo"] as? Number)?.toLong(), planLimitMo = (m["planLimitMo"] as? Number)?.toLong(),
cutoffIntervalMinutes = (m["cutoffIntervalMinutes"] as? Number)?.toInt() cutoffIntervalMinutes = (m["cutoffIntervalMinutes"] as? Number)?.toInt(),
rulesJson = (m["rules"] as? Map<*, *>)?.let { org.json.JSONObject(it).toString() }
) )
} }
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 s = settingsRepository.settingsFlow.first()
val ref = deviceDoc(s) ?: return val ref = deviceDoc(s) ?: return
val apps = installedApps.listInstalledApps(withIcons = false).map { val apps = installedApps.listInstalledApps(withIcons = false).map {
@@ -157,13 +200,27 @@ class RemoteSync @Inject constructor(
) )
} }
suspend fun pushUsage(date: String, totalMs: Long, appMs: Map<String, Long>) {
val s = settingsRepository.settingsFlow.first()
val ref = deviceDoc(s) ?: return
write(
ref.collection("usage").document(date),
mapOf("date" to date, "totalMs" to totalMs, "apps" to appMs, "at" to System.currentTimeMillis())
)
}
suspend fun pushEvent(type: String, detail: String) { suspend fun pushEvent(type: String, detail: String) {
val s = settingsRepository.settingsFlow.first() val s = settingsRepository.settingsFlow.first()
val ref = deviceDoc(s) ?: return 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 { runCatching {
ref.collection("events").add( withTimeoutOrNull(EVENT_TIMEOUT_MS) {
mapOf("type" to type, "detail" to detail, "at" to System.currentTimeMillis()) ref.collection("events").add(
) mapOf("type" to type, "detail" to detail, "at" to System.currentTimeMillis())
).await()
}
} }
} }
@@ -173,5 +230,8 @@ class RemoteSync @Inject constructor(
ref.set(data, SetOptions.merge()) 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
}
} }
@@ -5,6 +5,7 @@ import android.net.ConnectivityManager
import android.net.Network import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.NetworkRequest import android.net.NetworkRequest
import com.jttools.datacontrol.log.AppLog
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -42,7 +43,7 @@ class ConnectivityObserver @Inject constructor(
fun publish() { fun publish() {
val values = synchronized(physical) { physical.values.toList() } val values = synchronized(physical) { physical.values.toList() }
// Android privilégie le Wi-Fi quand les deux sont connectés. // Android privilégie le Wi-Fi quand les deux sont connectés.
trySend( val result = (
when { when {
NetworkTransport.WIFI in values -> NetworkTransport.WIFI NetworkTransport.WIFI in values -> NetworkTransport.WIFI
NetworkTransport.CELLULAR in values -> NetworkTransport.CELLULAR NetworkTransport.CELLULAR in values -> NetworkTransport.CELLULAR
@@ -50,12 +51,14 @@ class ConnectivityObserver @Inject constructor(
else -> NetworkTransport.NONE else -> NetworkTransport.NONE
} }
) )
trySend(result)
} }
val callback = object : ConnectivityManager.NetworkCallback() { val callback = object : ConnectivityManager.NetworkCallback() {
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) { override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return // notre propre tunnel : ignoré
synchronized(physical) { physical[network] = transportOf(caps) } synchronized(physical) { physical[network] = transportOf(caps) }
AppLog.i("Réseau", "réseau physique ${transportOf(caps)} (caps mis à jour)")
publish() publish()
} }
@@ -1,9 +1,17 @@
package com.jttools.datacontrol.service package com.jttools.datacontrol.service
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.jttools.datacontrol.MainActivity
import com.jttools.datacontrol.R
import com.jttools.datacontrol.data.SettingsRepository import com.jttools.datacontrol.data.SettingsRepository
import com.jttools.datacontrol.remote.RemoteSync
import dagger.hilt.EntryPoint import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors 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 * (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 * 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. * 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( class DataMonitorRestartWorker(
context: Context, context: Context,
@@ -26,17 +38,47 @@ class DataMonitorRestartWorker(
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface RestartWorkerEntryPoint { interface RestartWorkerEntryPoint {
fun settingsRepository(): SettingsRepository fun settingsRepository(): SettingsRepository
fun remoteSync(): RemoteSync
} }
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
val appContext = applicationContext val appContext = applicationContext
val entryPoint = EntryPointAccessors.fromApplication(appContext, RestartWorkerEntryPoint::class.java) val entryPoint = EntryPointAccessors.fromApplication(appContext, RestartWorkerEntryPoint::class.java)
val enabled = entryPoint.settingsRepository().settingsFlow.first().controlEnabled 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() 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 { companion object {
const val WORK_NAME = "data_monitor_restart_watchdog" 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.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.graphics.Color import android.graphics.Color
import android.os.Build import android.os.SystemClock
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleService import androidx.lifecycle.LifecycleService
@@ -20,6 +20,8 @@ import com.jttools.datacontrol.data.AppSettings
import com.jttools.datacontrol.data.DataState import com.jttools.datacontrol.data.DataState
import com.jttools.datacontrol.data.SettingsRepository import com.jttools.datacontrol.data.SettingsRepository
import com.jttools.datacontrol.data.UsageRepository import com.jttools.datacontrol.data.UsageRepository
import com.jttools.datacontrol.guard.GuardEngine
import com.jttools.datacontrol.log.AppLog
import com.jttools.datacontrol.remote.DeviceReporter import com.jttools.datacontrol.remote.DeviceReporter
import com.jttools.datacontrol.remote.RemoteSync import com.jttools.datacontrol.remote.RemoteSync
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@@ -49,10 +51,18 @@ class DataMonitorService : LifecycleService() {
@Inject lateinit var stateHolder: MonitorStateHolder @Inject lateinit var stateHolder: MonitorStateHolder
@Inject lateinit var deviceReporter: DeviceReporter @Inject lateinit var deviceReporter: DeviceReporter
@Inject lateinit var remoteSync: RemoteSync @Inject lateinit var remoteSync: RemoteSync
@Inject lateinit var guardEngine: GuardEngine
private var latestSettings: AppSettings = AppSettings() private var latestSettings: AppSettings = AppSettings()
private var latestTransport: NetworkTransport = NetworkTransport.NONE 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 lastAppliedMode: VpnMode? = null
private var lastAppliedWhitelist: Set<String> = emptySet() private var lastAppliedWhitelist: Set<String> = emptySet()
@@ -63,14 +73,16 @@ class DataMonitorService : LifecycleService() {
private var pulseStartMillis: Long = 0L private var pulseStartMillis: Long = 0L
private var settingsLoaded = false private var settingsLoaded = false
private var lastControlEnabled: Boolean? = null private var lastControlEnabled: Boolean? = null
private var lastDiagnostic = ""
private val vpnRevokedReceiver = object : BroadcastReceiver() { private val vpnRevokedReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { 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 { lifecycleScope.launch {
settingsRepository.setControlEnabled(false) remoteSync.pushEvent("vpn_revoked", "Autorisation VPN retirée : données mobiles non filtrées tant qu'elle n'est pas rétablie")
remoteSync.pushEvent("vpn_revoked", "Autorisation VPN retirée : le filtrage est désactivé")
} }
tick()
} }
} }
@@ -90,6 +102,7 @@ class DataMonitorService : LifecycleService() {
ContextCompat.RECEIVER_NOT_EXPORTED ContextCompat.RECEIVER_NOT_EXPORTED
) )
startForeground(NOTIF_ID, buildNotification(stateHolder.snapshot.value)) startForeground(NOTIF_ID, buildNotification(stateHolder.snapshot.value))
AppLog.i("Service", "DataMonitorService démarré")
lifecycleScope.launch { lifecycleScope.launch {
settingsRepository.settingsFlow.collect { settingsRepository.settingsFlow.collect {
@@ -112,10 +125,11 @@ class DataMonitorService : LifecycleService() {
} }
} }
lastControlEnabled = it.controlEnabled lastControlEnabled = it.controlEnabled
if (!it.controlEnabled) vpnRevoked = false if (!it.controlEnabled) vpnRevokedAt = null
tick() tick()
} }
} }
guardEngine.start()
deviceReporter.start(lifecycleScope) deviceReporter.start(lifecycleScope)
lifecycleScope.launch { lifecycleScope.launch {
connectivityObserver.observe().collect { connectivityObserver.observe().collect {
@@ -173,7 +187,7 @@ class DataMonitorService : LifecycleService() {
when { when {
latestTransport == NetworkTransport.WIFI -> { latestTransport == NetworkTransport.WIFI -> {
if (settings.wifiFilterEnabled && FirewallVpnService.isPrepared(this)) { if (settings.wifiFilterEnabled && !vpnRevoked && FirewallVpnService.isPrepared(this)) {
applyVpnMode(VpnMode.FILTER, settings.whitelistedPackagesWifi) applyVpnMode(VpnMode.FILTER, settings.whitelistedPackagesWifi)
} else { } else {
applyVpnMode(VpnMode.STOPPED, emptySet()) applyVpnMode(VpnMode.STOPPED, emptySet())
@@ -182,37 +196,47 @@ class DataMonitorService : LifecycleService() {
dataState = DataState.WIFI dataState = DataState.WIFI
} }
latestTransport == NetworkTransport.CELLULAR && !vpnRevoked && FirewallVpnService.isPrepared(this) -> { latestTransport == NetworkTransport.CELLULAR && !vpnRevoked && FirewallVpnService.isPrepared(this) -> {
// La coupure cyclique tourne en continu sur données mobiles, indépendamment du if (guardEngine.cutsMobileNow()) {
// dépassement du forfait : elle ne doit pas attendre le hors-forfait pour se déclencher. // Hors plage horaire autorisée : couper les données mobiles (règle du parent).
val now = System.currentTimeMillis() applyVpnMode(VpnMode.BLOCK_ALL, emptySet())
val intervalMs = settings.cutoffIntervalMinutes * 60_000L resetCutoffCycle()
if (cutoffCycleAnchorMillis == null) { firewallFault = !FirewallVpnService.tunnelActive &&
cutoffCycleAnchorMillis = now System.currentTimeMillis() - lastApplyMillis > TUNNEL_RETRY_DELAY_MS
persistCutoffState() dataState = DataState.CUT
} } else {
val anchor = cutoffCycleAnchorMillis!! // La coupure cyclique tourne en continu sur données mobiles, indépendamment du
// dépassement du forfait : elle ne doit pas attendre le hors-forfait pour se déclencher.
if (inPulse) { val now = System.currentTimeMillis()
if (now - pulseStartMillis >= PULSE_DURATION_MS) { val intervalMs = settings.cutoffIntervalMinutes * 60_000L
inPulse = false if (cutoffCycleAnchorMillis == null) {
cutoffCycleAnchorMillis = now cutoffCycleAnchorMillis = now
persistCutoffState() persistCutoffState()
applyVpnMode(VpnMode.FILTER, settings.whitelistedPackages)
} else {
applyVpnMode(VpnMode.BLOCK_ALL, emptySet())
} }
} else if (now - anchor >= intervalMs) { val anchor = cutoffCycleAnchorMillis!!
inPulse = true
pulseStartMillis = now if (inPulse) {
persistCutoffState() if (now - pulseStartMillis >= PULSE_DURATION_MS) {
applyVpnMode(VpnMode.BLOCK_ALL, emptySet()) inPulse = false
} else { cutoffCycleAnchorMillis = now
applyVpnMode(VpnMode.FILTER, settings.whitelistedPackages) persistCutoffState()
applyVpnMode(VpnMode.FILTER, settings.whitelistedPackages)
} else {
applyVpnMode(VpnMode.BLOCK_ALL, emptySet())
}
} else if (now - anchor >= intervalMs) {
inPulse = true
pulseStartMillis = now
persistCutoffState()
applyVpnMode(VpnMode.BLOCK_ALL, emptySet())
} else {
applyVpnMode(VpnMode.FILTER, settings.whitelistedPackages)
}
nextCutoffAt = if (inPulse) pulseStartMillis + PULSE_DURATION_MS else anchor + intervalMs
firewallFault = !FirewallVpnService.tunnelActive &&
System.currentTimeMillis() - lastApplyMillis > TUNNEL_RETRY_DELAY_MS
dataState = if (inPulse) DataState.CUT else if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK
} }
nextCutoffAt = if (inPulse) pulseStartMillis + PULSE_DURATION_MS else anchor + intervalMs
firewallFault = !FirewallVpnService.tunnelActive &&
System.currentTimeMillis() - lastApplyMillis > TUNNEL_RETRY_DELAY_MS
dataState = if (inPulse) DataState.CUT else if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK
} }
else -> { else -> {
// Pas de réseau, transport inconnu, ou VPN non préparé/révoqué : rien à filtrer. // Pas de réseau, transport inconnu, ou VPN non préparé/révoqué : rien à filtrer.
@@ -224,6 +248,8 @@ class DataMonitorService : LifecycleService() {
} }
} }
logDiagnostic(settings)
stateHolder.update { stateHolder.update {
it.copy( it.copy(
consumedMo = consumedMo, consumedMo = consumedMo,
@@ -241,6 +267,27 @@ class DataMonitorService : LifecycleService() {
updateNotification(stateHolder.snapshot.value) updateNotification(stateHolder.snapshot.value)
} }
/** Écrit dans le journal POURQUOI le pare-feu est (ou n'est pas) actif, à chaque changement de situation. */
private fun logDiagnostic(settings: AppSettings) {
val prepared = FirewallVpnService.isPrepared(this)
val expected = when {
!settings.controlEnabled -> "contrôle désactivé"
latestTransport == NetworkTransport.WIFI ->
if (settings.wifiFilterEnabled && prepared) "Wi-Fi filtré" else "Wi-Fi : pas de filtrage (normal, le VPN ne se monte que sur données mobiles)"
latestTransport == NetworkTransport.CELLULAR ->
if (!prepared) "données mobiles mais autorisation VPN absente" else if (vpnRevoked) "données mobiles mais VPN révoqué" else "données mobiles : pare-feu attendu"
else -> "aucun réseau actif (${latestTransport})"
}
val guard = com.jttools.datacontrol.guard.AppGuardService.isEnabled(this)
val diag = "réseau=$latestTransport contrôle=${settings.controlEnabled} vpnAutorisé=$prepared " +
"tunnelMonté=${FirewallVpnService.tunnelActive} accessibilité=$guard révoqué=$vpnRevoked " +
"mode=${lastAppliedMode ?: "—"} → $expected"
if (diag != lastDiagnostic) {
lastDiagnostic = diag
AppLog.i("Pare-feu", diag)
}
}
private fun resetCutoffCycle() { private fun resetCutoffCycle() {
if (cutoffCycleAnchorMillis == null && !inPulse) return if (cutoffCycleAnchorMillis == null && !inPulse) return
cutoffCycleAnchorMillis = null cutoffCycleAnchorMillis = null
@@ -272,27 +319,33 @@ class DataMonitorService : LifecycleService() {
lastAppliedMode = mode lastAppliedMode = mode
lastAppliedWhitelist = whitelist lastAppliedWhitelist = whitelist
lastApplyMillis = System.currentTimeMillis() lastApplyMillis = System.currentTimeMillis()
when (mode) { AppLog.i("Pare-feu", "→ mode $mode (${whitelist.size} app(s) autorisée(s))")
VpnMode.STOPPED -> ContextCompat.startForegroundService(this, FirewallVpnService.stopIntent(this)) stateHolder.update { it.copy(appliedMode = mode.name, effectiveWhitelist = whitelist) }
VpnMode.FILTER -> ContextCompat.startForegroundService( // Une exception ici (démarrage de service de premier plan refusé, par ex.) ne doit ni faire
this, FirewallVpnService.startFilterIntent(this, whitelist) // planter l'application ni rester invisible : elle est tracée dans le journal.
) try {
VpnMode.BLOCK_ALL -> ContextCompat.startForegroundService( when (mode) {
this, FirewallVpnService.startBlockAllIntent(this) VpnMode.STOPPED -> ContextCompat.startForegroundService(this, FirewallVpnService.stopIntent(this))
) VpnMode.FILTER -> ContextCompat.startForegroundService(
this, FirewallVpnService.startFilterIntent(this, whitelist)
)
VpnMode.BLOCK_ALL -> ContextCompat.startForegroundService(
this, FirewallVpnService.startBlockAllIntent(this)
)
}
} catch (e: Exception) {
AppLog.e("Pare-feu", "Impossible de démarrer FirewallVpnService pour le mode $mode", e)
} }
} }
private fun buildNotification(snapshot: MonitorSnapshot): Notification { private fun buildNotification(snapshot: MonitorSnapshot): Notification {
val nm = getSystemService(NotificationManager::class.java) val nm = getSystemService(NotificationManager::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel(
val channel = NotificationChannel( CHANNEL_ID,
CHANNEL_ID, getString(R.string.notification_channel_name),
getString(R.string.notification_channel_name), NotificationManager.IMPORTANCE_LOW
NotificationManager.IMPORTANCE_LOW ).apply { description = getString(R.string.notification_channel_description) }
).apply { description = getString(R.string.notification_channel_description) } nm.createNotificationChannel(channel)
nm.createNotificationChannel(channel)
}
val (emoji, stateLabel, color) = when { val (emoji, stateLabel, color) = when {
snapshot.firewallFault -> Triple("⚠️", getString(R.string.dashboard_state_warning), Color.parseColor("#C62828")) snapshot.firewallFault -> Triple("⚠️", getString(R.string.dashboard_state_warning), Color.parseColor("#C62828"))
@@ -306,7 +359,7 @@ class DataMonitorService : LifecycleService() {
val percentText = if (snapshot.planLimitMo > 0) { val percentText = if (snapshot.planLimitMo > 0) {
"${(snapshot.consumedMo * 100 / snapshot.planLimitMo)}%" "${(snapshot.consumedMo * 100 / snapshot.planLimitMo)}%"
} else "—" } 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 warn = if (snapshot.firewallFault) "⚠️ PARE-FEU INACTIF — données non filtrées · " else ""
val usageText = "$warn$emoji $stateLabel · ${snapshot.consumedMo}/${snapshot.planLimitMo} Mo ($percentText)" val usageText = "$warn$emoji $stateLabel · ${snapshot.consumedMo}/${snapshot.planLimitMo} Mo ($percentText)"
@@ -349,10 +402,19 @@ class DataMonitorService : LifecycleService() {
private const val TICK_INTERVAL_MS = 20_000L private const val TICK_INTERVAL_MS = 20_000L
private const val PULSE_DURATION_MS = 30_000L private const val PULSE_DURATION_MS = 30_000L
private const val TUNNEL_RETRY_DELAY_MS = 5_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 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)) 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) { fun stop(context: Context) {
@@ -8,12 +8,12 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.VpnService import android.net.VpnService
import android.os.Build
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import com.jttools.datacontrol.MainActivity import com.jttools.datacontrol.MainActivity
import com.jttools.datacontrol.R import com.jttools.datacontrol.R
import com.jttools.datacontrol.log.AppLog
import java.io.FileInputStream import java.io.FileInputStream
/** /**
@@ -37,9 +37,15 @@ class FirewallVpnService : VpnService() {
// cf. DataMonitorService.applyVpnMode) : le système impose un appel à startForeground() // cf. DataMonitorService.applyVpnMode) : le système impose un appel à startForeground()
// dans les 5 secondes qui suivent, sous peine de crash (ForegroundServiceDidNotStartInTimeException // dans les 5 secondes qui suivent, sous peine de crash (ForegroundServiceDidNotStartInTimeException
// sur Android 12+), même si le service s'arrête immédiatement après. // sur Android 12+), même si le service s'arrête immédiatement après.
startForeground(NOTIF_ID, buildNotification()) try {
startForeground(NOTIF_ID, buildNotification())
} catch (e: Exception) {
AppLog.e(TAG, "startForeground() refusé", e)
}
AppLog.i(TAG, "onStartCommand action=${intent?.action?.substringAfterLast('.') ?: "null (relance système)"}")
when (intent?.action) { when (intent?.action) {
ACTION_STOP -> { ACTION_STOP -> {
AppLog.i(TAG, "Arrêt du tunnel demandé")
stopTunnel() stopTunnel()
stopSelf() stopSelf()
return START_NOT_STICKY return START_NOT_STICKY
@@ -54,6 +60,7 @@ class FirewallVpnService : VpnService() {
private fun establishTunnel(allowedPackages: Set<String>) { private fun establishTunnel(allowedPackages: Set<String>) {
stopTunnel(keepForeground = true) stopTunnel(keepForeground = true)
AppLog.i(TAG, "Établissement du tunnel : ${allowedPackages.size} application(s) exclue(s) du filtrage")
val builder = Builder() val builder = Builder()
.setSession("DataControl") .setSession("DataControl")
@@ -70,10 +77,15 @@ class FirewallVpnService : VpnService() {
tunInterface = runCatching { builder.establish() }.getOrElse { tunInterface = runCatching { builder.establish() }.getOrElse {
Log.e(TAG, "Échec établissement du tunnel VPN", it) Log.e(TAG, "Échec établissement du tunnel VPN", it)
AppLog.e(TAG, "establish() a échoué", it)
null null
} }
val fd = tunInterface ?: return val fd = tunInterface ?: run {
AppLog.e(TAG, "Tunnel NON monté : establish() a renvoyé null (autorisation VPN retirée ou autre VPN prioritaire)")
return
}
AppLog.i(TAG, "Tunnel VPN monté (l'icône clé doit apparaître)")
running = true running = true
tunnelActive = true tunnelActive = true
readerThread = Thread({ blackhole(fd) }, "datacontrol-vpn-blackhole").apply { readerThread = Thread({ blackhole(fd) }, "datacontrol-vpn-blackhole").apply {
@@ -117,6 +129,7 @@ class FirewallVpnService : VpnService() {
} }
override fun onRevoke() { override fun onRevoke() {
AppLog.w(TAG, "onRevoke : le système ou un autre VPN a repris la main")
stopTunnel() stopTunnel()
sendBroadcast(Intent(ACTION_VPN_REVOKED).setPackage(packageName)) sendBroadcast(Intent(ACTION_VPN_REVOKED).setPackage(packageName))
super.onRevoke() super.onRevoke()
@@ -129,14 +142,12 @@ class FirewallVpnService : VpnService() {
private fun buildNotification(): Notification { private fun buildNotification(): Notification {
val nm = getSystemService(NotificationManager::class.java) val nm = getSystemService(NotificationManager::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel(
val channel = NotificationChannel( CHANNEL_ID,
CHANNEL_ID, getString(R.string.notification_channel_name),
getString(R.string.notification_channel_name), NotificationManager.IMPORTANCE_MIN
NotificationManager.IMPORTANCE_MIN )
) nm.createNotificationChannel(channel)
nm.createNotificationChannel(channel)
}
val openApp = PendingIntent.getActivity( val openApp = PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java), this, 0, Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_IMMUTABLE
@@ -18,7 +18,10 @@ data class MonitorSnapshot(
val serviceRunning: Boolean = false, val serviceRunning: Boolean = false,
/** Contrôle activé sur données mobiles mais tunnel VPN non monté : les données ne sont PAS filtrées. */ /** Contrôle activé sur données mobiles mais tunnel VPN non monté : les données ne sont PAS filtrées. */
val firewallFault: Boolean = false, val firewallFault: Boolean = false,
val transport: NetworkTransport = NetworkTransport.NONE val transport: NetworkTransport = NetworkTransport.NONE,
/** Mode réellement appliqué au pare-feu : FILTER, BLOCK_ALL ou STOPPED. */
val appliedMode: String = "STOPPED",
val effectiveWhitelist: Set<String> = emptySet()
) )
/** /**
@@ -73,6 +73,12 @@ class SystemActions(private val activity: ComponentActivity) {
backgroundLocationLauncher.launch(android.Manifest.permission.ACCESS_BACKGROUND_LOCATION) backgroundLocationLauncher.launch(android.Manifest.permission.ACCESS_BACKGROUND_LOCATION)
} }
fun isGuardEnabled(): Boolean = com.jttools.datacontrol.guard.AppGuardService.isEnabled(activity)
fun openAccessibilitySettings() {
activity.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
}
fun isAdminActive(): Boolean = com.jttools.datacontrol.admin.AdminController.isActive(activity) fun isAdminActive(): Boolean = com.jttools.datacontrol.admin.AdminController.isActive(activity)
fun requestAdmin(onResult: (Boolean) -> Unit) { fun requestAdmin(onResult: (Boolean) -> Unit) {
@@ -0,0 +1,135 @@
package com.jttools.datacontrol.ui.about
/**
* Contenu des sous-onglets de « À propos ». Chaque affirmation a été relevée dans le code, le README
* ou le CHANGELOG du dépôt (voir le compte rendu de la refonte) : ne rien ajouter ici qui ne s'y vérifie pas.
* Le journal, lui, n'est pas dans ce fichier : il est lu depuis CHANGELOG.md (cf. ChangelogParser).
*/
data class AboutEntry(val title: String, val body: String)
data class AboutSection(val heading: String?, val entries: List<AboutEntry>)
object AboutContent {
val features = listOf(
AboutSection("Données mobiles", listOf(
AboutEntry("Suivi de la consommation", "Mesure les Mo consommés depuis le début du cycle, sans permission spéciale (compteur système TrafficStats)."),
AboutEntry("Forfait et coût estimé", "Volume, prix, prix par Mo hors-forfait et date de début se règlent ; le total estimé se met à jour en direct, notification comprise."),
AboutEntry("Réinitialisation", "Automatique chaque mois à la date anniversaire, ou manuelle depuis le tableau de bord. La consommation peut aussi être recalée à la main sur le décompte de l'opérateur."),
AboutEntry("Liste blanche", "Sur données mobiles, seules les applications cochées ont accès à Internet."),
AboutEntry("Filtrage Wi-Fi optionnel", "Le Wi-Fi n'est jamais filtré par défaut ; une liste blanche Wi-Fi distincte peut être activée."),
AboutEntry("Coupure cyclique", "Toutes les X minutes (réglable), une coupure totale de 30 secondes s'applique aux données mobiles, liste blanche comprise."),
AboutEntry("Notification permanente", "Consommation, état en couleur et total estimé. Une alerte « PARE-FEU INACTIF » s'affiche si les données mobiles ne sont plus filtrées."),
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 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.")
)),
AboutSection("Contrôle parental (avec FamilyControl)", listOf(
AboutEntry("Appairage par code", "L'appareil s'associe à l'application parent FamilyControl avec un code d'appairage."),
AboutEntry("Suivi à distance", "Batterie, position (arrière-plan compris si autorisé), état du pare-feu, trafic quotidien mobile/Wi-Fi et journal d'événements."),
AboutEntry("Accès réseau par application", "Le parent autorise ou bloque Internet application par application, sur données mobiles comme sur Wi-Fi."),
AboutEntry("Plages horaires, temps d'écran, applications interdites", "Appliqués par un service d'accessibilité : verrouillage hors plage, limite quotidienne globale ou par application, ajout de temps, lancement refusé."),
AboutEntry("Condition", "Ces fonctions demandent une configuration Firebase saisie dans Réglages → Contrôle parental, puis l'association à un compte parent.")
))
)
val howTo = listOf(
"Ouvrez « Réglages » et renseignez le forfait : volume (Mo), prix, prix par Mo hors-forfait, intervalle de coupure. Touchez « Enregistrer ».",
"Ouvrez « Liste blanche » et cochez les applications qui doivent garder Internet en données mobiles.",
"Dans « Réglages » → « Autorisations », accordez « Pare-feu local (VPN) », « Notifications » et « Optimisation de la batterie ».",
"Sur le « Tableau de bord », activez « Activer le contrôle » et acceptez la demande VPN d'Android.",
"Coupez le Wi-Fi : l'icône clé du VPN apparaît en données mobiles et la notification affiche l'état.",
"Recommandé : définissez un mot de passe dans « Réglages » → « Sécurité »."
)
val everyday = listOf(
AboutEntry("Consulter la consommation", "Le Tableau de bord affiche les Mo consommés, le restant, le dépassement et le total estimé."),
AboutEntry("Repartir à zéro", "« Réinitialiser le forfait maintenant » sur le Tableau de bord, ou « Consommation actuelle » dans les Réglages pour recaler sur l'opérateur."),
AboutEntry("Changer une application autorisée", "Onglet « Liste blanche » : onglets « Données mobiles » et « Wi-Fi », recherche et sélection multiple.")
)
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é. 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.")
)
val prerequisites = listOf(
AboutSection("Système", listOf(
AboutEntry("Android 8.0 ou plus", "Version minimale déclarée par l'application (API 26) ; cible : Android 14 (API 34)."),
AboutEntry("Ni root, ni Shizuku", "Le filtrage repose sur le VPN local d'Android."),
AboutEntry("Un seul VPN à la fois", "Le pare-feu est incompatible avec un autre VPN actif en même temps.")
)),
AboutSection("Autorisations", listOf(
AboutEntry("VPN (obligatoire)", "Consentement système demandé à l'activation du contrôle."),
AboutEntry("Notifications (Android 13+)", "Pour l'état permanent."),
AboutEntry("Optimisation de la batterie (recommandé)", "Évite que le système n'arrête la surveillance."),
AboutEntry("Liste des applications", "Permission QUERY_ALL_PACKAGES, pour afficher toutes les applications dans la Liste blanche."),
AboutEntry("Contrôle parental (facultatif)", "Position (avec « Toujours autoriser » pour l'arrière-plan), administrateur de l'appareil, service d'accessibilité.")
)),
AboutSection("Réseau", listOf(
AboutEntry("Sortant, HTTPS (TCP 443)", "Recherche de mise à jour (git.tuxtech.fr) et, si l'appareil est associé, échanges avec Firebase. Aucun port entrant, aucune redirection de port.")
))
)
val security = listOf(
AboutSection("Pare-feu", listOf(
AboutEntry("Tunnel local sans transfert", "Le VPN ne route ni ne transmet aucun paquet : le trafic des applications non autorisées entre dans le tunnel et y est abandonné."),
AboutEntry("IPv4 et IPv6", "Les deux familles d'adresses sont capturées, pour qu'une application bloquée ne contourne pas le pare-feu en IPv6."),
AboutEntry("Reprise si le tunnel disparaît", "Si le service VPN est arrêté par le système, le tunnel est remonté automatiquement ; sinon la notification le signale."),
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é 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 à 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.")
)),
AboutSection("Limites connues", listOf(
AboutEntry("Signature de l'APK", "Le build release est actuellement signé avec la clé de debug d'Android. Android n'accepte une mise à jour que si elle porte la même signature que l'application installée."),
AboutEntry("Téléchargement des mises à jour", "L'APK est récupéré en HTTPS depuis la release Gitea ; aucune empreinte supplémentaire n'est vérifiée par l'application.")
))
)
val guarantees = listOf(
AboutSection("Vos données", listOf(
AboutEntry("Aucune télémétrie", "Le code ne contient ni analytique ni publicité. Les seuls accès réseau sont la recherche de mise à jour et, si l'appareil est associé, Firebase."),
AboutEntry("Sans appairage, rien ne sort", "Non associé, l'appareil n'envoie rien ; la recherche de mise à jour ne part que lorsque vous touchez le bouton."),
AboutEntry("Le mot de passe reste sur l'appareil", "Il n'est jamais transmis, pas même haché."),
AboutEntry("Appareil associé : ce qui est envoyé", "Nom de l'appareil, batterie, position, état du pare-feu et du contrôle, listes d'applications autorisées, consommation du forfait, liste des applications installées, trafic quotidien mobile/Wi-Fi, temps d'écran par application, événements.")
)),
AboutSection("Ce qui n'est jamais lu", listOf(
AboutEntry("Contenu des écrans et messages", "Le service d'accessibilité ne lit pas le contenu des fenêtres."),
AboutEntry("Contenu du trafic", "Le VPN ne route rien et n'inspecte aucun paquet.")
)),
AboutSection("Privilèges et licence", listOf(
AboutEntry("Rien d'activé à votre insu", "VPN, position, administrateur d'appareil et accessibilité passent chacun par une autorisation explicite d'Android."),
AboutEntry("Licence", "Projet personnel — JT-Tools by Johnny. Le dépôt ne contient pas de fichier de licence : aucun droit de réutilisation n'est accordé par ce texte.")
))
)
val choices = listOf(
AboutEntry("VpnService plutôt qu'une coupure des données", "Android interdit aux applications normales de couper les données mobiles depuis Android 5. Le VPN local fonctionne sans root ni Shizuku, sur le principe de NetGuard."),
AboutEntry("Exclusion d'applications plutôt que relais de paquets", "Les applications autorisées sont exclues du tunnel (addDisallowedApplication) : leur trafic passe normalement, sans passer par l'application."),
AboutEntry("TrafficStats plutôt que NetworkStatsManager", "Le compteur système cumulé ne demande aucune permission spéciale. Contrepartie : il repart de zéro au redémarrage, l'application recale alors sa référence."),
AboutEntry("Service de premier plan + relance périodique", "Un service permanent et une tâche WorkManager toutes les 15 minutes, car certains systèmes arrêtent agressivement les applications en arrière-plan."),
AboutEntry("Kotlin, Jetpack Compose (Material 3), Hilt, DataStore", "Interface déclarative et thème dynamique (Android 12+) ; réglages dans DataStore Preferences."),
AboutEntry("HttpURLConnection plutôt qu'un client HTTP tiers", "Seule la mise à jour fait un appel HTTP : pas de dépendance réseau supplémentaire pour cela."),
AboutEntry("Firebase sans plugin google-services", "La configuration se saisit dans l'application (Réglages → Contrôle parental) et peut changer sans recompiler ; sans elle, le contrôle à distance est simplement désactivé. Un fichier firebase.properties à la compilation reste possible comme valeur par défaut."),
AboutEntry("Service d'accessibilité plutôt que statistiques d'usage", "Permet de renvoyer à l'accueil et d'afficher un écran de blocage dès qu'une application interdite passe au premier plan."),
AboutEntry("Administrateur d'appareil plutôt que Device Owner", "Pas de réinitialisation d'usine ni de branchement à un PC ; en contrepartie, la protection est moins robuste."),
AboutEntry("Journal embarqué", "Le journal de diagnostic est conservé 15 jours (20 000 lignes au plus) dans un fichier local ; le changelog de cette page est le CHANGELOG.md du dépôt, embarqué dans l'APK.")
)
const val REPO_URL = "https://git.tuxtech.fr/tuxgyver/DataControl"
const val AI_NOTICE = "Application développée avec l'assistance de l'IA sous la direction et supervision de Johnny."
}
@@ -1,7 +1,10 @@
package com.jttools.datacontrol.ui.about package com.jttools.datacontrol.ui.about
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -16,11 +19,15 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -29,6 +36,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -37,6 +45,7 @@ import androidx.core.graphics.drawable.toBitmap
import com.jttools.datacontrol.R import com.jttools.datacontrol.R
import com.jttools.datacontrol.data.UpdateManager import com.jttools.datacontrol.data.UpdateManager
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.LocalDate
@Composable @Composable
fun AboutScreen() { fun AboutScreen() {
@@ -62,132 +71,218 @@ fun AboutScreen() {
var busy by remember { mutableStateOf(false) } var busy by remember { mutableStateOf(false) }
var progress by remember { mutableIntStateOf(-1) } var progress by remember { mutableIntStateOf(-1) }
Column( val tabs = listOf(
modifier = Modifier "Fonctionnalités", "Mode d'emploi", "Prérequis", "Sécurité",
.fillMaxSize() "Garanties", "Choix techniques", "Journal", "Auteur"
.verticalScroll(rememberScrollState()) )
.padding(20.dp), var tab by rememberSaveable { mutableIntStateOf(0) }
verticalArrangement = Arrangement.spacedBy(16.dp) val changelog = remember { ChangelogParser.load(context) }
) {
Text(stringResource(R.string.about_title), style = MaterialTheme.typography.headlineMedium)
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) { Column(Modifier.fillMaxSize()) {
Column( // En-tête toujours visible, quel que soit le sous-onglet.
Modifier AboutHeader(iconBitmap, versionName)
.fillMaxWidth()
.padding(24.dp), ScrollableTabRow(selectedTabIndex = tab, edgePadding = 12.dp) {
horizontalAlignment = Alignment.CenterHorizontally, tabs.forEachIndexed { i, label ->
verticalArrangement = Arrangement.spacedBy(6.dp) Tab(selected = tab == i, onClick = { tab = i }, text = { Text(label) })
) {
if (iconBitmap != null) {
Image(
bitmap = iconBitmap,
contentDescription = null,
modifier = Modifier.size(72.dp)
)
}
Text(
stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
stringResource(R.string.about_version, versionName),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} }
} }
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) { Column(
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { modifier = Modifier
Text(stringResource(R.string.update_section), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) .weight(1f)
HorizontalDivider() .verticalScroll(rememberScrollState())
val info = available .padding(16.dp),
val statusText = when { verticalArrangement = Arrangement.spacedBy(14.dp)
progress >= 0 -> stringResource(R.string.update_downloading, progress) ) {
updateStatus != null -> updateStatus!! when (tab) {
info != null -> stringResource(R.string.update_available, info.versionName) 0 -> AboutContent.features.forEach { SectionCard(it) }
else -> null 1 -> {
} CardBox("Démarrer") {
if (statusText != null) Text(statusText, style = MaterialTheme.typography.bodyMedium) AboutContent.howTo.forEachIndexed { i, step ->
if (info != null && progress < 0 && info.changelog.isNotBlank()) { Text("${i + 1}. $step", style = MaterialTheme.typography.bodyMedium)
Text(info.changelog, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (progress >= 0) {
LinearProgressIndicator(progress = { progress / 100f }, modifier = Modifier.fillMaxWidth())
}
val needPermission = stringResource(R.string.update_need_permission)
val upToDate = stringResource(R.string.update_up_to_date)
val errorFmt = stringResource(R.string.update_error, "%s")
Button(
enabled = !busy,
onClick = {
busy = true
scope.launch {
if (info == null) {
updateStatus = null
when (val r = updateManager.check()) {
is UpdateManager.CheckResult.UpToDate -> updateStatus = upToDate
is UpdateManager.CheckResult.Available -> available = r.info
is UpdateManager.CheckResult.Error -> updateStatus = errorFmt.replace("%s", r.message)
}
} else {
updateStatus = null
progress = 0
try {
val file = updateManager.download(info) { progress = it }
if (!updateManager.install(file)) updateStatus = needPermission
} catch (e: Exception) {
updateStatus = errorFmt.replace("%s", e.message ?: e.javaClass.simpleName)
}
progress = -1
}
busy = false
} }
} }
) { CardBox("Au quotidien") { AboutContent.everyday.forEach { EntryRow(it) } }
Text( CardBox("Si ça ne marche pas") { AboutContent.troubleshooting.forEach { EntryRow(it) } }
when { }
busy && info == null -> stringResource(R.string.update_checking) 2 -> AboutContent.prerequisites.forEach { SectionCard(it) }
info != null -> stringResource(R.string.update_download_install) 3 -> AboutContent.security.forEach { SectionCard(it) }
else -> stringResource(R.string.update_check) 4 -> AboutContent.guarantees.forEach { SectionCard(it) }
5 -> CardBox(null) { AboutContent.choices.forEach { EntryRow(it) } }
6 -> {
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(stringResource(R.string.update_section), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
HorizontalDivider()
val info = available
val statusText = when {
progress >= 0 -> stringResource(R.string.update_downloading, progress)
updateStatus != null -> updateStatus!!
info != null -> stringResource(R.string.update_available, info.versionName)
else -> null
}
if (statusText != null) Text(statusText, style = MaterialTheme.typography.bodyMedium)
if (info != null && progress < 0 && info.changelog.isNotBlank()) {
Text(info.changelog, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (progress >= 0) {
LinearProgressIndicator(progress = { progress / 100f }, modifier = Modifier.fillMaxWidth())
}
val needPermission = stringResource(R.string.update_need_permission)
val upToDate = stringResource(R.string.update_up_to_date)
val errorFmt = stringResource(R.string.update_error, "%s")
Button(
enabled = !busy,
onClick = {
busy = true
scope.launch {
if (info == null) {
updateStatus = null
when (val r = updateManager.check()) {
is UpdateManager.CheckResult.UpToDate -> updateStatus = upToDate
is UpdateManager.CheckResult.Available -> available = r.info
is UpdateManager.CheckResult.Error -> updateStatus = errorFmt.replace("%s", r.message)
}
} else {
updateStatus = null
progress = 0
try {
val file = updateManager.download(info) { progress = it }
if (!updateManager.install(file)) updateStatus = needPermission
} catch (e: Exception) {
updateStatus = errorFmt.replace("%s", e.message ?: e.javaClass.simpleName)
}
progress = -1
}
busy = false
}
} }
) ) {
Text(
when {
busy && info == null -> stringResource(R.string.update_checking)
info != null -> stringResource(R.string.update_download_install)
else -> stringResource(R.string.update_check)
}
)
}
} }
} }
}
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) { if (changelog.isEmpty()) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Text("Le journal des versions (CHANGELOG.md) est introuvable dans cette version de l'application.", style = MaterialTheme.typography.bodyMedium)
Text(stringResource(R.string.about_section_description), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) }
HorizontalDivider() changelog.forEach { v ->
Text(stringResource(R.string.about_description), style = MaterialTheme.typography.bodyMedium) CardBox(v.title) {
} v.items.forEach { item ->
} Text(ChangelogParser.styled("• $item"), style = MaterialTheme.typography.bodyMedium)
}
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) { }
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { }
Text(stringResource(R.string.about_section_how), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) }
HorizontalDivider() 7 -> AuthorTab()
Text(stringResource(R.string.about_how_it_works), style = MaterialTheme.typography.bodyMedium)
}
}
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
Row(
Modifier
.fillMaxWidth()
.padding(20.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(stringResource(R.string.about_author), style = MaterialTheme.typography.bodyMedium)
Text(
stringResource(R.string.about_author_value),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
} }
} }
} }
} }
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun AboutHeader(icon: androidx.compose.ui.graphics.ImageBitmap?, versionName: String) {
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
shape = RoundedCornerShape(20.dp),
elevation = CardDefaults.cardElevation(2.dp)
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
if (icon != null) Image(bitmap = icon, contentDescription = null, modifier = Modifier.size(52.dp))
Column {
Text(stringResource(R.string.app_name), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(
stringResource(R.string.about_version, versionName),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Text(
"Pare-feu de données mobiles pour Android : seules les applications autorisées accèdent à Internet en 4G/5G, le Wi-Fi reste libre. Pilotable à distance par un parent avec FamilyControl.",
style = MaterialTheme.typography.bodySmall
)
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("Android 8.0+", "Sans root", "VPN local", "Kotlin · Compose", "Projet personnel").forEach { Pill(it) }
}
}
}
}
@Composable
private fun Pill(text: String) {
Surface(shape = RoundedCornerShape(50), color = MaterialTheme.colorScheme.secondaryContainer) {
Text(
text,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 3.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
@Composable
private fun CardBox(title: String?, content: @Composable () -> Unit) {
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
if (title != null) {
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
HorizontalDivider()
}
content()
}
}
}
@Composable
private fun SectionCard(section: AboutSection) =
CardBox(section.heading) { section.entries.forEach { EntryRow(it) } }
@Composable
private fun EntryRow(entry: AboutEntry) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(entry.title, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
Text(entry.body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun AuthorTab() {
val uriHandler = LocalUriHandler.current
CardBox(null) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(stringResource(R.string.about_author), style = MaterialTheme.typography.bodyMedium)
Text(stringResource(R.string.about_author_value), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("Dépôt", style = MaterialTheme.typography.bodyMedium)
Text(
"git.tuxtech.fr/tuxgyver/DataControl",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable { runCatching { uriHandler.openUri(AboutContent.REPO_URL) } }
)
}
Text("© ${LocalDate.now().year} JT-Tools — Johnny", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Card(
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
) {
Text(
AboutContent.AI_NOTICE,
modifier = Modifier.padding(18.dp),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
@@ -0,0 +1,54 @@
package com.jttools.datacontrol.ui.about
import android.content.Context
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
data class ChangelogVersion(val title: String, val items: List<String>)
/** Lit CHANGELOG.md (embarqué dans les assets par le build) : versions de la plus récente à la plus ancienne. */
object ChangelogParser {
fun load(context: Context): List<ChangelogVersion> = runCatching {
parse(context.assets.open("CHANGELOG.md").bufferedReader().use { it.readText() })
}.getOrDefault(emptyList())
fun parse(text: String): List<ChangelogVersion> {
val out = mutableListOf<ChangelogVersion>()
var title: String? = null
var items = mutableListOf<String>()
fun flush() { title?.let { out += ChangelogVersion(it, items) }; items = mutableListOf() }
for (raw in text.lines()) {
val line = raw.trim()
when {
line.startsWith("## ") -> { flush(); title = line.removePrefix("## ").trim() }
line.startsWith("- ") && title != null -> items += line.removePrefix("- ").trim()
line.isNotEmpty() && title != null && !line.startsWith("#") && items.isNotEmpty() ->
items[items.lastIndex] = items.last() + " " + line // ligne de continuation
}
}
flush()
return out
}
/** Rend **gras** et `code` du Markdown ; le reste est du texte brut. */
fun styled(text: String): AnnotatedString = buildAnnotatedString {
var i = 0
while (i < text.length) {
when {
text.startsWith("**", i) -> {
val end = text.indexOf("**", i + 2)
if (end > 0) { withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(text.substring(i + 2, end)) }; i = end + 2 } else { append(text[i]); i++ }
}
text[i] == '`' -> {
val end = text.indexOf('`', i + 1)
if (end > 0) { withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { append(text.substring(i + 1, end)) }; i = end + 1 } else { append(text[i]); i++ }
}
else -> { append(text[i]); i++ }
}
}
}
}
@@ -35,14 +35,18 @@ import kotlinx.coroutines.launch
/** /**
* Verrouille [content] derrière une saisie de mot de passe tant que [passwordSet] est vrai. * 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). * Le déverrouillage n'est valable que pour la composition courante (redemandé à chaque visite de l'écran).
*/ */
@Composable @Composable
fun PasswordGate( fun PasswordGate(
passwordSet: Boolean, passwordSet: Boolean?,
verify: suspend (String) -> Boolean, verify: suspend (String) -> String?,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
if (passwordSet == null) return
var unlocked by remember(passwordSet) { mutableStateOf(!passwordSet) } var unlocked by remember(passwordSet) { mutableStateOf(!passwordSet) }
if (unlocked) { if (unlocked) {
content() content()
@@ -50,7 +54,8 @@ fun PasswordGate(
} }
var password by remember { mutableStateOf("") } 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() val scope = rememberCoroutineScope()
Column( Column(
@@ -64,31 +69,27 @@ fun PasswordGate(
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
OutlinedTextField( OutlinedTextField(
value = password, value = password,
onValueChange = { password = it; error = false }, onValueChange = { password = it; error = null },
label = { Text(stringResource(R.string.password_field_label)) }, label = { Text(stringResource(R.string.password_field_label)) },
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
isError = error, isError = error != null,
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
if (error) { error?.let {
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text( Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
stringResource(R.string.password_incorrect),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Button( Button(
enabled = !busy,
onClick = { onClick = {
busy = true
scope.launch { scope.launch {
if (verify(password)) { val result = verify(password)
unlocked = true busy = false
} else { if (result == null) unlocked = true else error = result
error = true
}
} }
}, },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
@@ -100,7 +101,7 @@ fun PasswordGate(
@Composable @Composable
fun PasswordPromptDialog( fun PasswordPromptDialog(
title: String, title: String,
error: Boolean, error: String?,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onConfirm: (String) -> Unit onConfirm: (String) -> Unit
) { ) {
@@ -117,17 +118,13 @@ fun PasswordPromptDialog(
label = { Text(stringResource(R.string.password_field_label)) }, label = { Text(stringResource(R.string.password_field_label)) },
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
isError = error, isError = error != null,
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
if (error) { error?.let {
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text( Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
stringResource(R.string.password_incorrect),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
} }
} }
}, },
@@ -56,9 +56,10 @@ fun DashboardScreen(systemActions: SystemActions) {
val viewModel: DashboardViewModel = hiltViewModel() val viewModel: DashboardViewModel = hiltViewModel()
val settings by viewModel.settings.collectAsStateWithLifecycle() val settings by viewModel.settings.collectAsStateWithLifecycle()
val snapshot by viewModel.snapshot.collectAsStateWithLifecycle() val snapshot by viewModel.snapshot.collectAsStateWithLifecycle()
val passwordSet by viewModel.passwordSet.collectAsStateWithLifecycle()
var showResetDialog by remember { mutableStateOf(false) } var showResetDialog by remember { mutableStateOf(false) }
var showDisablePasswordDialog 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 scope = rememberCoroutineScope()
val stateColor = when { val stateColor = when {
@@ -163,15 +164,16 @@ fun DashboardScreen(systemActions: SystemActions) {
if (checked) { if (checked) {
systemActions.requestNotificationPermission { _ -> systemActions.requestNotificationPermission { _ ->
systemActions.requestVpnPermission { granted -> systemActions.requestVpnPermission { granted ->
com.jttools.datacontrol.log.AppLog.i("Tableau de bord", "Activation du contrôle : autorisation VPN ${if (granted) "accordée" else "REFUSÉE"}")
if (granted) { if (granted) {
viewModel.setControlEnabled(true) viewModel.setControlEnabled(true)
DataMonitorService.start(context) DataMonitorService.start(context)
} }
} }
} }
} else { } else if (passwordSet != null) { // null : réglages pas encore chargés
if (settings.hasPassword) { if (passwordSet == true) {
disablePasswordError = false disablePasswordError = null
showDisablePasswordDialog = true showDisablePasswordDialog = true
} else { } else {
viewModel.setControlEnabled(false) viewModel.setControlEnabled(false)
@@ -242,11 +244,12 @@ fun DashboardScreen(systemActions: SystemActions) {
onDismiss = { showDisablePasswordDialog = false }, onDismiss = { showDisablePasswordDialog = false },
onConfirm = { password -> onConfirm = { password ->
scope.launch { scope.launch {
if (viewModel.verifyPassword(password)) { val error = viewModel.checkPassword(password)
if (error == null) {
viewModel.setControlEnabled(false) viewModel.setControlEnabled(false)
showDisablePasswordDialog = false showDisablePasswordDialog = false
} else { } else {
disablePasswordError = true disablePasswordError = error
} }
} }
} }
@@ -10,6 +10,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -33,5 +35,10 @@ class DashboardViewModel @Inject constructor(
viewModelScope.launch { settingsRepository.resetPlan(usageRepository.totalMobileBytes()) } 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)
} }
@@ -16,7 +16,6 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -29,6 +28,8 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.jttools.datacontrol.R import com.jttools.datacontrol.R
import com.jttools.datacontrol.ui.SystemActions import com.jttools.datacontrol.ui.SystemActions
@@ -41,27 +42,34 @@ fun FamilySection(systemActions: SystemActions) {
val vm: FamilyViewModel = hiltViewModel() val vm: FamilyViewModel = hiltViewModel()
val settings by vm.settings.collectAsStateWithLifecycle() val settings by vm.settings.collectAsStateWithLifecycle()
val pairing by vm.pairing.collectAsStateWithLifecycle() val pairing by vm.pairing.collectAsStateWithLifecycle()
val gw by vm.gatewayState.collectAsStateWithLifecycle()
var firebaseText by remember { mutableStateOf("") }
var firebaseError by remember { mutableStateOf<String?>(null) }
var editFirebase by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var adminActive by remember { mutableStateOf(systemActions.isAdminActive()) } var adminActive by remember { mutableStateOf(systemActions.isAdminActive()) }
var locationGranted by remember { mutableStateOf(systemActions.hasLocationPermission()) } var locationGranted by remember { mutableStateOf(systemActions.hasLocationPermission()) }
var backgroundGranted by remember { mutableStateOf(systemActions.hasBackgroundLocationPermission()) } var backgroundGranted by remember { mutableStateOf(systemActions.hasBackgroundLocationPermission()) }
var guardEnabled by remember { mutableStateOf(systemActions.isGuardEnabled()) }
var code by remember { mutableStateOf("") } var code by remember { mutableStateOf("") }
var deviceName by remember { mutableStateOf(android.os.Build.MODEL) } var deviceName by remember { mutableStateOf(android.os.Build.MODEL) }
// Action sensible en attente de confirmation par mot de passe : "admin" ou "unpair". // Action sensible en attente de confirmation par mot de passe : "admin" ou "unpair".
var pendingAction by remember { mutableStateOf<String?>(null) } var pendingAction by remember { mutableStateOf<String?>(null) }
var pwd by remember { mutableStateOf("") } var pwd by remember { mutableStateOf("") }
var pwdError by remember { mutableStateOf(false) } var pwdError by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) { // Au retour des écrans système (accessibilité, permissions, admin), on relit l'état réel.
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
adminActive = systemActions.isAdminActive() adminActive = systemActions.isAdminActive()
locationGranted = systemActions.hasLocationPermission() locationGranted = systemActions.hasLocationPermission()
backgroundGranted = systemActions.hasBackgroundLocationPermission() backgroundGranted = systemActions.hasBackgroundLocationPermission()
guardEnabled = systemActions.isGuardEnabled()
} }
fun runProtected(action: String, block: () -> Unit) { 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) { fun execute(action: String) {
@@ -79,9 +87,41 @@ fun FamilySection(systemActions: SystemActions) {
) )
HorizontalDivider() HorizontalDivider()
// --- Configuration Firebase (saisie ici, sans recompiler) ---
Text(stringResource(R.string.family_firebase_title), fontWeight = FontWeight.Medium)
if (gw.enabled && !editFirebase) {
Text(
stringResource(R.string.family_firebase_ok, gw.projectId ?: ""),
color = StateOk, style = MaterialTheme.typography.bodyMedium
)
OutlinedButton(onClick = { editFirebase = true }) { Text(stringResource(R.string.family_firebase_edit)) }
} else {
Text(
stringResource(R.string.family_firebase_help),
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant
)
OutlinedTextField(
value = firebaseText, onValueChange = { firebaseText = it; firebaseError = null },
label = { Text(stringResource(R.string.family_firebase_field)) },
minLines = 3, maxLines = 6, modifier = Modifier.fillMaxWidth()
)
firebaseError?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
Button(
enabled = firebaseText.isNotBlank(),
onClick = {
firebaseError = vm.saveFirebaseConfig(firebaseText)
if (firebaseError == null) { firebaseText = ""; editFirebase = false }
}
) { Text(stringResource(R.string.family_firebase_save)) }
}
HorizontalDivider()
// --- Appairage --- // --- Appairage ---
when { when {
!vm.remoteAvailable -> Text( !gw.enabled -> Text(
stringResource(R.string.family_unavailable), stringResource(R.string.family_unavailable),
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -140,6 +180,26 @@ fun FamilySection(systemActions: SystemActions) {
HorizontalDivider() HorizontalDivider()
// --- Limitation des applications ---
Text(stringResource(R.string.family_guard_title), fontWeight = FontWeight.Medium)
Text(
stringResource(R.string.family_guard_desc),
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (guardEnabled) {
Text(stringResource(R.string.settings_perm_granted), color = StateOk)
} else {
Button(onClick = { systemActions.openAccessibilitySettings() }) {
Text(stringResource(R.string.family_guard_enable))
}
Text(
stringResource(R.string.family_guard_hint),
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
HorizontalDivider()
// --- Position --- // --- Position ---
Text(stringResource(R.string.family_location_title), fontWeight = FontWeight.Medium) Text(stringResource(R.string.family_location_title), fontWeight = FontWeight.Medium)
Text( Text(
@@ -167,22 +227,23 @@ fun FamilySection(systemActions: SystemActions) {
text = { text = {
Column { Column {
OutlinedTextField( OutlinedTextField(
value = pwd, onValueChange = { pwd = it; pwdError = false }, value = pwd, onValueChange = { pwd = it; pwdError = null },
label = { Text(stringResource(R.string.password_field_label)) }, label = { Text(stringResource(R.string.password_field_label)) },
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
isError = pwdError, singleLine = true, modifier = Modifier.fillMaxWidth() isError = pwdError != null, singleLine = true, modifier = Modifier.fillMaxWidth()
) )
if (pwdError) Text( pwdError?.let { Text(
stringResource(R.string.password_incorrect), it,
color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall
) ) }
} }
}, },
confirmButton = { confirmButton = {
Button(onClick = { Button(onClick = {
scope.launch { 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)) } }) { Text(stringResource(R.string.common_confirm)) }
}, },
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.jttools.datacontrol.data.AppSettings import com.jttools.datacontrol.data.AppSettings
import com.jttools.datacontrol.data.SettingsRepository import com.jttools.datacontrol.data.SettingsRepository
import com.jttools.datacontrol.remote.FirebaseConfig
import com.jttools.datacontrol.remote.RemoteSync import com.jttools.datacontrol.remote.RemoteSync
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -12,6 +13,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
data class PairingUiState(val busy: Boolean = false, val error: String? = null) data class PairingUiState(val busy: Boolean = false, val error: String? = null)
@@ -26,7 +28,17 @@ class FamilyViewModel @Inject constructor(
viewModelScope, SharingStarted.WhileSubscribed(5_000), AppSettings() viewModelScope, SharingStarted.WhileSubscribed(5_000), AppSettings()
) )
val remoteAvailable: Boolean get() = remoteSync.available val gatewayState = remoteSync.gatewayState.stateIn(
viewModelScope, SharingStarted.WhileSubscribed(5_000), remoteSync.gatewayState.value
)
/** Enregistre la configuration Firebase collée ; renvoie un message d'erreur, ou null si elle est acceptée. */
fun saveFirebaseConfig(text: String): String? {
val cfg = FirebaseConfig.parse(text)
?: return "Configuration non reconnue : il faut au minimum apiKey, appId et projectId (collez le bloc « firebaseConfig » complet, ou la chaîne « fc1:… » de FamilyControl)."
viewModelScope.launch { settingsRepository.setFirebaseConfig(cfg) }
return null
}
private val _pairing = MutableStateFlow(PairingUiState()) private val _pairing = MutableStateFlow(PairingUiState())
val pairing: StateFlow<PairingUiState> = _pairing.asStateFlow() val pairing: StateFlow<PairingUiState> = _pairing.asStateFlow()
@@ -43,5 +55,10 @@ class FamilyViewModel @Inject constructor(
viewModelScope.launch { remoteSync.unpair() } 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)
} }
@@ -0,0 +1,119 @@
package com.jttools.datacontrol.ui.journal
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
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(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)) }
LaunchedEffect(Unit) {
while (true) { live = liveStatus(context); delay(1000) }
}
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Journal", style = MaterialTheme.typography.headlineMedium)
Card(shape = RoundedCornerShape(16.dp)) {
Text(live, fontFamily = FontFamily.Monospace, fontSize = 12.sp, modifier = Modifier.padding(12.dp))
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = {
val cm = context.getSystemService(ClipboardManager::class.java)
cm.setPrimaryClip(ClipData.newPlainText("Journal DataControl", live + "\n\n" + AppLog.asText()))
}) { Text("Copier") }
OutlinedButton(onClick = {
val send = Intent(Intent.ACTION_SEND).setType("text/plain")
.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(
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 ->
val color = when {
" E " in line.take(16) -> Color(0xFFC62828)
" W " in line.take(16) -> Color(0xFFEF6C00)
else -> MaterialTheme.colorScheme.onSurface
}
Text(line, color = color, fontFamily = FontFamily.Monospace, fontSize = 11.sp)
}
}
}
if (askPassword) {
ClearPasswordDialog(
error = passwordError,
onDismiss = { askPassword = false },
onConfirm = { password ->
scope.launch {
val error = viewModel.checkPassword(password)
if (error == null) { AppLog.clear(); askPassword = false } else passwordError = error
}
}
)
}
}
@Composable
private fun ClearPasswordDialog(
error: String?,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit
) = PasswordPromptDialog(title = "Effacer le journal", error = error, onDismiss = onDismiss, onConfirm = onConfirm)
private fun liveStatus(context: Context): String = buildString {
appendLine("VPN autorisé (prepare) : ${FirewallVpnService.isPrepared(context)}")
appendLine("Tunnel VPN monté : ${FirewallVpnService.tunnelActive}")
appendLine("Suivi des applis (accessibilité) : ${com.jttools.datacontrol.guard.AppGuardService.isEnabled(context)}")
appendLine("Conservation du journal : ${AppLog.RETENTION_DAYS} jours")
append("Attendu : le tunnel n'est monté QUE sur données mobiles, contrôle activé.")
}
@@ -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)
}
@@ -2,6 +2,7 @@ package com.jttools.datacontrol.ui.navigation
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Dashboard import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
@@ -25,6 +26,7 @@ import com.jttools.datacontrol.R
import com.jttools.datacontrol.ui.SystemActions import com.jttools.datacontrol.ui.SystemActions
import com.jttools.datacontrol.ui.about.AboutScreen import com.jttools.datacontrol.ui.about.AboutScreen
import com.jttools.datacontrol.ui.dashboard.DashboardScreen import com.jttools.datacontrol.ui.dashboard.DashboardScreen
import com.jttools.datacontrol.ui.journal.JournalScreen
import com.jttools.datacontrol.ui.settings.SettingsScreen import com.jttools.datacontrol.ui.settings.SettingsScreen
import com.jttools.datacontrol.ui.whitelist.WhitelistScreen import com.jttools.datacontrol.ui.whitelist.WhitelistScreen
@@ -32,10 +34,11 @@ private sealed class Destination(val route: String, val labelRes: Int, val icon:
data object Dashboard : Destination("dashboard", R.string.nav_dashboard, Icons.Filled.Dashboard) data object Dashboard : Destination("dashboard", R.string.nav_dashboard, Icons.Filled.Dashboard)
data object Settings : Destination("settings", R.string.nav_settings, Icons.Filled.Settings) data object Settings : Destination("settings", R.string.nav_settings, Icons.Filled.Settings)
data object Whitelist : Destination("whitelist", R.string.nav_whitelist, Icons.AutoMirrored.Filled.List) data object Whitelist : Destination("whitelist", R.string.nav_whitelist, Icons.AutoMirrored.Filled.List)
data object Journal : Destination("journal", R.string.nav_journal, Icons.Filled.BugReport)
data object About : Destination("about", R.string.nav_about, Icons.Filled.Info) data object About : Destination("about", R.string.nav_about, Icons.Filled.Info)
} }
private val destinations = listOf(Destination.Dashboard, Destination.Settings, Destination.Whitelist, Destination.About) private val destinations = listOf(Destination.Dashboard, Destination.Settings, Destination.Whitelist, Destination.Journal, Destination.About)
@Composable @Composable
fun DataControlNavHost(systemActions: SystemActions) { fun DataControlNavHost(systemActions: SystemActions) {
@@ -72,6 +75,7 @@ fun DataControlNavHost(systemActions: SystemActions) {
composable(Destination.Dashboard.route) { DashboardScreen(systemActions) } composable(Destination.Dashboard.route) { DashboardScreen(systemActions) }
composable(Destination.Settings.route) { SettingsScreen(systemActions) } composable(Destination.Settings.route) { SettingsScreen(systemActions) }
composable(Destination.Whitelist.route) { WhitelistScreen() } composable(Destination.Whitelist.route) { WhitelistScreen() }
composable(Destination.Journal.route) { JournalScreen() }
composable(Destination.About.route) { AboutScreen() } composable(Destination.About.route) { AboutScreen() }
} }
} }
@@ -53,8 +53,9 @@ import java.util.Locale
fun SettingsScreen(systemActions: SystemActions) { fun SettingsScreen(systemActions: SystemActions) {
val viewModel: SettingsViewModel = hiltViewModel() val viewModel: SettingsViewModel = hiltViewModel()
val settings by viewModel.settings.collectAsStateWithLifecycle() 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) SettingsContent(systemActions, viewModel, settings)
} }
} }
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.LocalDate import java.time.LocalDate
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -55,5 +56,10 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { settingsRepository.setPassword(password) } 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() { fun WhitelistScreen() {
val viewModel: WhitelistViewModel = hiltViewModel() val viewModel: WhitelistViewModel = hiltViewModel()
val settings by viewModel.settings.collectAsStateWithLifecycle() 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) WhitelistContent(viewModel, settings)
} }
} }
@@ -12,6 +12,8 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -62,5 +64,10 @@ class WhitelistViewModel @Inject constructor(
viewModelScope.launch { settingsRepository.setShowSystemApps(show) } 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)
} }
+14 -15
View File
@@ -6,15 +6,14 @@
<string name="nav_dashboard">Tableau de bord</string> <string name="nav_dashboard">Tableau de bord</string>
<string name="nav_settings">Réglages</string> <string name="nav_settings">Réglages</string>
<string name="nav_whitelist">Liste blanche</string> <string name="nav_whitelist">Liste blanche</string>
<string name="nav_journal">Journal</string>
<string name="nav_about">À propos</string> <string name="nav_about">À propos</string>
<!-- Dashboard --> <!-- Dashboard -->
<string name="dashboard_title">Tableau de bord</string> <string name="dashboard_title">Tableau de bord</string>
<string name="dashboard_consumed">Données consommées</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_remaining">Restant</string>
<string name="dashboard_overage">Dépassement</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_ok">Sous le forfait</string>
<string name="dashboard_state_warning">Proche du forfait</string> <string name="dashboard_state_warning">Proche du forfait</string>
<string name="dashboard_state_cut">Coupure totale en cours</string> <string name="dashboard_state_cut">Coupure totale en cours</string>
@@ -29,7 +28,6 @@
<string name="dashboard_reset_now">Réinitialiser le forfait maintenant</string> <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_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_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_control_disabled">Contrôle désactivé</string>
<string name="dashboard_next_cutoff">Prochaine coupure</string> <string name="dashboard_next_cutoff">Prochaine coupure</string>
<string name="dashboard_cutoff_wifi">Wi-Fi actif (pas de coupure)</string> <string name="dashboard_cutoff_wifi">Wi-Fi actif (pas de coupure)</string>
@@ -51,8 +49,6 @@
<string name="settings_cutoff_interval">Intervalle de coupure (minutes)</string> <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_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_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">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_vpn_desc">Nécessaire pour filtrer les applications sur les données mobiles</string>
<string name="settings_perm_notifications">Notifications</string> <string name="settings_perm_notifications">Notifications</string>
@@ -63,7 +59,6 @@
<string name="settings_perm_missing">Manquante</string> <string name="settings_perm_missing">Manquante</string>
<string name="settings_save">Enregistrer</string> <string name="settings_save">Enregistrer</string>
<string name="settings_edit_date">Modifier</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_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_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> <string name="settings_password_inactive">Aucun mot de passe défini</string>
@@ -79,16 +74,10 @@
<!-- Verrouillage par mot de passe --> <!-- Verrouillage par mot de passe -->
<string name="password_gate_title">Zone protégée</string> <string name="password_gate_title">Zone protégée</string>
<string name="password_field_label">Mot de passe</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> <string name="password_unlock">Déverrouiller</string>
<!-- About --> <!-- About -->
<string name="about_title">À propos</string>
<string name="about_version">Version %1$s</string> <string name="about_version">Version %1$s</string>
<string name="about_section_description">Description</string>
<string name="about_description">DataControl 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.</string>
<string name="about_section_how">Fonctionnement</string>
<string name="about_how_it_works">Le filtrage repose sur un pare-feu local basé sur VpnService (sans root) : le trafic des applications non autorisées est capturé dans un tunnel VPN local puis silencieusement ignoré, tandis que les applications de la liste blanche continuent de communiquer normalement.</string>
<string name="update_section">Mise à jour</string> <string name="update_section">Mise à jour</string>
<string name="update_check">Rechercher une mise à jour</string> <string name="update_check">Rechercher une mise à jour</string>
<string name="update_checking">Recherche en cours…</string> <string name="update_checking">Recherche en cours…</string>
@@ -119,13 +108,11 @@
<string name="notification_title">DataControl</string> <string name="notification_title">DataControl</string>
<!-- Common --> <!-- Common -->
<string name="unit_mo">Mo</string>
<string name="unit_euro">€</string>
<string name="common_cancel">Annuler</string> <string name="common_cancel">Annuler</string>
<string name="common_confirm">Confirmer</string> <string name="common_confirm">Confirmer</string>
<string name="family_title">Contrôle parental (FamilyControl)</string> <string name="family_title">Contrôle parental (FamilyControl)</string>
<string name="family_unavailable">Liaison FamilyControl non configurée dans cette version (fichier firebase.properties absent à la compilation).</string> <string name="family_unavailable">Renseignez d\'abord la configuration Firebase ci-dessus pour pouvoir associer l\'appareil.</string>
<string name="family_paired">Appareil associé à FamilyControl : le parent peut gérer les accès réseau, voir la batterie, la position et l\'historique de trafic.</string> <string name="family_paired">Appareil associé à FamilyControl : le parent peut gérer les accès réseau, voir la batterie, la position et l\'historique de trafic.</string>
<string name="family_unpair">Dissocier cet appareil</string> <string name="family_unpair">Dissocier cet appareil</string>
<string name="family_pair_help">Saisissez le code d\'appairage affiché dans FamilyControl pour associer cet appareil.</string> <string name="family_pair_help">Saisissez le code d\'appairage affiché dans FamilyControl pour associer cet appareil.</string>
@@ -142,4 +129,16 @@
<string name="family_location_desc">Partagée avec le parent via FamilyControl, y compris en arrière-plan (choisir « Toujours autoriser »).</string> <string name="family_location_desc">Partagée avec le parent via FamilyControl, y compris en arrière-plan (choisir « Toujours autoriser »).</string>
<string name="family_location_grant">Autoriser la position</string> <string name="family_location_grant">Autoriser la position</string>
<string name="family_location_background">Autoriser en arrière-plan</string> <string name="family_location_background">Autoriser en arrière-plan</string>
<string name="guard_service_label">DataControl — limitation des applications</string>
<string name="guard_service_description">Détecte l\'application utilisée pour appliquer les plages horaires et les limites de temps définies par les parents. Le contenu des écrans n\'est pas lu.</string>
<string name="family_guard_title">Limitation des applications</string>
<string name="family_guard_desc">Plages horaires, temps d\'écran et blocage d\'applications décidés par le parent. Nécessite le service d\'accessibilité « DataControl ».</string>
<string name="family_guard_hint">Android 13+ : si l\'activation est grisée, ouvrez Infos sur l\'application → ⋮ → « Autoriser les paramètres restreints », puis réessayez.</string>
<string name="family_guard_enable">Activer le service</string>
<string name="family_firebase_title">Configuration Firebase</string>
<string name="family_firebase_ok">Configurée — projet %1$s</string>
<string name="family_firebase_edit">Modifier la configuration</string>
<string name="family_firebase_help">Collez ici la chaîne « fc1:… » partagée par FamilyControl (bouton « Partager pour DataControl »), ou le bloc « firebaseConfig » de la console Firebase.</string>
<string name="family_firebase_field">Configuration Firebase</string>
<string name="family_firebase_save">Enregistrer</string>
</resources> </resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:canRetrieveWindowContent="false"
android:description="@string/guard_service_description"
android:notificationTimeout="100" />
+4
View File
@@ -1,4 +1,8 @@
<?xml version="1.0" encoding="utf-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> <full-backup-content>
<exclude domain="sharedpref" path="." /> <exclude domain="sharedpref" path="." />
<exclude domain="file" path="datastore/" />
<exclude domain="file" path="journal.txt" />
</full-backup-content> </full-backup-content>
@@ -1,6 +1,14 @@
<?xml version="1.0" encoding="utf-8"?> <?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> <data-extraction-rules>
<cloud-backup> <cloud-backup>
<exclude domain="sharedpref" path="." /> <exclude domain="sharedpref" path="." />
<exclude domain="file" path="datastore/" />
<exclude domain="file" path="journal.txt" />
</cloud-backup> </cloud-backup>
<device-transfer>
<exclude domain="sharedpref" path="." />
<exclude domain="file" path="datastore/" />
<exclude domain="file" path="journal.txt" />
</device-transfer>
</data-extraction-rules> </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,43 @@
package com.jttools.datacontrol.remote
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.util.Base64
class FirebaseConfigTest {
private val expected = FirebaseConfig("AIzaTest", "1:123:android:abc", "mon-projet")
@Test fun `bloc firebaseConfig de la console`() {
val js = """
const firebaseConfig = {
apiKey: "AIzaTest",
authDomain: "mon-projet.firebaseapp.com",
projectId: "mon-projet",
appId: "1:123:android:abc"
};
""".trimIndent()
assertEquals(expected, FirebaseConfig.parse(js))
}
@Test fun `json`() {
assertEquals(
expected,
FirebaseConfig.parse("""{"apiKey":"AIzaTest","appId":"1:123:android:abc","projectId":"mon-projet"}""")
)
}
@Test fun `chaine fc1 de FamilyControl`() {
val b64 = Base64.getEncoder().encodeToString(
"""{"apiKey":"AIzaTest","appId":"1:123:android:abc","projectId":"mon-projet"}""".toByteArray()
)
assertEquals(expected, FirebaseConfig.parse("fc1:$b64"))
assertEquals(expected, FirebaseConfig.parse(" fc1:${b64.chunked(20).joinToString("\n")} "))
}
@Test fun `champ manquant ou illisible`() {
assertNull(FirebaseConfig.parse("""{"apiKey":"AIzaTest","projectId":"mon-projet"}"""))
assertNull(FirebaseConfig.parse("fc1:!!!"))
assertNull(FirebaseConfig.parse(""))
}
}
+2
View File
@@ -3,6 +3,7 @@ agp = "8.5.0"
kotlin = "1.9.24" kotlin = "1.9.24"
coreKtx = "1.13.1" coreKtx = "1.13.1"
junit = "4.13.2" junit = "4.13.2"
orgJson = "20240303"
junitVersion = "1.2.1" junitVersion = "1.2.1"
espressoCore = "3.6.1" espressoCore = "3.6.1"
composeBom = "2024.09.00" composeBom = "2024.09.00"
@@ -22,6 +23,7 @@ coroutinesPlayServices = "1.8.1"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" } 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-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } 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" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+7
View File
@@ -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
View File
@@ -1,2 +1,2 @@
versionName=0.1.3 versionName=0.1.9
versionCode=4 versionCode=10