22 Commits
Author SHA1 Message Date
tuxgyverandClaude Opus 4.8 d4aa0e1962 feat: partage d'écran Android 5+ (MediaProjection) et blocage des installations
- Partage d'écran par MediaProjection (ScreenCaptureService + ScreenConsentActivity)
  quand la capture par accessibilité n'est pas possible (Android < 11 ou service coupé).
  En mode propriétaire, le service d'accessibilité valide seul le consentement système.
- Blocage de l'installation d'applications (Play Store compris) piloté par le parent
  (policy block_installs → OwnerPolicy DISALLOW_INSTALL_APPS en mode propriétaire).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ
2026-10-25 22:28:16 +01:00
tuxgyverandClaude Opus 4.8 665d6e2cb3 feat: quatre modes réseau distincts (liste noire explicite)
Le serveur distingue désormais open / blocklist / allowlist / cut. La liste
noire (services bloqués par le DNS) devient un mode à part entière et ne
s'applique plus que dans ce mode. DataControl affiche le libellé du nouveau
mode sur le tableau de bord, la notification et le journal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ
2026-10-25 21:50:36 +01:00
tuxgyverandClaude Opus 4.8 c66302a2bd feat: verrouillage parental et désactivation par code du serveur
Le parent verrouille le téléphone à distance sur un écran de blocage plein
écran (ParentalLockActivity) que seul un code à usage unique généré par le
serveur permet de lever. La désactivation du contrôle (couper la protection,
dissocier, quitter le mode propriétaire, retirer l'anti-désinstallation) exige
désormais, sur un téléphone associé, ce code du serveur au lieu du mot de passe
local : l'enfant ne peut plus rien désactiver sans l'accord du parent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ
2026-10-25 21:26:41 +01:00
tuxgyver b138a15513 chore(release): v0.7.0 2026-10-25 16:30:09 +01:00
tuxgyverandClaude Opus 4.8 303d2c4cdc feat: déploiement forcé de DataControl (réinstallation même version)
AppDeployer honore le drapeau `force` de l'ordre de déploiement envoyé par
le serveur : la (ré)installation est effectuée même si la version proposée
n'est pas plus récente que celle installée, et les garde-fous anti-rejeu sont
ignorés. Utile pour réparer ou réinstaller DataControl à distance via le tunnel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ
2026-10-25 16:28:59 +01:00
tuxgyver 36c829c14d chore(release): v0.6.0 2026-10-25 15:33:43 +01:00
tuxgyverandClaude Opus 4.8 ed8be296ba feat: faire sonner le téléphone à distance et accusé de réception des commandes
Nouvelle action « Faire sonner » (commandes ring / ring_stop) : sonnerie
d'alarme à plein volume même en silencieux, vibration et notification
« touchez pour arrêter », arrêt automatique au bout de la durée demandée.

Chaque commande reçue est désormais acquittée au serveur (exécutée ou en
échec, avec le détail), ce qui complète l'audit côté espace parents.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ
2026-10-25 15:31:36 +01:00
tuxgyverandClaude Opus 4.8 7af250cc1c feat: actions à distance du parent (voir l'écran, verrouiller/veille, couper le son)
- Partage d'écran par le service d'accessibilité (Android 11+), image réduite envoyée dans le tunnel
- Bandeau « Écran partagé avec le parent » visible pendant tout le partage, arrêt auto après 5 min
- Verrouillage/mise en veille (lockNow, force-lock) et coupure/rétablissement du son
- Boucle de commandes en attente longue via /api/device/commands

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ
2026-10-25 15:07:48 +01:00
tuxgyverandClaude Opus 5.5 e6215ab4a7 feat: mises à jour de DataControl poussées depuis le serveur (installation silencieuse en mode propriétaire)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ
2026-10-25 14:59:27 +01:00
tuxgyverandClaude Opus 5.5 1b5558ff8b feat: provisionnement du mode propriétaire par QR code (appairage et protection automatiques)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ
2026-10-25 14:51:34 +01:00
tuxgyverandClaude Opus 5.5 c9547016eb chore(release): v0.2.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ
2026-10-25 14:45:47 +01:00
tuxgyverandClaude Opus 5.5 33ea4ff947 feat: tunnel WireGuard vers le serveur familyd et mode propriétaire de l'appareil
- Firebase et le pare-feu local (trou noir, liste blanche par application, coupure cyclique locale) sont retirés
- Client WireGuard officiel (GoBackend) : tout le trafic passe par le serveur familyd, qui filtre
- Appairage par code ou lien familyd:// ; clé privée WireGuard et jeton chiffrés par l'Android Keystore
- Remontées par le tunnel : état, temps d'écran, applications installées, évènements (file persistante)
- Mode propriétaire (Device Owner) : VPN toujours actif avec blocage hors VPN, réglages verrouillés,
  garde-fou si le serveur ne répond plus pendant 15 min
- Plan de la refonte : docs/PLAN-wireguard.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ
2026-10-25 14:44:18 +01:00
tuxgyverandClaude Opus 5.5 f382eeb4fe chore(release): v0.1.10
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ
2026-10-25 11:17:24 +01:00
tuxgyverandClaude Opus 5.5 11d7ed1fa6 fix: configuration Firebase acceptée avec guillemets typographiques ou google-services.json
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ
2026-10-25 11:16:07 +01:00
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
tuxgyverandClaude Sonnet 5 82d47fcd72 release: v0.1.3 — correctif VPN, alerte pare-feu inactif, contrôle parental (FamilyControl), anti-désinstallation
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 17:37:40 +02:00
70 changed files with 5760 additions and 989 deletions

No files matched your search

+8
View File
@@ -16,3 +16,11 @@ local.properties
# Jeton Gitea (lecture/écriture) pour "make publish"/"make deploy" : local, # Jeton Gitea (lecture/écriture) pour "make publish"/"make deploy" : local,
# jamais versionné. # jamais versionné.
gitea-token.properties gitea-token.properties
# Configuration Firebase locale (clés du projet).
firebase.properties
# Clé de signature des releases (chemin + mots de passe) : local, jamais versionné.
keystore.properties
*.keystore
*.jks
+96
View File
@@ -1,5 +1,101 @@
# Changelog # Changelog
## v0.10.0 – 2026-10-25
- ✨ **Ajouté** — **Partage d'écran compatible dès Android 5** : capture par MediaProjection quand la capture par le service d'accessibilité n'est pas possible (celle-ci reste réservée à Android 11+). En mode propriétaire, le consentement système « Démarrer la capture » peut être validé automatiquement par le service d'accessibilité ; sinon un appui est demandé sur le téléphone. Une notification « Écran partagé avec le parent » reste affichée pendant le partage.
- ✨ **Ajouté** — **Blocage de l'installation d'applications** (Play Store compris), piloté par le parent depuis l'espace parents. Appliqué en mode propriétaire (restriction système `DISALLOW_INSTALL_APPS`).
## v0.9.0 – 2026-10-25
- 🛠️ **Modifié** — **Quatre modes réseau distincts** décidés par le serveur, affichés sur le tableau de bord et la notification : *Accès libre* (aucun filtrage), *Liste noire* (tout sauf les services bloqués), *Liste blanche* (rien sauf les domaines autorisés), *Internet coupé*. La liste noire ne s'applique désormais que dans le mode qui lui correspond.
## v0.8.0 – 2026-10-25
- ✨ **Ajouté** — **Verrouillage parental à distance** : depuis l'espace parents, le parent verrouille le téléphone sur un écran de blocage plein écran que seul un **code à usage unique** (généré par le serveur et dicté à l'enfant) permet de lever. En mode propriétaire, l'écran est épinglé (accueil et récents neutralisés) ; sinon il revient de lui-même si l'enfant tente de le quitter. Le parent peut aussi déverrouiller à distance.
- 🔒 **Sécurité** — **Désactivation protégée par un code du serveur** : sur un téléphone associé, couper la protection, dissocier le téléphone, quitter le mode propriétaire ou retirer l'anti-désinstallation exige désormais un code à usage unique fourni par le parent — **à la place du mot de passe local**. L'enfant ne peut plus rien désactiver sans l'accord du parent.
## v0.7.0 – 2026-10-25
- ✨ **Ajouté** — **Déploiement forcé** depuis l'espace parents : réinstalle DataControl via le tunnel WireGuard même si la version proposée n'est pas plus récente que celle installée (réparation / réinstallation à distance).
## v0.6.0 – 2026-10-25
- ✨ **Ajouté** — **Faire sonner le téléphone** depuis l'espace parents : une sonnerie d'alarme retentit à plein volume (même si le téléphone est en silencieux), avec vibration et une notification « touchez pour arrêter » ; la sonnerie s'arrête d'elle-même au bout de la durée demandée, ou quand le parent l'arrête.
- 🛠️ **Modifié** — Chaque action à distance (verrouillage, son, partage d'écran, sonnerie) est désormais acquittée au serveur — exécutée ou en échec, avec le détail — ce qui complète l'audit des commandes côté espace parents.
## v0.5.0 – 2026-10-25
- ✨ **Ajouté** — **Actions à distance depuis l'espace parents** (téléphones DataControl) : voir l'écran en direct, verrouiller / mettre en veille, couper ou rétablir le son.
- ✨ **Ajouté** — Partage d'écran par le service d'accessibilité (Android 11+), image réduite envoyée par le tunnel ; un bandeau rouge « Écran partagé avec le parent » reste affiché sur le téléphone pendant toute la durée, et le partage s'arrête de lui-même au bout de 5 minutes.
## v0.4.0 – 2026-10-25
- ✨ **Ajouté** — **Mises à jour poussées depuis le serveur** : depuis l'espace parents (bouton « Déployer » ou « Mettre à jour DataControl partout »), la nouvelle version est téléchargée par le tunnel, son empreinte vérifiée, puis installée — sans aucune action sur le téléphone en mode propriétaire, sinon après un toucher sur la notification « Mise à jour de DataControl ».
- 🛠️ **Modifié** — DataControl redémarre sa surveillance tout seul juste après une mise à jour.
## v0.3.0 – 2026-10-25
- ✨ **Ajouté** — **Provisionnement du mode propriétaire par QR code**, sans PC ni débogage USB : sur un téléphone réinitialisé, toucher 6 fois l'écran d'accueil puis scanner le QR créé sur l'espace parents. DataControl est installé, devient propriétaire de l'appareil, s'associe tout seul au serveur et active la protection.
- ✨ **Ajouté** — En mode propriétaire, DataControl s'accorde les autorisations de notification et de position, et pose ses verrous avant de monter le tunnel.
## v0.2.0 – 2026-10-25
- ✨ **Ajouté** — **Tunnel WireGuard permanent vers le serveur familyd** du parent : tout le trafic du téléphone (Wi-Fi et données mobiles) passe par le serveur, qui applique le mode choisi (accès libre, liste blanche seulement, Internet coupé) et bloque les services interdits.
- ✨ **Ajouté** — Appairage par code (ou lien familyd://) dans Réglages → Contrôle parental ; clés WireGuard générées sur le téléphone, clé privée et jeton chiffrés par l'Android Keystore.
- ✨ **Ajouté** — **Mode propriétaire de l'appareil** : VPN toujours actif avec blocage hors VPN, réglages VPN / date et heure / DNS privé verrouillés, pas de réinitialisation, DataControl indésinstallable ; garde-fou si le serveur ne répond plus pendant 15 min.
- ✨ **Ajouté** — Le tableau de bord et la notification affichent l'état du tunnel et le mode décidé par le parent.
- 🗑️ **Supprimé** — Firebase, le pare-feu local (liste blanche par application, filtrage Wi-Fi) et la coupure cyclique locale : ces règles s'appliquent désormais sur le serveur. L'onglet Liste blanche disparaît.
- ⚙️ **Technique** — Mise à jour depuis la v0.1 : l'ancien appairage Firebase est effacé, il faut associer de nouveau le téléphone puis réactiver la protection. APK limité aux processeurs ARM (wireguard-go natif).
## v0.1.10 – 2026-10-25
- 🐛 **Corrigé** — Configuration Firebase (Réglages → Contrôle parental) : le bloc collé est accepté même si le copier-coller a remplacé les guillemets par des guillemets typographiques (“ ” « »), et le contenu du fichier google-services.json de l'application Android est aussi reconnu.
## v0.1.9 – 2026-10-25
- 🔒 **Sécurité** — Retirer l'autorisation VPN ou activer un autre VPN ne désactive plus le contrôle : la notification passe en rouge, le parent est alerté, le tunnel est remonté après 30 s si possible et l'autorisation est redemandée à l'ouverture.
- 🔒 **Sécurité** — Changer à la main la date, l'heure ou le fuseau du téléphone ne contourne plus les plages horaires ni le temps d'écran : le changement est ignoré et signalé au parent.
- 🔒 **Sécurité** — Mot de passe haché avec PBKDF2 ; après 3 essais ratés, attente croissante (30 s à 15 min). Les mots de passe existants restent valides.
- 🔒 **Sécurité** — Effacer le journal demande le mot de passe ; les écrans protégés ne s'affichent plus un instant avant le verrou.
- 🔒 **Sécurité** — Le code d'appairage ne sert qu'une fois ; réglages, appairage et journal ne sont plus inclus dans la sauvegarde Google.
- 🐛 **Corrigé** — L'alerte « protection anti-désinstallation retirée » ne peut plus se perdre ; si le système refuse de relancer la surveillance, une notification invite à rouvrir l'application et le parent est prévenu.
- 🐛 **Corrigé** — La liste des applications est renvoyée au parent à chaque installation ou désinstallation ; le bouton Retour de l'écran de blocage renvoie toujours à l'accueil.
- 🛠️ **Modifié** — Journal plus léger (la liste n'est reconstruite que lorsque l'onglet est ouvert) et horodatage fiable.
- ⚙️ **Technique** — Signature des releases via `keystore.properties` (même clé qu'avant) ; premiers tests unitaires ; README et page « À propos » alignés sur le comportement réel (la coupure cyclique tourne en permanence sur données mobiles).
## v0.1.8 – 2026-09-21
- Contrôle parental : la configuration Firebase se **saisit dans l'application** (Réglages → Contrôle parental → Configuration Firebase), en collant la chaîne « fc1:… » partagée par FamilyControl ou le bloc « firebaseConfig » de la console. Plus besoin de recompiler ni de fichier firebase.properties (qui reste possible comme valeur par défaut).
- 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
- **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.
- Forfait par défaut : 1 Go (1000 Mo).
- Contrôle parental : appairage avec **FamilyControl** (Firebase), envoi de la batterie, de la position, de l'état du pare-feu et de l'historique de trafic ; les accès réseau par application se pilotent à distance.
- Protection anti-désinstallation (administrateur d'appareil) ; sa désactivation depuis l'app demande le mot de passe.
## v0.1.2 – 2026-09-20 ## v0.1.2 – 2026-09-20
- Mise à jour depuis les Releases Gitea : bouton « Rechercher une mise à jour » dans l'onglet À propos (téléchargement + installation de l'APK). - Mise à jour depuis les Releases Gitea : bouton « Rechercher une mise à jour » dans l'onglet À propos (téléchargement + installation de l'APK).
+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."
+56 -31
View File
@@ -1,37 +1,47 @@
# DataControl # DataControl
**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. 🚀 v0.10.0 · 🤖 Android · 🟣 Kotlin · 🎨 Jetpack Compose · 🔒 WireGuard · 🛡️ mode propriétaire · 👨‍👩‍👧 contrôle parental · 🧪 tests · 📄 licence non déclarée
> JT-Tools by Johnny — v0.1.0 **DataControl** est l'application Android du téléphone de l'enfant. Elle relie le téléphone en permanence, par un tunnel **WireGuard**, au serveur de contrôle parental **familyd** du parent : tout le trafic (Wi-Fi comme données mobiles) passe par ce serveur, qui décide de ce qui passe. Sur le téléphone, elle bloque les applications interdites, compte le temps d'écran et suit la consommation de données mobiles.
> JT-Tools by Johnny — version courante et historique : voir [CHANGELOG.md](CHANGELOG.md) (aussi lisible dans l'onglet *À propos* de l'application). Plan de la refonte WireGuard : [docs/PLAN-wireguard.md](docs/PLAN-wireguard.md).
--- ---
## Fonctionnalités ## Fonctionnalités
- **Suivi de la consommation** — Mesure en continu les données mobiles consommées depuis le début du cycle de forfait (`TrafficStats`, aucune permission spéciale requise). - **Tunnel WireGuard permanent** — Une fois le téléphone associé et la protection activée, tout son trafic passe par le serveur du parent. Bibliothèque officielle `com.wireguard.android:tunnel` (wireguard-go embarqué).
- **Forfait configurable** — Volume du forfait (Mo), prix du forfait (€), prix par Mo hors-forfait (€), date de début du cycle. - **Accès décidé par le parent** — Le serveur applique l'un des trois modes : *accès libre*, *liste blanche seulement* (par exemple Pronote) ou *Internet coupé* ; à la main, ou selon des plages horaires, un forfait de données mobiles ou une coupure cyclique. Il bloque aussi des services (réseaux sociaux, vidéo, jeux…) par son DNS filtrant.
- **Estimation du coût** — Calcul automatique : prix du forfait + (Mo hors-forfait × prix/Mo), affiché en temps réel sur le tableau de bord. - **Appairage par code** — Code de 8 caractères (ou lien `familyd://`) créé sur l'espace parents, saisi dans *Réglages → Contrôle parental*. La paire de clés WireGuard est générée sur le téléphone ; la clé privée et le jeton de l'appareil sont chiffrés par l'Android Keystore.
- **Réinitialisation** — Automatique chaque mois à la date anniversaire du début de forfait, ou manuelle à tout moment depuis le tableau de bord. - **Règles d'usage** (service d'accessibilité) — Plages horaires, 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 ne contourne pas ces règles (le changement est ignoré et signalé au parent).
- **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é. - **Remontées au parent** — Batterie, position (si autorisée), type de réseau, état des protections, applications installées, temps d'écran, évènements (tunnel coupé, protection retirée, heure modifiée…). Les évènements sont gardés sur le téléphone tant que le serveur n'est pas joignable.
- **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". - **Tunnel coupé = alerte** — Si le tunnel tombe (VPN retiré, autre VPN, serveur injoignable), la protection reste activée : la notification passe en rouge (« PROTECTION INACTIVE »), le parent est prévenu et le tunnel est remonté dès que possible.
- **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. - **Mode propriétaire de l'appareil** (Device Owner, facultatif) — VPN toujours actif avec blocage des connexions hors VPN, réglages VPN / date et heure / DNS privé verrouillés, pas de réinitialisation ni de mode sans échec, DataControl indésinstallable. Garde-fou : si le serveur ne répond plus pendant 15 min, le blocage hors VPN est levé jusqu'à son retour.
- **Démarrage au boot** — Le service redémarre automatiquement après un redémarrage de l'appareil si le contrôle était actif. - **Suivi de la consommation** — Données mobiles depuis le début du cycle (`TrafficStats`), forfait, coût estimé, réinitialisation mensuelle automatique ou manuelle.
- **Interface Material 3** — Thème clair/sombre avec couleurs dynamiques (Android 12+), 3 écrans : Tableau de bord, Réglages, Liste blanche. - **Mot de passe de verrouillage** — Protège l'accès aux Réglages et l'effacement du journal (haché avec PBKDF2 ; après 3 essais ratés, attente croissante de 30 s à 15 min). Sur un téléphone associé, la *désactivation* du contrôle et la sortie du mode propriétaire exigent en plus un code du serveur dicté par le parent (voir ci-dessus).
- **Journal de diagnostic** — État du tunnel, mode appliqué et, avec le service d'accessibilité, chaque application utilisée. Conservé 15 jours.
- **Mise à jour intégrée** — Dernière Release Gitea depuis l'onglet *À propos* ; le serveur familyd la propose aussi aux appareils du tunnel sur `http://10.78.0.1/installer`.
- **Déploiement depuis le serveur** — Le parent pousse la nouvelle version depuis l'espace parents : téléchargement par le tunnel, empreinte SHA-256 vérifiée, installation silencieuse en mode propriétaire (sinon un toucher sur la notification).
- **Actions à distance** — Depuis l'espace parents : voir l'écran en direct (service d'accessibilité, Android 11+, avec bandeau visible « Écran partagé » sur le téléphone), verrouiller / mettre en veille, couper ou rétablir le son, faire sonner, et **verrouiller le téléphone** sur un écran de blocage que seul un code du serveur lève.
- **Déverrouillage et désactivation par code** — Sur un téléphone associé, couper la protection, dissocier, quitter le mode propriétaire ou lever le verrouillage parental exige un **code à usage unique** généré par le parent (dans l'espace parents) et dicté à l'enfant, au lieu du mot de passe local.
--- ---
## Comportement du pare-feu (important) ## Installation sur le téléphone de l'enfant
Android interdit depuis longtemps aux applications normales de couper les données mobiles par programmation (l'API historique a été bloquée depuis Android 5). DataControl **ne nécessite ni root ni Shizuku** : il utilise un pare-feu local basé sur `VpnService` (comme NetGuard), qui ne route ni ne transmet aucun paquet — il capture le trafic des applications non autorisées dans un tunnel VPN local et le laisse silencieusement tomber ("trou noir"), tandis que les applications autorisées sont explicitement exclues du tunnel (`addDisallowedApplication`) et continuent de communiquer normalement. 1. **Installer DataControl** : par la Release Gitea, ou — sans PC — en ajoutant d'abord le téléphone par QR code dans l'application WireGuard (espace parents → *Ajouter un appareil*), puis en ouvrant `http://10.78.0.1/installer` dans son navigateur.
2. **Associer** : espace parents → *Associer un téléphone (DataControl)* ; saisir le code dans *Réglages → Contrôle parental*.
3. **Activer la protection** sur le tableau de bord et accepter la demande VPN d'Android.
4. **Verrouiller** (recommandé) : mot de passe dans *Réglages → Sécurité*, administrateur d'appareil, et si possible le mode propriétaire.
Règles appliquées : **Par QR code, sans PC** (téléphone réinitialisé) : espace parents → *Préparer un téléphone (mode propriétaire)* ; sur l'écran « Bienvenue » du téléphone, toucher 6 fois au même endroit, scanner le QR. DataControl est installé, devient propriétaire, s'associe et active la protection tout seul.
| Réseau actif | Sous le forfait | Forfait dépassé | **Ou par adb** :
|---|---|---|
| **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) |
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é. ```bash
# téléphone branché en USB, débogage USB actif, tous les comptes retirés le temps de la commande
adb shell dpm set-device-owner com.jttools.datacontrol/.admin.AppDeviceAdminReceiver
```
--- ---
@@ -39,28 +49,40 @@ Le pare-feu est démarré/arrêté automatiquement selon le transport réseau ac
| Permission | Usage | | Permission | Usage |
|---|---| |---|---|
| VPN (consentement système) | Établir le pare-feu local — demandé au premier `Activer le contrôle` | | VPN (consentement système) | Tunnel WireGuard — demandé à l'activation de la protection (accordé automatiquement en mode propriétaire) |
| Notifications (Android 13+) | Afficher la notification permanente d'état | | Notifications (Android 13+) | Notification permanente d'état |
| 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 n'arrête la surveillance |
| `QUERY_ALL_PACKAGES` | Lister toutes les applications installées dans l'écran Liste blanche | | `QUERY_ALL_PACKAGES` | Liste des applications installées, pour que le parent choisisse lesquelles limiter |
| `RECEIVE_BOOT_COMPLETED` | Relancer la surveillance après redémarrage si le contrôle était actif | | `RECEIVE_BOOT_COMPLETED` | Relancer la surveillance et le tunnel après redémarrage |
| Position (dont arrière-plan) | Facultatif — transmise au parent |
| Administrateur d'appareil / propriétaire de l'appareil | Facultatif — anti-désinstallation, verrouillage du VPN et des réglages |
| Service d'accessibilité | Facultatif — détecte l'application au premier plan (sans lire les écrans) pour les règles d'usage |
Réseau : sortant UDP 51721 (tunnel vers `fam-vpn.tuxtech.fr`) et HTTPS (appairage sur `family.tuxtech.fr`, mises à jour sur `git.tuxtech.fr`). L'API du serveur est jointe en HTTP **dans** le tunnel (10.78.0.1:8100), seule exception autorisée par `network_security_config.xml`.
--- ---
## Limites connues ## Limites connues
- Le compteur de consommation repose sur `TrafficStats` (cumul depuis le dernier redémarrage de l'appareil). Un redémarrage réinitialise ce compteur système ; DataControl détecte cette situation et rebascule automatiquement sa référence à 0 pour éviter un calcul négatif, mais la consommation entre le redémarrage et la détection n'est pas comptabilisée. - Sans le mode propriétaire, le VPN peut être coupé dans les réglages Android : le serveur le voit et alerte le parent, sans pouvoir l'empêcher.
- `QUERY_ALL_PACKAGES` est soumis à restriction sur le Google Play Store pour les apps grand public ; sans distribution via le Store, cela ne pose pas de problème. - Le mode propriétaire se provisionne par QR code au premier démarrage (téléphone réinitialisé) ou par `adb` sur un téléphone sans compte.
- Le pare-feu VPN local est incompatible avec l'usage simultané d'un autre VPN (limitation système Android : un seul VPN actif à la fois). - Un seul VPN peut être actif à la fois sous Android : DataControl est incompatible avec un autre VPN.
- Si le serveur familyd est arrêté, le téléphone n'est plus filtré (et, en mode propriétaire, n'a plus Internet pendant 15 min au plus).
- Le compteur de consommation local repose sur `TrafficStats` (remis à zéro au redémarrage, la référence est recalée) ; le serveur mesure de son côté le trafic réel du tunnel.
--- ---
## Architecture ## Architecture
- **Kotlin + Jetpack Compose (Material 3)**, Hilt pour l'injection de dépendances. - **Kotlin + Jetpack Compose (Material 3)**, Hilt, DataStore Preferences.
- `data/` — `SettingsRepository` (DataStore Preferences), `UsageRepository` (TrafficStats), `InstalledAppsRepository` (PackageManager). - `vpn/` — `TunnelManager` (bibliothèque WireGuard officielle, `GoBackend`).
- `service/` — `FirewallVpnService` (VpnService), `ConnectivityObserver` (détection Wi-Fi/cellulaire), `DataMonitorService` (orchestration + notification), `MonitorStateHolder` (pont service ↔ UI), `BootReceiver`. - `remote/` — `FamilyApi` (client HTTP de familyd), `FamilyLink` / `PairingInput` (liaison et saisie du code), `RemoteSync` (appairage, consignes, file d'évènements), `DeviceReporter` (état, temps d'écran, applications).
- `ui/` — écrans Compose (`dashboard`, `settings`, `whitelist`), navigation par `NavigationBar`, thème dynamique. - `service/` — `DataMonitorService` (tunnel, consommation, notification, mode propriétaire), `ConnectivityObserver`, `MonitorStateHolder`, `BootReceiver`, `DataMonitorRestartWorker`.
- `guard/` — `GuardEngine` (règles d'usage, temps d'écran, horloge de confiance), `AppGuardService` (accessibilité), `BlockedActivity`.
- `admin/` — administrateur d'appareil et `OwnerPolicy` (mode propriétaire). `data/` — réglages, mot de passe, `SecretBox` (Android Keystore). `log/` — journal.
- `ui/` — écrans Compose (`dashboard`, `settings`, `family`, `journal`, `about`).
Le serveur **familyd** (FastAPI, WireGuard, nftables, AdGuard Home) est dans un dépôt séparé (`~/scripts/familyd`).
--- ---
@@ -70,10 +92,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
+40 -1
View File
@@ -9,6 +9,13 @@ plugins {
alias(libs.plugins.kotlin.kapt) alias(libs.plugins.kotlin.kapt)
} }
// 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())
} }
@@ -28,10 +35,23 @@ android {
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
} }
// wireguard-go est natif : seuls les téléphones (ARM) sont visés, pour alléger l'APK.
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") }
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
@@ -39,7 +59,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")
}
} }
} }
@@ -68,6 +91,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))
@@ -87,8 +122,12 @@ dependencies {
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.work.runtime.ktx)
// Client WireGuard officiel (GoBackend : wireguard-go embarqué, VpnService fourni par la bibliothèque).
implementation(libs.wireguard.tunnel)
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))
+5
View File
@@ -7,3 +7,8 @@
# Hilt # Hilt
-dontwarn dagger.hilt.** -dontwarn dagger.hilt.**
# WireGuard (com.wireguard.android:tunnel) : méthodes natives (JNI) et rappels appelés par libwg-go.
-keep class com.wireguard.android.backend.** { *; }
-keep class com.wireguard.config.** { *; }
-keep class com.wireguard.crypto.** { *; }
+96 -10
View File
@@ -6,10 +6,16 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Position de l'appareil, transmise au parent via FamilyControl (arrière-plan compris). -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- Nécessaire pour lister TOUTES les apps installées dans l'écran "Liste blanche" --> <!-- Nécessaire pour lister TOUTES les apps installées dans l'écran "Liste blanche" -->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" /> tools:ignore="QueryAllPackagesPermission" />
@@ -19,6 +25,7 @@
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:networkSecurityConfig="@xml/network_security_config"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
@@ -35,21 +42,23 @@
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".service.FirewallVpnService"
android:permission="android.permission.BIND_VPN_SERVICE"
android:exported="false"
android:foregroundServiceType="dataSync">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
</service>
<service <service
android:name=".service.DataMonitorService" android:name=".service.DataMonitorService"
android:exported="false" android:exported="false"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync" />
<!-- Partage d'écran par MediaProjection (fonctionne dès Android 5, contrairement à l'accessibilité). -->
<service
android:name=".remote.ScreenCaptureService"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
<activity
android:name=".remote.ScreenConsentActivity"
android:exported="false"
android:excludeFromRecents="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- FileProvider pour installer l'APK de mise à jour téléchargé --> <!-- FileProvider pour installer l'APK de mise à jour téléchargé -->
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
@@ -61,12 +70,89 @@
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" />
<!-- Verrouillage parental plein écran : levé uniquement par un code à usage unique du serveur. -->
<activity
android:name=".guard.ParentalLockActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTask"
android:showOnLockScreen="true"
android:theme="@style/Theme.DataControl" />
<!-- Administrateur d'appareil : bloque la désinstallation tant qu'il est actif. -->
<receiver
android:name=".admin.AppDeviceAdminReceiver"
android:exported="true"
android:permission="android.permission.BIND_DEVICE_ADMIN">
<meta-data
android:name="android.app.device_admin"
android:resource="@xml/device_admin" />
<intent-filter>
<action android:name="android.app.action.DEVICE_ADMIN_ENABLED" />
<action android:name="android.app.action.PROFILE_PROVISIONING_COMPLETE" />
</intent-filter>
</receiver>
<!-- Provisionnement « propriétaire de l'appareil » par QR code (assistant de première mise en route). -->
<activity
android:name=".admin.ProvisioningModeActivity"
android:exported="true"
android:permission="android.permission.BIND_DEVICE_ADMIN"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<intent-filter>
<action android:name="android.app.action.GET_PROVISIONING_MODE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name=".admin.PolicyComplianceActivity"
android:exported="true"
android:permission="android.permission.BIND_DEVICE_ADMIN"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<intent-filter>
<action android:name="android.app.action.ADMIN_POLICY_COMPLIANCE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<!-- Résultat des mises à jour poussées par le serveur (PackageInstaller). -->
<receiver
android:name=".remote.InstallResultReceiver"
android:exported="false" />
<!-- Arrêt de la sonnerie « Faire sonner » depuis la notification. -->
<receiver
android:name=".remote.RingStopReceiver"
android:exported="false" />
<receiver <receiver
android:name=".service.BootReceiver" android:name=".service.BootReceiver"
android:exported="true" android:exported="true"
android:enabled="true"> android:enabled="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" /> <action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter> </intent-filter>
</receiver> </receiver>
@@ -20,7 +20,16 @@ class DataControlApp : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
CoroutineScope(Dispatchers.IO).launch { settingsRepository.resetPasswordOnce() } com.jttools.datacontrol.log.AppLog.init(this)
CoroutineScope(Dispatchers.IO).launch {
settingsRepository.resetPasswordOnce()
settingsRepository.migrateToV02()
}
// VPN « toujours actif » (réglage Android ou mode propriétaire) : le système démarre le service
// VPN de la bibliothèque ; on relance alors la surveillance, qui monte le tunnel.
com.wireguard.android.backend.GoBackend.setAlwaysOnCallback {
com.jttools.datacontrol.service.DataMonitorService.start(this)
}
// 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)
}
}
}
}
} }
@@ -0,0 +1,31 @@
package com.jttools.datacontrol.admin
import android.app.admin.DevicePolicyManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
/** Protection anti-désinstallation : tant que DataControl est administrateur de l'appareil, Android refuse de le désinstaller. */
object AdminController {
fun component(context: Context) = ComponentName(context, AppDeviceAdminReceiver::class.java)
fun isActive(context: Context): Boolean =
context.getSystemService(DevicePolicyManager::class.java).isAdminActive(component(context))
fun requestIntent(context: Context): Intent =
Intent(DevicePolicyManager.ACTION_ADD_DEVICE_ADMIN)
.putExtra(DevicePolicyManager.EXTRA_DEVICE_ADMIN, component(context))
.putExtra(
DevicePolicyManager.EXTRA_ADD_EXPLANATION,
"Empêche la désinstallation de DataControl sans mot de passe."
)
/** Vrai si DataControl est « propriétaire de l'appareil » (provisionné par adb, voir le plan, phase 4). */
fun isDeviceOwner(context: Context): Boolean =
context.getSystemService(DevicePolicyManager::class.java).isDeviceOwnerApp(context.packageName)
/** À n'appeler qu'après vérification du mot de passe. */
fun deactivate(context: Context) {
context.getSystemService(DevicePolicyManager::class.java).removeActiveAdmin(component(context))
}
}
@@ -0,0 +1,38 @@
package com.jttools.datacontrol.admin
import android.app.admin.DeviceAdminReceiver
import android.content.Context
import android.content.Intent
import com.jttools.datacontrol.remote.RemoteSync
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class AppDeviceAdminReceiver : DeviceAdminReceiver() {
@Inject lateinit var remoteSync: RemoteSync
/** Android 9 et avant : fin du provisionnement signalée ici (Android 10+ : PolicyComplianceActivity). */
override fun onProfileProvisioningComplete(context: Context, intent: Intent) {
Provisioning.complete(context, Provisioning.extrasOf(intent))
}
override fun onDisableRequested(context: Context, intent: Intent): CharSequence =
"Désactiver la protection permet de désinstaller DataControl. Cette action est signalée à FamilyControl."
override fun onDisabled(context: Context, intent: Intent) {
// Prévient le parent : la protection anti-désinstallation vient d'être retirée.
// goAsync : sans lui, le processus peut être arrêté avant que l'alerte soit partie.
val pending = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
remoteSync.pushEvent("admin_disabled", "Protection anti-désinstallation désactivée")
} finally {
pending.finish()
}
}
}
}
@@ -0,0 +1,120 @@
package com.jttools.datacontrol.admin
import android.app.admin.DevicePolicyManager
import android.content.Context
import android.os.Build
import android.os.UserManager
import com.jttools.datacontrol.log.AppLog
/**
* Mode « propriétaire de l'appareil » (Device Owner), provisionné une fois par adb :
* adb shell dpm set-device-owner com.jttools.datacontrol/.admin.AppDeviceAdminReceiver
*
* DataControl impose alors au système : VPN toujours actif (connexions bloquées hors tunnel), réglages
* VPN / date et heure / DNS privé verrouillés, pas de réinitialisation, pas de mode sans échec, pas
* d'autre utilisateur, désinstallation impossible. Sans ce mode, toutes ces fonctions sont inertes.
*/
object OwnerPolicy {
/** Restrictions posées tant que la protection est active (le débogage USB reste permis : maintenance). */
private val RESTRICTIONS = buildList {
add(UserManager.DISALLOW_CONFIG_VPN)
add(UserManager.DISALLOW_CONFIG_DATE_TIME)
add(UserManager.DISALLOW_FACTORY_RESET)
add(UserManager.DISALLOW_SAFE_BOOT)
add(UserManager.DISALLOW_ADD_USER)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) add(UserManager.DISALLOW_CONFIG_PRIVATE_DNS)
}
/** État voulu, appliqué tel quel ; [lockdown] = connexions bloquées hors VPN ;
* [blockInstalls] = installation d'applications interdite (Play Store compris). */
data class Target(val protect: Boolean, val lockdown: Boolean, val blockInstalls: Boolean = false)
private var applied: Target? = null
fun isDeviceOwner(context: Context): Boolean = AdminController.isDeviceOwner(context)
private fun dpm(context: Context) = context.getSystemService(DevicePolicyManager::class.java)
/** Applique [target] si DataControl est propriétaire et que l'état a changé. Renvoie faux en cas d'échec. */
fun sync(context: Context, target: Target, force: Boolean = false): Boolean {
if (!isDeviceOwner(context)) {
applied = null
return true
}
if (!force && target == applied) return true
val dpm = dpm(context)
val admin = AdminController.component(context)
return try {
if (target.protect) {
// Le système accorde lui-même l'autorisation VPN au paquet désigné : aucune fenêtre de consentement.
dpm.setAlwaysOnVpnPackage(admin, context.packageName, target.lockdown)
RESTRICTIONS.forEach { dpm.addUserRestriction(admin, it) }
// Blocage des installations : facultatif, piloté par le parent.
if (target.blockInstalls) dpm.addUserRestriction(admin, UserManager.DISALLOW_INSTALL_APPS)
else dpm.clearUserRestriction(admin, UserManager.DISALLOW_INSTALL_APPS)
dpm.setUninstallBlocked(admin, context.packageName, true)
requireAutoTime(context)
} else {
dpm.setAlwaysOnVpnPackage(admin, null, false)
RESTRICTIONS.forEach { dpm.clearUserRestriction(admin, it) }
dpm.clearUserRestriction(admin, UserManager.DISALLOW_INSTALL_APPS)
dpm.setUninstallBlocked(admin, context.packageName, false)
}
applied = target
AppLog.i("Propriétaire", when {
!target.protect -> "Protections levées (contrôle désactivé ou appareil non associé)"
target.lockdown -> "VPN toujours actif, connexions bloquées hors VPN, réglages verrouillés"
else -> "VPN toujours actif SANS blocage hors VPN (serveur injoignable), réglages verrouillés"
})
true
} catch (e: Exception) {
AppLog.e("Propriétaire", "Application des protections impossible", e)
false
}
}
@Suppress("DEPRECATION")
private fun requireAutoTime(context: Context) {
val dpm = dpm(context)
val admin = AdminController.component(context)
runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
dpm.setAutoTimeEnabled(admin, true)
dpm.setAutoTimeZoneEnabled(admin, true)
} else {
dpm.setAutoTimeRequired(admin, true)
}
}
}
/** VPN toujours actif imposé par DataControl, et avec quel blocage. */
fun alwaysOnState(context: Context): Pair<Boolean, Boolean> {
if (!isDeviceOwner(context)) return false to false
val dpm = dpm(context)
val admin = AdminController.component(context)
return runCatching {
val pkg = dpm.getAlwaysOnVpnPackage(admin)
val lockdown = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && dpm.isAlwaysOnVpnLockdownEnabled(admin)
(pkg == context.packageName) to lockdown
}.getOrDefault(false to false)
}
/**
* Sortie du mode propriétaire (après vérification du mot de passe) : protections levées, puis
* DataControl redevient une application ordinaire (l'administrateur d'appareil simple reste actif).
*/
fun release(context: Context): Boolean {
if (!isDeviceOwner(context)) return true
sync(context, Target(protect = false, lockdown = false), force = true)
return runCatching {
dpm(context).clearDeviceOwnerApp(context.packageName)
applied = null
AppLog.w("Propriétaire", "Mode propriétaire de l'appareil retiré")
true
}.getOrElse {
AppLog.e("Propriétaire", "Retrait du mode propriétaire impossible", it)
false
}
}
}
@@ -0,0 +1,101 @@
package com.jttools.datacontrol.admin
import android.Manifest
import android.app.Activity
import android.app.admin.DevicePolicyManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.PersistableBundle
import com.jttools.datacontrol.data.SettingsRepository
import com.jttools.datacontrol.log.AppLog
import com.jttools.datacontrol.service.DataMonitorService
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.runBlocking
/**
* Provisionnement « propriétaire de l'appareil » par QR code, à la première mise en route d'un téléphone
* réinitialisé (QR créé sur l'espace parents). Le QR transmet, dans le bundle « admin extras », l'adresse du
* serveur familyd, un code d'appairage et le nom du téléphone : DataControl s'associe ensuite tout seul
* (voir DataMonitorService) et active la protection.
*/
object Provisioning {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface Deps {
fun settingsRepository(): SettingsRepository
}
fun extrasOf(intent: Intent?): PersistableBundle? {
intent ?: return null
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(DevicePolicyManager.EXTRA_PROVISIONING_ADMIN_EXTRAS_BUNDLE, PersistableBundle::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(DevicePolicyManager.EXTRA_PROVISIONING_ADMIN_EXTRAS_BUNDLE)
}
}
/** Fin du provisionnement : appairage en attente, autorisations accordées, surveillance démarrée. */
fun complete(context: Context, extras: PersistableBundle?) {
val app = context.applicationContext
val url = extras?.getString("familyd_url").orEmpty()
val code = extras?.getString("familyd_code").orEmpty()
val name = extras?.getString("device_name").orEmpty()
if (url.startsWith("https://") && code.isNotBlank()) {
val settings = EntryPointAccessors.fromApplication(app, Deps::class.java).settingsRepository()
// Écriture courte et indispensable avant de rendre la main à l'assistant de configuration.
runBlocking { settings.setPendingProvision(url, code, name) }
AppLog.i("Propriétaire", "Provisionnement par QR : appairage prévu avec $url (« $name »)")
} else {
AppLog.w("Propriétaire", "Provisionnement sans informations d'appairage : associer à la main")
}
grantPermissions(app)
DataMonitorService.start(app)
}
/** Le propriétaire de l'appareil accorde lui-même les autorisations d'exécution utiles. */
private fun grantPermissions(context: Context) {
if (!AdminController.isDeviceOwner(context)) return
val dpm = context.getSystemService(DevicePolicyManager::class.java)
val admin = AdminController.component(context)
val perms = buildList {
add(Manifest.permission.ACCESS_FINE_LOCATION)
add(Manifest.permission.ACCESS_COARSE_LOCATION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) add(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) add(Manifest.permission.POST_NOTIFICATIONS)
}
for (p in perms) {
val ok = runCatching {
dpm.setPermissionGrantState(admin, context.packageName, p, DevicePolicyManager.PERMISSION_GRANT_STATE_GRANTED)
}.getOrDefault(false)
AppLog.i("Propriétaire", "Autorisation ${p.substringAfterLast('.')} : ${if (ok) "accordée" else "refusée par le système"}")
}
}
}
/** Android 12+ : l'assistant demande quel mode de gestion l'application veut (ici : appareil entièrement géré). */
class ProvisioningModeActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setResult(RESULT_OK, Intent().putExtra(
DevicePolicyManager.EXTRA_PROVISIONING_MODE, DevicePolicyManager.PROVISIONING_MODE_FULLY_MANAGED_DEVICE
))
finish()
}
}
/** Android 10+ : dernière étape du provisionnement, l'application finalise sa configuration. */
class PolicyComplianceActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Provisioning.complete(this, Provisioning.extrasOf(intent))
setResult(RESULT_OK)
finish()
}
}
@@ -1,29 +1,37 @@
package com.jttools.datacontrol.data package com.jttools.datacontrol.data
import com.jttools.datacontrol.remote.FamilyLink
import java.time.LocalDate import java.time.LocalDate
/** /**
* Réglages persistés de l'application, avec leurs valeurs par défaut. * Réglages persistés de l'application, avec leurs valeurs par défaut.
*/ */
data class AppSettings( data class AppSettings(
/** Tunnel WireGuard vers le serveur familyd monté en permanence (désactivation protégée par mot de passe). */
val controlEnabled: Boolean = false, val controlEnabled: Boolean = false,
val planLimitMo: Long = 5000L, val planLimitMo: Long = 1000L,
val planPriceEuro: Double = 19.99, val planPriceEuro: Double = 19.99,
val overagePricePerMoEuro: Double = 0.01, val overagePricePerMoEuro: Double = 0.01,
val planStartDateEpochDay: Long = LocalDate.now().toEpochDay(), val planStartDateEpochDay: Long = LocalDate.now().toEpochDay(),
val autoResetEnabled: Boolean = true, val autoResetEnabled: Boolean = true,
val cutoffIntervalMinutes: Int = 15,
val baselineMobileBytesAtReset: Long = 0L, val baselineMobileBytesAtReset: Long = 0L,
val whitelistedPackages: Set<String> = DEFAULT_WHITELIST,
val whitelistedPackagesWifi: Set<String> = DEFAULT_WHITELIST,
val wifiFilterEnabled: Boolean = false,
val showSystemApps: Boolean = false,
val passwordHash: String? = null, val passwordHash: String? = null,
val passwordSalt: String? = null, val passwordSalt: String? = null,
val cutoffCycleAnchorMillis: Long? = null, /** Règles d'usage des applications (plages horaires, limites, interdites), envoyées par le serveur. */
val cutoffInPulse: Boolean = false, val rulesJson: String? = null,
val cutoffPulseStartMillis: Long = 0L /** Liaison avec le serveur familyd (null tant que l'appareil n'est pas associé). */
val link: FamilyLink? = null,
/** Mode réseau appliqué par le serveur : open, allowlist ou cut. */
val serverMode: String = "open",
val serverModeReason: String = "",
val policyVersion: Long = 0L,
/** Bloque l'installation d'applications (Play Store compris) — appliqué en mode propriétaire. */
val blockInstalls: Boolean = false,
/** Téléphone verrouillé à distance par le parent : un code du serveur est exigé pour le déverrouiller. */
val parentalLocked: Boolean = false
) { ) {
val isPaired: Boolean get() = link != null
val planStartDate: LocalDate get() = LocalDate.ofEpochDay(planStartDateEpochDay) val planStartDate: LocalDate get() = LocalDate.ofEpochDay(planStartDateEpochDay)
/** Un mot de passe de verrouillage protège la désactivation du contrôle et l'accès aux réglages. */ /** Un mot de passe de verrouillage protège la désactivation du contrôle et l'accès aux réglages. */
@@ -38,13 +46,6 @@ data class AppSettings(
} }
return next return next
} }
companion object {
const val PRONOTE_PACKAGE = "com.indexeducation.pronote"
/** Applications autorisées par défaut (tant que l'utilisateur n'a pas modifié la liste blanche). */
val DEFAULT_WHITELIST: Set<String> = setOf(PRONOTE_PACKAGE)
}
} }
/** État calculé de la consommation, dérivé des réglages + de la mesure de données réelle. */ /** État calculé de la consommation, dérivé des réglages + de la mesure de données réelle. */
@@ -60,8 +61,8 @@ data class UsageState(
} }
enum class DataState { enum class DataState {
OK, // sous le seuil d'alerte, liste blanche active, pas de coupure en cours OK, // sous le seuil d'alerte
WARNING, // proche ou au-delà du forfait (>= 80%), liste blanche active, pas de coupure en cours WARNING, // proche ou au-delà du forfait (>= 80 %)
CUT, // coupure totale cyclique en cours (indépendant du dépassement de forfait) CUT, // le serveur coupe l'accès (mode « coupé » ou « liste blanche »)
WIFI // Wi-Fi actif, aucun filtrage (sauf si filtrage Wi-Fi activé séparément) WIFI // Wi-Fi actif
} }
@@ -14,7 +14,7 @@ class InstalledAppsRepository @Inject constructor(
@ApplicationContext private val context: Context @ApplicationContext private val context: Context
) { ) {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
suspend fun listInstalledApps(): List<AppInfo> = withContext(Dispatchers.Default) { suspend fun listInstalledApps(withIcons: Boolean = true): List<AppInfo> = withContext(Dispatchers.Default) {
val pm = context.packageManager val pm = context.packageManager
val ownPackage = context.packageName val ownPackage = context.packageName
pm.getInstalledApplications(PackageManager.GET_META_DATA) pm.getInstalledApplications(PackageManager.GET_META_DATA)
@@ -24,7 +24,7 @@ class InstalledAppsRepository @Inject constructor(
AppInfo( AppInfo(
packageName = appInfo.packageName, packageName = appInfo.packageName,
label = pm.getApplicationLabel(appInfo).toString(), label = pm.getApplicationLabel(appInfo).toString(),
icon = runCatching { pm.getApplicationIcon(appInfo) }.getOrNull(), icon = if (withIcons) runCatching { pm.getApplicationIcon(appInfo) }.getOrNull() else null,
isSystemApp = (appInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0 isSystemApp = (appInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0
) )
} }
@@ -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() }
} }
@@ -0,0 +1,51 @@
package com.jttools.datacontrol.data
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* Chiffrement des secrets (jeton de l'appareil, clé privée WireGuard) par une clé AES de l'Android
* Keystore : la clé ne sort jamais du matériel, une copie des fichiers de l'application ne suffit pas.
* Format : « v1:<base64(iv + chiffré)> ».
*/
object SecretBox {
private const val ALIAS = "datacontrol_secrets"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private fun key(): SecretKey {
val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(ks.getKey(ALIAS, null) as? SecretKey)?.let { return it }
val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
gen.init(
KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build()
)
return gen.generateKey()
}
fun encrypt(plain: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION).apply { init(Cipher.ENCRYPT_MODE, key()) }
val out = cipher.iv + cipher.doFinal(plain.toByteArray(Charsets.UTF_8))
return "v1:" + Base64.encodeToString(out, Base64.NO_WRAP)
}
/** null si la valeur est absente ou illisible (clé du Keystore perdue : il faut se ré-associer). */
fun decrypt(stored: String?): String? {
if (stored.isNullOrEmpty() || !stored.startsWith("v1:")) return null
return runCatching {
val raw = Base64.decode(stored.removePrefix("v1:"), Base64.NO_WRAP)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, raw, 0, 12))
String(cipher.doFinal(raw, 12, raw.size - 12), Charsets.UTF_8)
}.getOrNull()
}
}
@@ -1,24 +1,35 @@
package com.jttools.datacontrol.data package com.jttools.datacontrol.data
import android.content.Context import android.content.Context
import android.os.SystemClock
import androidx.datastore.preferences.core.Preferences
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
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.jttools.datacontrol.log.AppLog
import com.jttools.datacontrol.remote.FamilyLink
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.time.LocalDate import java.time.LocalDate
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
private val Context.dataStore by preferencesDataStore(name = "datacontrol_settings") private val Context.dataStore by preferencesDataStore(name = "datacontrol_settings")
/** Évènement à transmettre au serveur, gardé tant qu'il n'est pas parti (tunnel coupé, serveur injoignable). */
data class PendingEvent(val type: String, val detail: String, val at: Long)
@Singleton @Singleton
class SettingsRepository @Inject constructor( class SettingsRepository @Inject constructor(
@ApplicationContext private val context: Context @ApplicationContext private val context: Context
@@ -30,18 +41,50 @@ class SettingsRepository @Inject constructor(
val OVERAGE_PRICE_PER_MO = doublePreferencesKey("overage_price_per_mo") val OVERAGE_PRICE_PER_MO = doublePreferencesKey("overage_price_per_mo")
val PLAN_START_DATE_EPOCH_DAY = longPreferencesKey("plan_start_date_epoch_day") val PLAN_START_DATE_EPOCH_DAY = longPreferencesKey("plan_start_date_epoch_day")
val AUTO_RESET_ENABLED = booleanPreferencesKey("auto_reset_enabled") val AUTO_RESET_ENABLED = booleanPreferencesKey("auto_reset_enabled")
val CUTOFF_INTERVAL_MINUTES = intPreferencesKey("cutoff_interval_minutes")
val BASELINE_MOBILE_BYTES = longPreferencesKey("baseline_mobile_bytes") val BASELINE_MOBILE_BYTES = longPreferencesKey("baseline_mobile_bytes")
val WHITELISTED_PACKAGES = stringSetPreferencesKey("whitelisted_packages")
val WHITELISTED_PACKAGES_WIFI = stringSetPreferencesKey("whitelisted_packages_wifi")
val WIFI_FILTER_ENABLED = booleanPreferencesKey("wifi_filter_enabled")
val SHOW_SYSTEM_APPS = booleanPreferencesKey("show_system_apps")
val PASSWORD_HASH = stringPreferencesKey("password_hash") val PASSWORD_HASH = stringPreferencesKey("password_hash")
val PASSWORD_SALT = stringPreferencesKey("password_salt") val PASSWORD_SALT = stringPreferencesKey("password_salt")
val PASSWORD_RESET_V1_DONE = booleanPreferencesKey("password_reset_v1_done") val PASSWORD_RESET_V1_DONE = booleanPreferencesKey("password_reset_v1_done")
val CUTOFF_ANCHOR_MILLIS = longPreferencesKey("cutoff_anchor_millis") val PASSWORD_FAILS = intPreferencesKey("password_fails")
val CUTOFF_IN_PULSE = booleanPreferencesKey("cutoff_in_pulse") val PASSWORD_LOCK_UNTIL = longPreferencesKey("password_lock_until")
val CUTOFF_PULSE_START_MILLIS = longPreferencesKey("cutoff_pulse_start_millis") val RULES_JSON = stringPreferencesKey("rules_json")
val PARENTAL_LOCKED = booleanPreferencesKey("parental_locked")
val USAGE_DAY = stringPreferencesKey("usage_day")
val USAGE_JSON = stringPreferencesKey("usage_json")
val CLOCK_SKEW_MS = longPreferencesKey("clock_skew_ms")
val CLOCK_ZONE = stringPreferencesKey("clock_zone")
// v0.2 : liaison familyd
val LINK_JSON = stringPreferencesKey("link_json")
val LINK_TOKEN_ENC = stringPreferencesKey("link_token_enc")
val WG_PRIVATE_ENC = stringPreferencesKey("wg_private_enc")
val SERVER_MODE = stringPreferencesKey("server_mode")
val BLOCK_INSTALLS = booleanPreferencesKey("block_installs")
val SERVER_MODE_REASON = stringPreferencesKey("server_mode_reason")
val POLICY_VERSION = longPreferencesKey("policy_version")
val EVENT_QUEUE = stringPreferencesKey("event_queue")
val MIGRATED_V02 = booleanPreferencesKey("migrated_v02")
val PENDING_PROVISION = stringPreferencesKey("pending_provision")
}
/** Clés de la v0.1 (Firebase, liste blanche locale, coupure cyclique locale) retirées à la mise à jour. */
private val obsoleteKeys: List<Preferences.Key<*>> = listOf(
intPreferencesKey("cutoff_interval_minutes"), stringSetPreferencesKey("whitelisted_packages"),
stringSetPreferencesKey("whitelisted_packages_wifi"), booleanPreferencesKey("wifi_filter_enabled"),
booleanPreferencesKey("show_system_apps"), longPreferencesKey("cutoff_anchor_millis"),
booleanPreferencesKey("cutoff_in_pulse"), longPreferencesKey("cutoff_pulse_start_millis"),
stringPreferencesKey("family_id"), stringPreferencesKey("device_id"),
longPreferencesKey("remote_config_version"), stringPreferencesKey("firebase_api_key"),
stringPreferencesKey("firebase_app_id"), stringPreferencesKey("firebase_project_id"),
stringPreferencesKey("traffic_day"), longPreferencesKey("traffic_mobile_acc"),
longPreferencesKey("traffic_wifi_acc"), longPreferencesKey("traffic_last_mobile"),
longPreferencesKey("traffic_last_other")
)
private companion object {
const val FREE_ATTEMPTS = 3
const val LOCK_BASE_MS = 30_000L
const val LOCK_MAX_MS = 15 * 60_000L
const val EVENT_QUEUE_MAX = 100
} }
val settingsFlow: Flow<AppSettings> = context.dataStore.data.map { prefs -> val settingsFlow: Flow<AppSettings> = context.dataStore.data.map { prefs ->
@@ -53,36 +96,50 @@ class SettingsRepository @Inject constructor(
overagePricePerMoEuro = prefs[Keys.OVERAGE_PRICE_PER_MO] ?: defaults.overagePricePerMoEuro, overagePricePerMoEuro = prefs[Keys.OVERAGE_PRICE_PER_MO] ?: defaults.overagePricePerMoEuro,
planStartDateEpochDay = prefs[Keys.PLAN_START_DATE_EPOCH_DAY] ?: defaults.planStartDateEpochDay, planStartDateEpochDay = prefs[Keys.PLAN_START_DATE_EPOCH_DAY] ?: defaults.planStartDateEpochDay,
autoResetEnabled = prefs[Keys.AUTO_RESET_ENABLED] ?: defaults.autoResetEnabled, autoResetEnabled = prefs[Keys.AUTO_RESET_ENABLED] ?: defaults.autoResetEnabled,
cutoffIntervalMinutes = prefs[Keys.CUTOFF_INTERVAL_MINUTES] ?: defaults.cutoffIntervalMinutes,
baselineMobileBytesAtReset = prefs[Keys.BASELINE_MOBILE_BYTES] ?: defaults.baselineMobileBytesAtReset, baselineMobileBytesAtReset = prefs[Keys.BASELINE_MOBILE_BYTES] ?: defaults.baselineMobileBytesAtReset,
whitelistedPackages = prefs[Keys.WHITELISTED_PACKAGES] ?: defaults.whitelistedPackages,
whitelistedPackagesWifi = prefs[Keys.WHITELISTED_PACKAGES_WIFI] ?: defaults.whitelistedPackagesWifi,
wifiFilterEnabled = prefs[Keys.WIFI_FILTER_ENABLED] ?: defaults.wifiFilterEnabled,
showSystemApps = prefs[Keys.SHOW_SYSTEM_APPS] ?: defaults.showSystemApps,
passwordHash = prefs[Keys.PASSWORD_HASH], passwordHash = prefs[Keys.PASSWORD_HASH],
passwordSalt = prefs[Keys.PASSWORD_SALT], passwordSalt = prefs[Keys.PASSWORD_SALT],
cutoffCycleAnchorMillis = prefs[Keys.CUTOFF_ANCHOR_MILLIS], rulesJson = prefs[Keys.RULES_JSON],
cutoffInPulse = prefs[Keys.CUTOFF_IN_PULSE] ?: defaults.cutoffInPulse, link = FamilyLink.fromJson(prefs[Keys.LINK_JSON]),
cutoffPulseStartMillis = prefs[Keys.CUTOFF_PULSE_START_MILLIS] ?: defaults.cutoffPulseStartMillis serverMode = prefs[Keys.SERVER_MODE] ?: defaults.serverMode,
serverModeReason = prefs[Keys.SERVER_MODE_REASON] ?: defaults.serverModeReason,
policyVersion = prefs[Keys.POLICY_VERSION] ?: defaults.policyVersion,
parentalLocked = prefs[Keys.PARENTAL_LOCKED] ?: defaults.parentalLocked,
blockInstalls = prefs[Keys.BLOCK_INSTALLS] ?: defaults.blockInstalls
) )
} }
/** Passage à la v0.2 : l'ancien appairage Firebase et le filtrage local ne servent plus. */
suspend fun migrateToV02() {
context.dataStore.edit { prefs ->
if (prefs[Keys.MIGRATED_V02] == true) return@edit
obsoleteKeys.forEach { prefs.remove(it) }
prefs.remove(Keys.RULES_JSON)
// L'ancien contrôle (pare-feu local) est remplacé par le tunnel : à réactiver après appairage.
prefs[Keys.CONTROL_ENABLED] = false
prefs[Keys.MIGRATED_V02] = true
}
}
suspend fun setControlEnabled(enabled: Boolean) { suspend fun setControlEnabled(enabled: Boolean) {
context.dataStore.edit { it[Keys.CONTROL_ENABLED] = enabled } context.dataStore.edit { it[Keys.CONTROL_ENABLED] = enabled }
} }
/** Verrouillage parental plein écran (commande « plock » du parent, levé par un code du serveur). */
suspend fun setParentalLocked(locked: Boolean) {
context.dataStore.edit { it[Keys.PARENTAL_LOCKED] = locked }
}
suspend fun updatePlan( suspend fun updatePlan(
planLimitMo: Long, planLimitMo: Long,
planPriceEuro: Double, planPriceEuro: Double,
overagePricePerMoEuro: Double, overagePricePerMoEuro: Double,
cutoffIntervalMinutes: Int,
autoResetEnabled: Boolean autoResetEnabled: Boolean
) { ) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
prefs[Keys.PLAN_LIMIT_MO] = planLimitMo prefs[Keys.PLAN_LIMIT_MO] = planLimitMo
prefs[Keys.PLAN_PRICE_EURO] = planPriceEuro prefs[Keys.PLAN_PRICE_EURO] = planPriceEuro
prefs[Keys.OVERAGE_PRICE_PER_MO] = overagePricePerMoEuro prefs[Keys.OVERAGE_PRICE_PER_MO] = overagePricePerMoEuro
prefs[Keys.CUTOFF_INTERVAL_MINUTES] = cutoffIntervalMinutes
prefs[Keys.AUTO_RESET_ENABLED] = autoResetEnabled prefs[Keys.AUTO_RESET_ENABLED] = autoResetEnabled
} }
} }
@@ -92,22 +149,6 @@ class SettingsRepository @Inject constructor(
context.dataStore.edit { it[Keys.PLAN_START_DATE_EPOCH_DAY] = date.toEpochDay() } context.dataStore.edit { it[Keys.PLAN_START_DATE_EPOCH_DAY] = date.toEpochDay() }
} }
suspend fun setWhitelistedPackages(packages: Set<String>) {
context.dataStore.edit { it[Keys.WHITELISTED_PACKAGES] = packages }
}
suspend fun setWhitelistedPackagesWifi(packages: Set<String>) {
context.dataStore.edit { it[Keys.WHITELISTED_PACKAGES_WIFI] = packages }
}
suspend fun setWifiFilterEnabled(enabled: Boolean) {
context.dataStore.edit { it[Keys.WIFI_FILTER_ENABLED] = enabled }
}
suspend fun setShowSystemApps(show: Boolean) {
context.dataStore.edit { it[Keys.SHOW_SYSTEM_APPS] = show }
}
/** Réinitialise le compteur de consommation : nouvelle date de début + nouvelle base de référence. */ /** Réinitialise le compteur de consommation : nouvelle date de début + nouvelle base de référence. */
suspend fun resetPlan(currentMobileBytes: Long, startDate: LocalDate = LocalDate.now()) { suspend fun resetPlan(currentMobileBytes: Long, startDate: LocalDate = LocalDate.now()) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
@@ -124,18 +165,15 @@ class SettingsRepository @Inject constructor(
/** /**
* Force la consommation affichée à [consumedMo] sans changer la date de début du cycle, en * Force la consommation affichée à [consumedMo] sans changer la date de début du cycle, en
* recalculant la base de référence à partir du compteur système actuel ([currentTotalBytes]). * recalculant la base de référence à partir du compteur système actuel ([currentTotalBytes]).
* Utile pour recaler l'app sur le décompte opérateur réel. * La base peut devenir négative : c'est volontaire (voir [UsageRepository.consumedBytesSince]).
*
* La base peut devenir négative (ex: valeur saisie supérieure au compteur TrafficStats depuis
* le dernier redémarrage de l'appareil) : c'est volontaire, ne pas la borner à 0 sous peine
* d'écraser silencieusement la valeur saisie par l'utilisateur. [UsageRepository.consumedBytesSince]
* et [UsageRepository.hasCounterReset] gèrent correctement une base négative.
*/ */
suspend fun setConsumedMo(consumedMo: Long, currentTotalBytes: Long) { suspend fun setConsumedMo(consumedMo: Long, currentTotalBytes: Long) {
val baseline = currentTotalBytes - UsageRepository.moToBytes(consumedMo) val baseline = currentTotalBytes - UsageRepository.moToBytes(consumedMo)
context.dataStore.edit { it[Keys.BASELINE_MOBILE_BYTES] = baseline } context.dataStore.edit { it[Keys.BASELINE_MOBILE_BYTES] = baseline }
} }
// --- Mot de passe ----------------------------------------------------------------------
/** Définit (ou supprime, si [password] est vide/null) le mot de passe de verrouillage. */ /** Définit (ou supprime, si [password] est vide/null) le mot de passe de verrouillage. */
suspend fun setPassword(password: String?) { suspend fun setPassword(password: String?) {
if (password.isNullOrBlank()) { if (password.isNullOrBlank()) {
@@ -145,10 +183,12 @@ 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)
} }
} }
} }
@@ -167,24 +207,178 @@ 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
val prefs = context.dataStore.data.first()
val hash = prefs[Keys.PASSWORD_HASH] ?: return true
val salt = prefs[Keys.PASSWORD_SALT] ?: return true
return PasswordHasher.hash(password, salt) == hash
}
/** /**
* Persiste l'état du cycle de coupure automatique, pour que le minuteur ne reparte pas de * Vérifie [password]. Renvoie null s'il est correct (ou si aucun mot de passe n'est défini),
* zéro si [DataMonitorService] est tué puis relancé par le système avant la fin de l'intervalle * sinon le message d'erreur à afficher. Après [FREE_ATTEMPTS] échecs, chaque nouvel essai
* (fréquent sur les ROM qui tuent agressivement les apps en arrière-plan sans exemption batterie). * impose une attente qui double (30 s, 1 min, 2 min… jusqu'à 15 min).
*/ */
suspend fun setCutoffCycleState(anchorMillis: Long?, inPulse: Boolean, pulseStartMillis: Long) { suspend fun checkPassword(password: String): String? {
context.dataStore.edit { prefs -> val prefs = context.dataStore.data.first()
if (anchorMillis == null) prefs.remove(Keys.CUTOFF_ANCHOR_MILLIS) else prefs[Keys.CUTOFF_ANCHOR_MILLIS] = anchorMillis val hash = prefs[Keys.PASSWORD_HASH] ?: return null
prefs[Keys.CUTOFF_IN_PULSE] = inPulse val salt = prefs[Keys.PASSWORD_SALT] ?: return null
prefs[Keys.CUTOFF_PULSE_START_MILLIS] = pulseStartMillis
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."
}
// --- Horloge de référence et temps d'écran --------------------------------------------------
/** 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
} }
} }
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
}
}
// --- Liaison familyd --------------------------------------------------------------------
/** Enregistre l'appairage. Le jeton et la clé privée sont chiffrés par l'Android Keystore. */
suspend fun savePairing(link: FamilyLink, deviceToken: String, wgPrivateKey: String) {
val token = SecretBox.encrypt(deviceToken)
val key = SecretBox.encrypt(wgPrivateKey)
context.dataStore.edit {
it[Keys.LINK_JSON] = link.toJson()
it[Keys.LINK_TOKEN_ENC] = token
it[Keys.WG_PRIVATE_ENC] = key
it[Keys.SERVER_MODE] = "open"
it[Keys.SERVER_MODE_REASON] = ""
it[Keys.POLICY_VERSION] = 0L
it.remove(Keys.RULES_JSON)
it.remove(Keys.EVENT_QUEUE)
}
}
suspend fun clearPairing() {
context.dataStore.edit {
listOf(Keys.LINK_JSON, Keys.LINK_TOKEN_ENC, Keys.WG_PRIVATE_ENC, Keys.SERVER_MODE,
Keys.SERVER_MODE_REASON, Keys.POLICY_VERSION, Keys.RULES_JSON, Keys.EVENT_QUEUE).forEach { k -> it.remove(k) }
it[Keys.CONTROL_ENABLED] = false
}
}
suspend fun deviceToken(): String? = SecretBox.decrypt(context.dataStore.data.first()[Keys.LINK_TOKEN_ENC])
suspend fun wgPrivateKey(): String? = SecretBox.decrypt(context.dataStore.data.first()[Keys.WG_PRIVATE_ENC])
/** Consigne reçue du serveur (mode réseau en cours et règles d'applications). */
suspend fun applyServerPolicy(version: Long, mode: String, reason: String, rulesJson: String?, blockInstalls: Boolean) {
context.dataStore.edit {
it[Keys.POLICY_VERSION] = version
it[Keys.SERVER_MODE] = mode
it[Keys.SERVER_MODE_REASON] = reason
it[Keys.BLOCK_INSTALLS] = blockInstalls
if (rulesJson.isNullOrBlank() || rulesJson == "{}") it.remove(Keys.RULES_JSON) else it[Keys.RULES_JSON] = rulesJson
}
}
// --- Appairage prévu par le QR de provisionnement -------------------------------------------
/** (serveur, code, nom) reçus du QR de provisionnement, en attente d'appairage. */
suspend fun pendingProvision(): Triple<String, String, String>? = runCatching {
val o = JSONObject(context.dataStore.data.first()[Keys.PENDING_PROVISION] ?: return null)
Triple(o.getString("url"), o.getString("code"), o.optString("name"))
}.getOrNull()
suspend fun setPendingProvision(url: String, code: String, name: String) {
context.dataStore.edit {
it[Keys.PENDING_PROVISION] = JSONObject().put("url", url).put("code", code).put("name", name).toString()
}
}
suspend fun clearPendingProvision() {
context.dataStore.edit { it.remove(Keys.PENDING_PROVISION) }
}
// --- File des évènements --------------------------------------------------------------------
suspend fun queueEvent(event: PendingEvent) {
context.dataStore.edit {
val list = parseQueue(it[Keys.EVENT_QUEUE]) + event
it[Keys.EVENT_QUEUE] = serializeQueue(list.takeLast(EVENT_QUEUE_MAX))
}
}
suspend fun pendingEvents(): List<PendingEvent> = parseQueue(context.dataStore.data.first()[Keys.EVENT_QUEUE])
/** Retire de la file les [count] premiers évènements (envoyés avec succès). */
suspend fun dropEvents(count: Int) {
context.dataStore.edit {
val rest = parseQueue(it[Keys.EVENT_QUEUE]).drop(count)
if (rest.isEmpty()) it.remove(Keys.EVENT_QUEUE) else it[Keys.EVENT_QUEUE] = serializeQueue(rest)
}
}
private fun parseQueue(json: String?): List<PendingEvent> = runCatching {
val a = JSONArray(json ?: return emptyList())
(0 until a.length()).map { a.getJSONObject(it) }
.map { PendingEvent(it.getString("type"), it.optString("detail"), it.getLong("at")) }
}.getOrDefault(emptyList())
private fun serializeQueue(list: List<PendingEvent>): String =
JSONArray(list.map { JSONObject().put("type", it.type).put("detail", it.detail).put("at", it.at) }).toString()
} }
@@ -0,0 +1,329 @@
package com.jttools.datacontrol.guard
import android.accessibilityservice.AccessibilityService
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.PixelFormat
import android.os.Build
import android.view.Gravity
import android.view.WindowManager
import android.widget.TextView
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.accessibility.AccessibilityNodeInfo
import android.view.inputmethod.InputMethodManager
import com.jttools.datacontrol.data.SettingsRepository
import com.jttools.datacontrol.log.AppLog
import com.jttools.datacontrol.remote.RemoteControl
import com.jttools.datacontrol.remote.RemoteSync
import kotlinx.coroutines.flow.map
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.suspendCancellableCoroutine
import kotlinx.coroutines.launch
import java.io.ByteArrayOutputStream
import javax.inject.Inject
import kotlin.coroutines.resume
/**
* 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
@Inject lateinit var remoteControl: RemoteControl
@Inject lateinit var remoteSync: RemoteSync
@Inject lateinit var settings: SettingsRepository
@Volatile private var parentalLocked = false
private var shareBanner: TextView? = null
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()
if (parentalLocked) enforceParentalLock() else currentPkg?.let { enforce(it) }
}
}
// Suit le verrouillage parental demandé par le parent (ramène l'écran de blocage si l'enfant le quitte).
scope.launch {
settings.settingsFlow.map { it.parentalLocked }.collect { locked ->
parentalLocked = locked
if (locked) enforceParentalLock()
}
}
startScreenShareLoop()
}
/**
* Partage d'écran demandé par le parent : capture l'écran (API 30+) et l'envoie au serveur, tant que
* [RemoteControl.screenShareUntil] n'est pas atteint. Un bandeau « Écran partagé » reste affiché.
*/
private fun startScreenShareLoop() = scope.launch {
while (isActive) {
if (!remoteControl.screenSharing) {
hideBanner()
delay(1_000)
continue
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
AppLog.w("Écran", "Partage d'écran indisponible (Android 11 minimum)")
remoteSync.pushEvent("screen_unsupported", "Partage d'écran impossible : Android 11 minimum")
delay(5_000)
continue
}
showBanner()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val jpeg = captureJpeg()
if (jpeg != null) remoteSync.pushScreen(jpeg)
}
delay(SCREEN_INTERVAL_MS)
}
}
@androidx.annotation.RequiresApi(Build.VERSION_CODES.R)
private suspend fun captureJpeg(): ByteArray? {
val bitmap = suspendCancellableCoroutine<Bitmap?> { cont ->
runCatching {
takeScreenshot(android.view.Display.DEFAULT_DISPLAY, mainExecutor,
object : TakeScreenshotCallback {
override fun onSuccess(result: ScreenshotResult) {
val bmp = runCatching {
Bitmap.wrapHardwareBuffer(result.hardwareBuffer, result.colorSpace)
?.copy(Bitmap.Config.ARGB_8888, false)
}.getOrNull()
result.hardwareBuffer.close()
if (cont.isActive) cont.resume(bmp)
}
override fun onFailure(errorCode: Int) {
if (cont.isActive) cont.resume(null)
}
})
}.onFailure { if (cont.isActive) cont.resume(null) }
} ?: return null
return runCatching {
val scaled = scaleDown(bitmap, MAX_WIDTH)
if (scaled !== bitmap) bitmap.recycle()
ByteArrayOutputStream().use { out ->
scaled.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out)
scaled.recycle()
out.toByteArray()
}
}.getOrNull()
}
private fun scaleDown(bmp: Bitmap, maxWidth: Int): Bitmap {
if (bmp.width <= maxWidth) return bmp
val h = bmp.height * maxWidth / bmp.width
return Bitmap.createScaledBitmap(bmp, maxWidth, h, true)
}
private fun showBanner() {
if (shareBanner != null) return
val wm = getSystemService(WindowManager::class.java)
val tv = TextView(this).apply {
text = getString(com.jttools.datacontrol.R.string.screen_shared_banner)
setBackgroundColor(Color.parseColor("#C62828"))
setTextColor(Color.WHITE)
setPadding(24, 12, 24, 12)
textSize = 13f
}
val lp = WindowManager.LayoutParams(
WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE,
PixelFormat.TRANSLUCENT
).apply { gravity = Gravity.TOP }
runCatching { wm.addView(tv, lp); shareBanner = tv }
}
private fun hideBanner() {
shareBanner?.let { runCatching { getSystemService(WindowManager::class.java).removeView(it) } }
shareBanner = null
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (event?.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) return
val pkg = event.packageName?.toString() ?: return
// Auto-validation du consentement au partage d'écran (dialogue système), en mode propriétaire.
if (remoteControl.awaitingScreenConsent && confirmProjectionConsent()) {
remoteControl.awaitingScreenConsent = false
return
}
// Verrouillage parental : toute tentative de passer à une autre application ramène l'écran de blocage.
if (parentalLocked && pkg != packageName) {
enforceParentalLock()
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) }
}
/** Ramène l'écran de verrouillage parental au premier plan tant que le parent n'a pas levé le verrou. */
private fun enforceParentalLock() {
runCatching { startActivity(ParentalLockActivity.intent(this)) }
}
/** Clique « Démarrer maintenant » sur le dialogue système de partage d'écran (MediaProjection). */
private fun confirmProjectionConsent(): Boolean {
val root = rootInActiveWindow ?: return false
val btn = findClickableByText(root, CONSENT_WORDS)
return btn != null && btn.performAction(AccessibilityNodeInfo.ACTION_CLICK)
}
private fun findClickableByText(node: AccessibilityNodeInfo?, re: Regex): AccessibilityNodeInfo? {
if (node == null) return null
val text = (node.text?.toString() ?: "") + " " + (node.contentDescription?.toString() ?: "")
if (node.isClickable && re.containsMatchIn(text)) return node
for (i in 0 until node.childCount) {
findClickableByText(node.getChild(i), re)?.let { return it }
}
return null
}
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
if (!snap.tunnelUp) return "NON FILTRÉ (tunnel coupé) [$net]" to false
return when (snap.serverMode) {
"cut" -> "BLOQUÉ (Internet coupé par le serveur) [$net]" to true
"allowlist" -> "LIMITÉ (liste blanche du serveur) [$net]" to true
"blocklist" -> "filtré (liste noire du serveur) [$net]" to false
else -> "filtré par le serveur (accès libre) [$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() {
hideBanner()
runCatching { unregisterReceiver(screenReceiver) }
scope.cancel()
super.onDestroy()
}
companion object {
private val CONSENT_WORDS = Regex("démarrer|commencer|autoriser|accepter|start now|\\ballow\\b", RegexOption.IGNORE_CASE)
private const val CHECK_INTERVAL_MS = 10_000L
private const val EXEMPT_TTL_MS = 5 * 60_000L
private const val SCREEN_INTERVAL_MS = 1_500L
private const val MAX_WIDTH = 720
private const val JPEG_QUALITY = 50
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,183 @@
package com.jttools.datacontrol.guard
import android.app.admin.DevicePolicyManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.WindowManager
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
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.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import com.jttools.datacontrol.R
import com.jttools.datacontrol.admin.AdminController
import com.jttools.datacontrol.data.SettingsRepository
import com.jttools.datacontrol.remote.RemoteSync
import com.jttools.datacontrol.ui.theme.DataControlTheme
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Écran de verrouillage parental plein écran : affiché sur commande du parent (« plock »), il reste
* au premier plan jusqu'à la saisie d'un code à usage unique généré par le serveur (action "unlock").
*
* Le code remplace le mot de passe local : seul le parent, en dictant le code, peut déverrouiller.
* En mode propriétaire de l'appareil, l'écran est épinglé (lock task) : accueil et récents neutralisés.
* Sinon, l'activité se remet d'elle-même au premier plan si l'enfant tente de la quitter.
*/
@AndroidEntryPoint
class ParentalLockActivity : ComponentActivity() {
@Inject lateinit var remoteSync: RemoteSync
@Inject lateinit var settings: SettingsRepository
private var unlocked = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
}
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
// Le bouton Retour ne ferme pas le verrou.
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() = Unit
})
setContent {
DataControlTheme {
var code by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
val scope = androidx.compose.runtime.rememberCoroutineScope()
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(
getString(R.string.parental_lock_title),
style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center
)
Spacer(Modifier.height(10.dp))
Text(
getString(R.string.parental_lock_message),
style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center
)
Spacer(Modifier.height(24.dp))
OutlinedTextField(
value = code,
onValueChange = { code = it.take(20); error = null },
label = { Text(getString(R.string.parental_lock_code_label)) },
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Characters),
isError = error != null,
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
error?.let {
Spacer(Modifier.height(6.dp))
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(20.dp))
Button(
enabled = !busy,
onClick = {
busy = true
scope.launch {
val result = remoteSync.verifyUnlock(code, "unlock")
if (result == null) {
settings.setParentalLocked(false) // l'observateur ferme l'écran
} else {
error = result
busy = false
}
}
},
modifier = Modifier.fillMaxWidth()
) { Text(getString(R.string.parental_lock_unlock)) }
}
}
}
}
// Le parent peut aussi déverrouiller à distance (« punlock ») : on ferme dès que l'état repasse à faux.
lifecycleScope.launch {
settings.settingsFlow.map { it.parentalLocked }.collect { locked ->
if (!locked) {
unlocked = true
stopPinning()
finish()
}
}
}
}
override fun onResume() {
super.onResume()
startPinning()
}
override fun onStop() {
super.onStop()
// Tant que le verrou tient, toute tentative de quitter (Accueil, bascule) ramène l'écran.
if (!unlocked && !isFinishing) {
startActivity(intent(this))
}
}
/** Épingle l'écran en mode propriétaire : l'accueil et les récents ne peuvent plus l'écarter. */
private fun startPinning() {
if (!AdminController.isDeviceOwner(this)) return
runCatching {
val dpm = getSystemService(DevicePolicyManager::class.java)
dpm.setLockTaskPackages(AdminController.component(this), arrayOf(packageName))
startLockTask()
}
}
private fun stopPinning() {
runCatching { stopLockTask() }
}
companion object {
fun intent(context: Context): Intent =
Intent(context, ParentalLockActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
}
@@ -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 } }
}
@@ -0,0 +1,193 @@
package com.jttools.datacontrol.remote
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.os.Build
import android.os.SystemClock
import androidx.core.app.NotificationCompat
import com.jttools.datacontrol.BuildConfig
import com.jttools.datacontrol.R
import com.jttools.datacontrol.data.UpdateManager
import com.jttools.datacontrol.log.AppLog
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
import javax.inject.Inject
import javax.inject.Singleton
/**
* Mise à jour de DataControl poussée depuis le serveur familyd (bouton « Déployer » de l'espace parents).
*
* L'APK est téléchargé dans le tunnel, son empreinte SHA-256 vérifiée, puis confié à l'installateur
* d'Android : installation silencieuse en mode propriétaire de l'appareil ; sinon, une notification
* « Touchez pour installer » (Android impose alors une confirmation). Android refuse de toute façon un
* APK qui ne porte pas la signature de l'application installée.
*/
@Singleton
class AppDeployer @Inject constructor(
@ApplicationContext private val context: Context
) {
private var lastVersion: String? = null
private var lastAttemptAt = 0L
/** [order] : {"version", "url", "sha256", "size"} reçu dans la consigne du serveur. */
suspend fun handle(order: JSONObject, apiBase: String, report: suspend (String, String) -> Unit) =
withContext(Dispatchers.IO) {
val version = order.optString("version")
if (version.isBlank()) return@withContext
// Déploiement forcé (option du parent) : réinstalle même version comprise, en ignorant les garde-fous.
val force = order.optBoolean("force", false)
if (!force && !UpdateManager.isNewer(version, BuildConfig.VERSION_NAME)) return@withContext
val now = SystemClock.elapsedRealtime()
if (!force && version == lastVersion && now - lastAttemptAt < RETRY_MS) return@withContext
lastVersion = version
lastAttemptAt = now
AppLog.i("Mise à jour", "Déploiement de DataControl $version demandé par le serveur" + if (force) " (forcé)" else "")
try {
val file = download(apiBase + order.getString("url"), order.getString("sha256").lowercase(), version)
install(file, version)
} catch (e: Exception) {
AppLog.e("Mise à jour", "Déploiement de $version impossible", e)
report("update_failed", "DataControl $version : ${e.message ?: e.javaClass.simpleName}")
}
}
private fun download(url: String, sha256: String, version: String): File {
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
dir.listFiles()?.forEach { it.delete() }
val file = File(dir, "DataControl-$version.apk")
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 60_000
}
val digest = MessageDigest.getInstance("SHA-256")
try {
if (conn.responseCode != HttpURLConnection.HTTP_OK) error("téléchargement refusé (HTTP ${conn.responseCode})")
var total = 0L
conn.inputStream.use { input ->
file.outputStream().use { out ->
val buf = ByteArray(64 * 1024)
while (true) {
val n = input.read(buf)
if (n < 0) break
total += n
if (total > MAX_SIZE) error("APK trop volumineux")
digest.update(buf, 0, n)
out.write(buf, 0, n)
}
}
}
} finally {
conn.disconnect()
}
val got = digest.digest().joinToString("") { "%02x".format(it) }
if (got != sha256) {
file.delete()
error("empreinte SHA-256 différente de celle annoncée par le serveur")
}
return file
}
private fun install(file: File, version: String) {
val installer = context.packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
setAppPackageName(context.packageName)
setSize(file.length())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
}
}
val id = installer.createSession(params)
installer.openSession(id).use { session ->
session.openWrite("base.apk", 0, file.length()).use { out ->
file.inputStream().use { it.copyTo(out) }
session.fsync(out)
}
val result = Intent(context, InstallResultReceiver::class.java).putExtra(EXTRA_VERSION, version)
val flags = PendingIntent.FLAG_UPDATE_CURRENT or
(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0)
session.commit(PendingIntent.getBroadcast(context, id, result, flags).intentSender)
}
AppLog.i("Mise à jour", "DataControl $version confié à l'installateur d'Android")
}
companion object {
const val EXTRA_VERSION = "version"
private const val RETRY_MS = 30 * 60_000L
private const val MAX_SIZE = 100L * 1024 * 1024
}
}
/** Résultat de l'installation : confirmation à demander à l'utilisateur, succès ou échec (signalé au parent). */
class InstallResultReceiver : BroadcastReceiver() {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface Deps {
fun remoteSync(): RemoteSync
}
override fun onReceive(context: Context, intent: Intent) {
val version = intent.getStringExtra(AppDeployer.EXTRA_VERSION).orEmpty()
when (val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
val confirm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
} else {
@Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_INTENT)
} ?: return
AppLog.i("Mise à jour", "DataControl $version : confirmation de l'utilisateur demandée")
notifyConfirm(context, confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), version)
}
PackageInstaller.STATUS_SUCCESS -> AppLog.i("Mise à jour", "DataControl $version installé")
else -> {
val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) ?: "code $status"
AppLog.e("Mise à jour", "Installation de DataControl $version refusée : $msg")
val pending = goAsync()
val remote = EntryPointAccessors.fromApplication(context.applicationContext, Deps::class.java).remoteSync()
CoroutineScope(Dispatchers.IO).launch {
try {
remote.pushEvent("update_failed", "DataControl $version : $msg")
} finally {
pending.finish()
}
}
}
}
}
private fun notifyConfirm(context: Context, confirm: Intent, version: String) {
val nm = context.getSystemService(NotificationManager::class.java)
nm.createNotificationChannel(NotificationChannel(CHANNEL, "Mises à jour", NotificationManager.IMPORTANCE_HIGH))
val pi = PendingIntent.getActivity(context, 0, confirm, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
nm.notify(NOTIF_ID, NotificationCompat.Builder(context, CHANNEL)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("Mise à jour de DataControl $version")
.setContentText("Touchez pour installer la version envoyée par le parent.")
.setContentIntent(pi)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build())
}
private companion object {
const val CHANNEL = "datacontrol_updates"
const val NOTIF_ID = 3101
}
}
@@ -0,0 +1,204 @@
package com.jttools.datacontrol.remote
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.BatteryManager
import android.os.Build
import android.os.Looper
import android.provider.Settings
import androidx.core.content.ContextCompat
import com.jttools.datacontrol.BuildConfig
import com.jttools.datacontrol.admin.AdminController
import com.jttools.datacontrol.data.SettingsRepository
import com.jttools.datacontrol.guard.AppGuardService
import com.jttools.datacontrol.guard.GuardEngine
import com.jttools.datacontrol.service.MonitorStateHolder
import com.jttools.datacontrol.service.NetworkTransport
import com.jttools.datacontrol.vpn.TunnelManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.json.JSONObject
import javax.inject.Inject
import javax.inject.Singleton
/**
* Envoie au serveur familyd l'état du téléphone : batterie, position, réseau, protections actives,
* temps d'écran, applications installées. Le volume de données n'est plus envoyé : le serveur le
* mesure lui-même sur le tunnel. Ne fait rien tant que l'appareil n'est pas associé.
*/
@Singleton
class DeviceReporter @Inject constructor(
@ApplicationContext private val context: Context,
private val remote: RemoteSync,
private val settingsRepository: SettingsRepository,
private val stateHolder: MonitorStateHolder,
private val guardEngine: GuardEngine,
private val tunnel: TunnelManager
) {
private var started = false
private var battery: JSONObject? = null
private var location: JSONObject? = null
private var lastSignature = ""
private var lastSentAt = 0L
private var lastLocationAt = 0L
private var locationRegistered = false
private var lastGuardEnabled: Boolean? = null
private var lastUsageSentTotal = -1L
private var appsPushJob: Job? = null
private var appsSent = false
fun start(scope: CoroutineScope) {
if (started) return
started = true
remote.start(scope)
val batteryReceiver = object : BroadcastReceiver() {
override fun onReceive(c: Context?, intent: Intent?) {
intent ?: return
val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, 100)
if (level < 0 || scale <= 0) return
val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
val charging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL
battery = JSONObject().put("level", level * 100 / scale).put("charging", charging)
}
}
// Récepteur enregistré dynamiquement : ACTION_BATTERY_CHANGED n'est pas livrable via le manifeste.
ContextCompat.registerReceiver(
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
)
// Changement de réseau : envoyé tout de suite (le forfait et la coupure cyclique en dépendent).
scope.launch {
stateHolder.snapshot.map { it.transport }.distinctUntilChanged().collect {
if (settingsRepository.settingsFlow.first().isPaired) pushState(force = true)
}
}
scope.launch {
while (isActive) {
val s = settingsRepository.settingsFlow.first()
if (s.isPaired && tunnel.up.value) {
registerLocationUpdates()
pushState(force = false)
pushUsage()
if (!appsSent) appsSent = remote.pushApps()
}
delay(REPORT_INTERVAL_MS)
}
}
}
private suspend fun pushState(force: Boolean) {
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 = JSONObject()
.put("transport", when (stateHolder.snapshot.value.transport) {
NetworkTransport.CELLULAR -> "mobile"
NetworkTransport.WIFI -> "wifi"
else -> "none"
})
.put("app_version", BuildConfig.VERSION_NAME)
.put("android", Build.VERSION.RELEASE)
.put("guard_enabled", guardEnabled)
.put("admin_active", AdminController.isActive(context))
.put("device_owner", AdminController.isDeviceOwner(context))
.put("always_on_vpn", com.jttools.datacontrol.admin.OwnerPolicy.alwaysOnState(context).let { (on, lock) ->
JSONObject().put("enabled", on).put("lockdown", lock)
})
.put("auto_time", Settings.Global.getInt(context.contentResolver, Settings.Global.AUTO_TIME, 1) == 1)
battery?.let { state.put("battery", it) }
location?.let { state.put("location", it) }
val signature = state.toString()
val now = System.currentTimeMillis()
// Évite les envois inutiles ; un battement toutes les 5 min suffit quand rien ne change.
if (!force && signature == lastSignature && now - lastSentAt < HEARTBEAT_MS) return
if (remote.pushState(state)) {
lastSignature = signature
lastSentAt = now
}
}
private suspend fun pushUsage() {
val u = guardEngine.usage()
if (u.totalMs == lastUsageSentTotal) return
if (remote.pushUsage(u.date, u.totalMs, u.appMs)) lastUsageSentTotal = u.totalMs
}
@SuppressLint("MissingPermission")
private fun registerLocationUpdates() {
if (locationRegistered) return
val fine = ContextCompat.checkSelfPermission(context, android.Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
val coarse = ContextCompat.checkSelfPermission(context, android.Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (!fine && !coarse) return
val lm = context.getSystemService(LocationManager::class.java)
val listener = LocationListener { loc -> onLocation(loc) }
var registered = false
for (provider in listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)) {
if (provider == LocationManager.GPS_PROVIDER && !fine) continue
if (!lm.isProviderEnabled(provider)) continue
runCatching {
lm.requestLocationUpdates(provider, LOCATION_INTERVAL_MS, LOCATION_MIN_DISTANCE_M, listener, Looper.getMainLooper())
registered = true
}
}
locationRegistered = registered
}
private fun onLocation(loc: Location) {
val now = System.currentTimeMillis()
if (now - lastLocationAt < LOCATION_MIN_SEND_MS) return
lastLocationAt = now
location = JSONObject().put("lat", loc.latitude).put("lng", loc.longitude)
.put("accuracy", loc.accuracy.toDouble()).put("at", now)
}
private companion object {
const val REPORT_INTERVAL_MS = 60_000L
const val HEARTBEAT_MS = 5 * 60_000L
const val LOCATION_INTERVAL_MS = 5 * 60_000L
const val LOCATION_MIN_DISTANCE_M = 50f
const val LOCATION_MIN_SEND_MS = 60_000L
const val APPS_PUSH_DEBOUNCE_MS = 5_000L
}
}
@@ -0,0 +1,120 @@
package com.jttools.datacontrol.remote
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
/** Erreur renvoyée par le serveur ([code] HTTP) ou réseau ([code] = 0), avec un message lisible. */
class ApiException(val code: Int, message: String) : IOException(message)
/**
* Client de l'API familyd. Appels bloquants : à lancer sur Dispatchers.IO.
* Les routes des appareils passent par le tunnel ([FamilyLink.apiBase], http://10.78.0.1:8100,
* déjà chiffré par WireGuard) ; l'appairage passe par l'adresse publique en HTTPS.
*/
class FamilyApi(private val apiBase: String, private val token: String) {
fun policy(): JSONObject = JSONObject(request("GET", "$apiBase/api/device/policy", token, null))
fun serverTime(): Long = JSONObject(request("GET", "$apiBase/api/device/time", token, null)).getLong("time")
fun state(state: JSONObject) {
request("POST", "$apiBase/api/device/state", token, state)
}
fun usage(date: String, totalMs: Long, apps: Map<String, Long>) {
request("POST", "$apiBase/api/device/usage", token,
JSONObject().put("date", date).put("total_ms", totalMs).put("apps", JSONObject(apps as Map<*, *>)))
}
fun event(type: String, detail: String, at: Long) {
request("POST", "$apiBase/api/device/events", token,
JSONObject().put("type", type).put("detail", detail.take(500)).put("at", at))
}
fun apps(apps: JSONArray) {
request("POST", "$apiBase/api/device/apps", token, JSONObject().put("apps", apps))
}
/** Attente longue des commandes du parent (verrouillage, son, partage d'écran). */
fun commands(waitSeconds: Int): JSONArray =
JSONObject(request("GET", "$apiBase/api/device/commands?wait=$waitSeconds", token, null, waitSeconds * 1000 + 5000))
.optJSONArray("commands") ?: JSONArray()
/** Envoie une image de l'écran (JPEG) pendant un partage. */
fun screen(jpeg: ByteArray) {
request("POST", "$apiBase/api/device/screen", token, jpeg, "image/jpeg")
}
/**
* Vérifie auprès du serveur le code dicté par le parent (désactivation / déverrouillage).
* Renvoie sans erreur si le code est accepté ; lève [ApiException] (404) sinon.
*/
fun unlock(code: String, action: String) {
request("POST", "$apiBase/api/device/unlock", token,
JSONObject().put("code", code).put("action", action))
}
/** Signale au serveur qu'une commande a été exécutée (audit). */
fun commandAck(id: String, status: String, detail: String) {
request("POST", "$apiBase/api/device/command-ack", token,
JSONObject().put("id", id).put("status", status).put("detail", detail.take(300)))
}
companion object {
private const val CONNECT_TIMEOUT_MS = 10_000
private const val READ_TIMEOUT_MS = 15_000
/** Appairage (public, HTTPS) : renvoie la réponse brute, à lire avec [FamilyLink.fromPairResponse]. */
fun pair(serverUrl: String, code: String, publicKey: String, name: String, model: String, appVersion: String): String {
require(serverUrl.startsWith("https://")) { "L'adresse du serveur doit commencer par https://" }
val body = JSONObject().put("code", code).put("public_key", publicKey).put("name", name)
.put("model", model).put("app_version", appVersion)
return request("POST", "${serverUrl.trimEnd('/')}/api/pair", null, body)
}
fun request(method: String, url: String, token: String?, body: JSONObject?, readTimeoutMs: Int = READ_TIMEOUT_MS): String {
return request(method, url, token, body?.toString()?.toByteArray(Charsets.UTF_8),
"application/json; charset=utf-8", readTimeoutMs)
}
fun request(method: String, url: String, token: String?, body: ByteArray?, contentType: String,
readTimeoutMs: Int = READ_TIMEOUT_MS): String {
val conn = try {
(URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = method
connectTimeout = CONNECT_TIMEOUT_MS
readTimeout = readTimeoutMs
setRequestProperty("Accept", "application/json")
token?.let { setRequestProperty("Authorization", "Bearer $it") }
if (body != null) {
doOutput = true
setRequestProperty("Content-Type", contentType)
setFixedLengthStreamingMode(body.size)
}
}
} catch (e: IOException) {
throw ApiException(0, "Serveur injoignable : ${e.message}")
}
try {
if (body != null) conn.outputStream.use { it.write(body) }
val code = conn.responseCode
val text = (if (code in 200..299) conn.inputStream else conn.errorStream)
?.bufferedReader()?.use { it.readText() }.orEmpty()
if (code !in 200..299) {
val detail = runCatching { JSONObject(text).optString("detail") }.getOrNull()
throw ApiException(code, detail?.takeIf { it.isNotBlank() } ?: "Erreur du serveur ($code)")
}
return text
} catch (e: ApiException) {
throw e
} catch (e: IOException) {
throw ApiException(0, "Serveur injoignable : ${e.message}")
} finally {
conn.disconnect()
}
}
}
}
@@ -0,0 +1,113 @@
package com.jttools.datacontrol.remote
import org.json.JSONArray
import org.json.JSONObject
import java.net.URI
import java.net.URLDecoder
/**
* Liaison avec le serveur familyd, reçue à l'appairage (sans les secrets : jeton de l'appareil et clé
* privée WireGuard sont stockés à part, chiffrés par l'Android Keystore, voir SecretBox).
*/
data class FamilyLink(
val serverUrl: String,
val deviceId: String,
val name: String,
val address: String,
val dns: String,
val mtu: Int,
val serverPublicKey: String,
val endpoint: String,
val allowedIps: List<String>,
val keepalive: Int,
val apiBase: String
) {
/** Adresse du téléphone dans le tunnel, sans le /32. */
val tunnelIp: String get() = address.substringBefore('/')
fun toJson(): String = JSONObject()
.put("serverUrl", serverUrl).put("deviceId", deviceId).put("name", name)
.put("address", address).put("dns", dns).put("mtu", mtu)
.put("serverPublicKey", serverPublicKey).put("endpoint", endpoint)
.put("allowedIps", JSONArray(allowedIps)).put("keepalive", keepalive).put("apiBase", apiBase)
.toString()
/** Configuration WireGuard au format wg-quick (lue par la bibliothèque officielle). */
fun wgQuickConfig(privateKey: String): String = buildString {
appendLine("[Interface]")
appendLine("PrivateKey = $privateKey")
appendLine("Address = $address")
appendLine("DNS = $dns")
appendLine("MTU = $mtu")
appendLine()
appendLine("[Peer]")
appendLine("PublicKey = $serverPublicKey")
appendLine("Endpoint = $endpoint")
appendLine("AllowedIPs = ${allowedIps.joinToString(", ")}")
appendLine("PersistentKeepalive = $keepalive")
}
companion object {
fun fromJson(json: String?): FamilyLink? {
if (json.isNullOrBlank()) return null
return runCatching {
val o = JSONObject(json)
val ips = o.getJSONArray("allowedIps")
FamilyLink(
serverUrl = o.getString("serverUrl"), deviceId = o.getString("deviceId"),
name = o.optString("name"), address = o.getString("address"), dns = o.getString("dns"),
mtu = o.optInt("mtu", 1280), serverPublicKey = o.getString("serverPublicKey"),
endpoint = o.getString("endpoint"),
allowedIps = (0 until ips.length()).map { ips.getString(it) },
keepalive = o.optInt("keepalive", 25), apiBase = o.getString("apiBase")
)
}.getOrNull()
}
/** Réponse de POST /api/pair → liaison + jeton de l'appareil. */
fun fromPairResponse(serverUrl: String, json: String): Pair<FamilyLink, String> {
val o = JSONObject(json)
val wg = o.getJSONObject("wireguard")
val ips = wg.getJSONArray("allowed_ips")
val link = FamilyLink(
serverUrl = serverUrl, deviceId = o.getString("device_id"), name = o.optString("name"),
address = wg.getString("address"), dns = wg.getString("dns"), mtu = wg.optInt("mtu", 1280),
serverPublicKey = wg.getString("server_public_key"), endpoint = wg.getString("endpoint"),
allowedIps = (0 until ips.length()).map { ips.getString(it) },
keepalive = wg.optInt("persistent_keepalive", 25), apiBase = o.getString("api").trimEnd('/')
)
require(link.serverPublicKey.isNotBlank()) { "Le serveur n'a pas fourni sa clé WireGuard." }
return link to o.getString("token")
}
}
}
/**
* Ce que l'utilisateur saisit pour s'associer : le code seul (« ABCD 2345 »), ou le lien
* « familyd://pair?url=https://…&code=… » affiché par l'interface des parents.
*/
data class PairingInput(val serverUrl: String, val code: String) {
companion object {
const val DEFAULT_SERVER = "https://family.tuxtech.fr"
private val CODE = Regex("^[A-HJ-NP-Z2-9]{8}$") // même alphabet que le serveur (sans I, O, 0, 1)
fun parse(input: String, defaultServer: String = DEFAULT_SERVER): PairingInput? {
val text = input.trim()
if (text.startsWith("familyd://", ignoreCase = true)) {
val query = runCatching { URI(text).rawQuery }.getOrNull() ?: return null
val params = query.split('&').mapNotNull {
val (k, v) = it.split('=', limit = 2).takeIf { p -> p.size == 2 } ?: return@mapNotNull null
k to URLDecoder.decode(v, "UTF-8")
}.toMap()
val url = params["url"]?.trimEnd('/') ?: return null
val code = normalize(params["code"] ?: return null) ?: return null
if (!url.startsWith("https://")) return null
return PairingInput(url, code)
}
return normalize(text)?.let { PairingInput(defaultServer, it) }
}
private fun normalize(code: String): String? =
code.uppercase().replace(" ", "").replace("-", "").takeIf { CODE.matches(it) }
}
}
@@ -0,0 +1,175 @@
package com.jttools.datacontrol.remote
import android.app.admin.DevicePolicyManager
import android.content.Context
import android.media.AudioManager
import android.os.Build
import android.os.SystemClock
import com.jttools.datacontrol.admin.AdminController
import com.jttools.datacontrol.guard.AppGuardService
import com.jttools.datacontrol.data.SettingsRepository
import com.jttools.datacontrol.guard.ParentalLockActivity
import com.jttools.datacontrol.log.AppLog
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.json.JSONObject
import javax.inject.Inject
import javax.inject.Singleton
/**
* Actions à distance demandées par le parent (via le serveur familyd) : verrouiller / mettre en veille,
* couper ou rétablir le son, partager l'écran. La boucle interroge le serveur en attente longue ; le
* partage d'écran est réalisé par le service d'accessibilité ([com.jttools.datacontrol.guard.AppGuardService]),
* qui observe [screenShareUntil].
*
* Le partage d'écran est toujours visible du téléphone (notification + bandeau « Écran partagé avec le parent »).
*/
@Singleton
class RemoteControl @Inject constructor(
@ApplicationContext private val context: Context,
private val remote: RemoteSync,
private val settings: SettingsRepository,
private val ringer: Ringer
) {
/** Instant (temps monotone) jusqu'auquel l'écran est partagé ; 0 = pas de partage. */
private val _screenShareUntil = MutableStateFlow(0L)
val screenShareUntil: StateFlow<Long> = _screenShareUntil.asStateFlow()
val screenSharing: Boolean get() = _screenShareUntil.value > SystemClock.elapsedRealtime()
/** Vrai pendant qu'on attend le consentement MediaProjection (le dialogue système est à l'écran). */
@Volatile var awaitingScreenConsent = false
private var started = false
fun start(scope: CoroutineScope) {
if (started) return
started = true
scope.launch(Dispatchers.IO) {
while (isActive) {
if (!settings.settingsFlow.first().isPaired) {
delay(10_000)
continue
}
val commands = remote.pollCommands(WAIT_SECONDS)
if (commands == null) {
delay(5_000) // tunnel coupé / serveur injoignable : on réessaie posément
continue
}
for (i in 0 until commands.length()) {
val cmd = commands.getJSONObject(i)
val outcome = runCatching { handle(cmd) }
val (status, detail) = outcome.getOrNull()
?: ("failed" to (outcome.exceptionOrNull()?.message ?: "erreur"))
remote.ackCommand(cmd.optString("id"), status, detail)
}
}
}
// Fin automatique du partage d'écran à l'échéance.
scope.launch {
while (isActive) {
if (screenSharing) {
delay(1_000)
} else {
if (_screenShareUntil.value != 0L) _screenShareUntil.value = 0L
delay(2_000)
}
}
}
}
/** Renvoie (statut, détail) pour l'audit : "done" ou "failed". */
private suspend fun handle(cmd: JSONObject): Pair<String, String> = when (cmd.optString("type")) {
"lock" -> lock()
"plock" -> parentalLock(true)
"punlock" -> parentalLock(false)
"mute" -> setMuted(true)
"unmute" -> setMuted(false)
"screen_start" -> startScreenShare(cmd.optInt("seconds", 120).coerceIn(10, 300))
"screen_stop" -> {
_screenShareUntil.value = 0L
awaitingScreenConsent = false
ScreenCaptureService.stop(context)
AppLog.i("Commande", "Partage d'écran arrêté")
"done" to ""
}
"ring" -> ringer.start(cmd.optInt("seconds", 60))
"ring_stop" -> {
ringer.stop(silent = false)
"done" to ""
}
else -> "failed" to "commande inconnue"
}
/**
* Verrouillage parental plein écran : affiche [ParentalLockActivity], que seul un code du serveur
* (dicté par le parent) peut lever. [lock] = false lève le verrou à distance.
*/
private suspend fun parentalLock(lock: Boolean): Pair<String, String> {
settings.setParentalLocked(lock)
return if (lock) {
runCatching { context.startActivity(ParentalLockActivity.intent(context)) }
AppLog.w("Commande", "Verrouillage parental activé (code du serveur requis pour déverrouiller)")
"done" to "téléphone verrouillé"
} else {
AppLog.i("Commande", "Verrouillage parental levé par le parent")
"done" to "téléphone déverrouillé"
}
}
/** Démarre le partage d'écran : capture silencieuse par l'accessibilité (Android 11+) ou MediaProjection. */
private fun startScreenShare(seconds: Int): Pair<String, String> {
_screenShareUntil.value = SystemClock.elapsedRealtime() + seconds * 1000L
val silent = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && AppGuardService.isEnabled(context)
if (silent) {
awaitingScreenConsent = false
AppLog.i("Commande", "Partage d'écran démarré ($seconds s, accessibilité)")
} else if (!ScreenCaptureService.running) {
awaitingScreenConsent = true
runCatching { context.startActivity(ScreenConsentActivity.intent(context)) }
.onFailure { AppLog.w("Commande", "Lancement du partage MediaProjection impossible : ${it.message}") }
AppLog.i("Commande", "Partage d'écran démarré ($seconds s, MediaProjection)")
}
return "done" to "partage démarré"
}
private fun lock(): Pair<String, String> {
val dpm = context.getSystemService(DevicePolicyManager::class.java)
if (!AdminController.isActive(context)) {
AppLog.w("Commande", "Verrouillage impossible : DataControl n'est pas administrateur de l'appareil")
return "failed" to "DataControl n'est pas administrateur de l'appareil"
}
return runCatching { dpm.lockNow() }.fold(
onSuccess = { AppLog.i("Commande", "Téléphone verrouillé / mis en veille"); "done" to "" },
onFailure = { AppLog.w("Commande", "Verrouillage refusé : ${it.message}"); "failed" to (it.message ?: "refusé") }
)
}
private fun setMuted(muted: Boolean): Pair<String, String> {
val am = context.getSystemService(AudioManager::class.java)
val dir = if (muted) AudioManager.ADJUST_MUTE else AudioManager.ADJUST_UNMUTE
val streams = intArrayOf(
AudioManager.STREAM_MUSIC, AudioManager.STREAM_RING,
AudioManager.STREAM_NOTIFICATION, AudioManager.STREAM_SYSTEM
)
var any = false
for (s in streams) {
// Le son de la sonnerie peut exiger l'accès « Ne pas déranger » : on n'échoue pas pour autant.
if (runCatching { am.adjustStreamVolume(s, dir, 0) }.isSuccess) any = true
}
AppLog.i("Commande", if (muted) "Son coupé (${if (any) "ok" else "refusé"})" else "Son rétabli")
return if (any) "done" to "" else "failed" to "aucun flux audio modifiable (accès « Ne pas déranger » ?)"
}
private companion object {
const val WAIT_SECONDS = 25
}
}
@@ -0,0 +1,207 @@
package com.jttools.datacontrol.remote
import android.os.Build
import com.jttools.datacontrol.BuildConfig
import com.jttools.datacontrol.data.InstalledAppsRepository
import com.jttools.datacontrol.data.PendingEvent
import com.jttools.datacontrol.data.SettingsRepository
import com.jttools.datacontrol.log.AppLog
import com.jttools.datacontrol.vpn.TunnelManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.json.JSONArray
import org.json.JSONObject
import javax.inject.Inject
import javax.inject.Singleton
/**
* Liaison avec le serveur familyd : appairage, consignes (mode réseau, règles d'applications),
* remontées (état, temps d'écran, applications installées, évènements).
*
* Tout passe par le tunnel WireGuard, sauf l'appairage (HTTPS public). Les évènements sont d'abord
* enregistrés sur le téléphone, puis envoyés dès que le serveur répond : aucun ne se perd quand le
* tunnel est coupé.
*/
@Singleton
class RemoteSync @Inject constructor(
private val settings: SettingsRepository,
private val installedApps: InstalledAppsRepository,
private val tunnel: TunnelManager,
private val deployer: AppDeployer
) {
private val flushLock = Mutex()
private var started = false
private suspend fun api(): FamilyApi? {
val link = settings.settingsFlow.first().link ?: return null
val token = settings.deviceToken() ?: return null
return FamilyApi(link.apiBase, token)
}
/** Attend les commandes du parent (bloquant jusqu'à [waitSeconds] s). null si le tunnel est coupé ou en erreur. */
suspend fun pollCommands(waitSeconds: Int): JSONArray? = withContext(Dispatchers.IO) {
if (!tunnel.up.value) return@withContext null
val api = api() ?: return@withContext null
try { api.commands(waitSeconds) } catch (_: ApiException) { null }
}
/** Envoie une image de l'écran pendant un partage. */
suspend fun pushScreen(jpeg: ByteArray): Boolean = withContext(Dispatchers.IO) {
if (!tunnel.up.value) return@withContext false
val api = api() ?: return@withContext false
try { api.screen(jpeg); true } catch (_: ApiException) { false }
}
/**
* Vérifie auprès du serveur un code dicté par le parent. [action] vaut "deactivate" (couper la
* protection, se dissocier, sortir du mode propriétaire) ou "unlock" (lever le blocage parental).
* Renvoie null si le code est accepté, sinon le message d'erreur à afficher.
*/
suspend fun verifyUnlock(code: String, action: String): String? = withContext(Dispatchers.IO) {
val clean = code.trim()
if (clean.isBlank()) return@withContext "Entrez le code fourni par le parent."
val api = api() ?: return@withContext "Ce téléphone n'est pas associé à un serveur."
try {
api.unlock(clean, action)
null
} catch (e: ApiException) {
when (e.code) {
404 -> "Code incorrect, déjà utilisé ou expiré."
0 -> "Serveur injoignable : vérifiez que le téléphone est connecté."
else -> e.message ?: "Vérification impossible."
}
}
}
/** Accuse réception/exécution d'une commande (audit côté parent). */
suspend fun ackCommand(id: String, status: String, detail: String = "") = withContext(Dispatchers.IO) {
if (id.isBlank() || !tunnel.up.value) return@withContext
val api = api() ?: return@withContext
runCatching { api.commandAck(id, status, detail) }
}
/** Associe le téléphone au serveur : clés WireGuard générées ici, la clé privée ne quitte jamais l'appareil. */
suspend fun pair(input: PairingInput, deviceName: String): Result<FamilyLink> = withContext(Dispatchers.IO) {
runCatching {
val (privateKey, publicKey) = TunnelManager.newKeyPair()
val raw = FamilyApi.pair(
input.serverUrl, input.code, publicKey, deviceName.trim().ifBlank { Build.MODEL },
"${Build.MANUFACTURER} ${Build.MODEL}", BuildConfig.VERSION_NAME
)
val (link, token) = FamilyLink.fromPairResponse(input.serverUrl, raw)
settings.savePairing(link, token, privateKey)
AppLog.i("Liaison", "Associé à ${input.serverUrl} : appareil ${link.name} (${link.tunnelIp})")
link
}.onFailure { AppLog.w("Liaison", "Appairage refusé : ${it.message}") }
}
/** Oubli local de la liaison (le parent retire l'appareil côté serveur). */
suspend fun forget() {
pushEvent("unpaired", "Liaison supprimée depuis DataControl")
tunnel.down()
settings.clearPairing()
AppLog.w("Liaison", "Liaison avec le serveur supprimée sur le téléphone")
}
fun start(scope: CoroutineScope) {
if (started) return
started = true
scope.launch(Dispatchers.IO) {
while (isActive) {
runCatching { syncOnce() }
delay(SYNC_INTERVAL_MS)
}
}
}
/** Récupère la consigne en cours et vide la file des évènements. Faux si le serveur est injoignable. */
suspend fun syncOnce(): Boolean = withContext(Dispatchers.IO) {
if (!tunnel.up.value) return@withContext false
val api = api() ?: return@withContext false
val p = try {
api.policy()
} catch (e: ApiException) {
if (e.code == 401) AppLog.w("Liaison", "Le serveur ne reconnaît plus ce téléphone (dissocié par le parent ?)")
return@withContext false
}
val current = settings.settingsFlow.first()
val version = p.optLong("version")
val mode = p.optString("mode", "open")
val reason = p.optString("mode_reason")
val blockInstalls = p.optBoolean("block_installs", false)
if (version != current.policyVersion || mode != current.serverMode || reason != current.serverModeReason
|| blockInstalls != current.blockInstalls) {
val rules = p.optJSONObject("app_rules")?.takeIf { it.length() > 0 }?.toString()
settings.applyServerPolicy(version, mode, reason, rules, blockInstalls)
if (mode != current.serverMode) AppLog.i("Liaison", "Mode réseau : $mode${if (reason.isNotBlank()) " ($reason)" else ""}")
if (blockInstalls != current.blockInstalls) AppLog.i("Liaison", "Blocage des installations : $blockInstalls")
}
// Mise à jour de DataControl poussée par le parent.
p.optJSONObject("deploy")?.let { order ->
current.link?.let { link -> deployer.handle(order, link.apiBase) { t, d -> pushEvent(t, d) } }
}
flushEvents()
true
}
/** Enregistre l'évènement puis tente de l'envoyer (8 s au plus : appelable depuis un récepteur goAsync). */
suspend fun pushEvent(type: String, detail: String) {
if (!settings.settingsFlow.first().isPaired) return
settings.queueEvent(PendingEvent(type, detail, System.currentTimeMillis()))
withTimeoutOrNull(EVENT_TIMEOUT_MS) { flushEvents() }
}
private suspend fun flushEvents() = withContext(Dispatchers.IO) {
if (!tunnel.up.value) return@withContext
flushLock.withLock {
val api = api() ?: return@withLock
val pending = settings.pendingEvents()
var sent = 0
for (e in pending) {
try {
api.event(e.type, e.detail, e.at)
sent++
} catch (_: ApiException) {
break
}
}
if (sent > 0) settings.dropEvents(sent)
}
}
suspend fun pushState(state: JSONObject): Boolean = call { it.state(state) }
suspend fun pushUsage(date: String, totalMs: Long, apps: Map<String, Long>): Boolean = call { it.usage(date, totalMs, apps) }
/** Liste des applications installées, pour que le parent choisisse lesquelles limiter. */
suspend fun pushApps(): Boolean {
val list = installedApps.listInstalledApps(withIcons = false)
val arr = JSONArray(list.map { JSONObject().put("pkg", it.packageName).put("label", it.label.take(100)).put("system", it.isSystemApp) })
return call { it.apps(arr) }
}
private suspend fun call(block: (FamilyApi) -> Unit): Boolean = withContext(Dispatchers.IO) {
if (!tunnel.up.value) return@withContext false
val api = api() ?: return@withContext false
try {
block(api)
true
} catch (e: ApiException) {
AppLog.w("Liaison", "Envoi au serveur impossible : ${e.message}")
false
}
}
private companion object {
const val SYNC_INTERVAL_MS = 30_000L
const val EVENT_TIMEOUT_MS = 8_000L
}
}
@@ -0,0 +1,162 @@
package com.jttools.datacontrol.remote
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.MediaPlayer
import android.media.RingtoneManager
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import androidx.core.app.NotificationCompat
import com.jttools.datacontrol.R
import com.jttools.datacontrol.log.AppLog
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import java.util.Timer
import java.util.TimerTask
import javax.inject.Inject
import javax.inject.Singleton
/**
* « Faire sonner » demandé par le parent : joue une sonnerie d'alarme à plein volume, même si le
* téléphone est en silencieux (le flux d'alarme n'est pas concerné par le mode silencieux), vibre et
* affiche une notification « Appuyez pour arrêter ». S'arrête tout seul au bout de la durée demandée,
* à la demande du parent (commande ring_stop), ou quand l'enfant touche la notification.
*/
@Singleton
class Ringer @Inject constructor(
@ApplicationContext private val context: Context
) {
private var player: MediaPlayer? = null
private var previousVolume = -1
private var stopTimer: Timer? = null
@Synchronized
fun start(seconds: Int): Pair<String, String> {
stop(silent = true) // repart proprement si une sonnerie était déjà en cours
val am = context.getSystemService(AudioManager::class.java)
val uri = RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_ALARM)
?: RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_RINGTONE)
?: return "failed" to "aucune sonnerie disponible"
return try {
previousVolume = am.getStreamVolume(AudioManager.STREAM_ALARM)
am.setStreamVolume(AudioManager.STREAM_ALARM, am.getStreamMaxVolume(AudioManager.STREAM_ALARM), 0)
player = MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ALARM)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
)
setDataSource(context, uri)
isLooping = true
prepare()
start()
}
vibrate(true)
notify(context)
val secs = seconds.coerceIn(5, 300)
stopTimer = Timer().apply {
schedule(object : TimerTask() {
override fun run() = stop(silent = false)
}, secs * 1000L)
}
AppLog.i("Commande", "Sonnerie déclenchée ($secs s)")
"done" to "sonnerie déclenchée"
} catch (e: Exception) {
AppLog.e("Commande", "Sonnerie impossible", e)
stop(silent = true)
"failed" to (e.message ?: "sonnerie impossible")
}
}
@Synchronized
fun stop(silent: Boolean) {
stopTimer?.cancel(); stopTimer = null
runCatching { player?.stop() }
runCatching { player?.release() }
player = null
vibrate(false)
if (previousVolume >= 0) {
runCatching {
context.getSystemService(AudioManager::class.java)
.setStreamVolume(AudioManager.STREAM_ALARM, previousVolume, 0)
}
previousVolume = -1
}
context.getSystemService(NotificationManager::class.java).cancel(NOTIF_ID)
if (!silent) AppLog.i("Commande", "Sonnerie arrêtée")
}
private fun vibrate(on: Boolean) {
val vib = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
context.getSystemService(VibratorManager::class.java)?.defaultVibrator
} else {
@Suppress("DEPRECATION") context.getSystemService(Vibrator::class.java)
} ?: return
if (!on) { runCatching { vib.cancel() }; return }
runCatching {
val pattern = longArrayOf(0, 600, 400)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vib.vibrate(VibrationEffect.createWaveform(pattern, 0))
} else {
@Suppress("DEPRECATION") vib.vibrate(pattern, 0)
}
}
}
private fun notify(context: Context) {
val nm = context.getSystemService(NotificationManager::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
nm.createNotificationChannel(
NotificationChannel(CHANNEL, "Sonnerie", NotificationManager.IMPORTANCE_HIGH).apply {
setSound(null, null) // le son est joué par le lecteur (flux d'alarme)
enableVibration(false)
}
)
}
val stop = PendingIntent.getBroadcast(
context, 0, Intent(context, RingStopReceiver::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
nm.notify(NOTIF_ID, NotificationCompat.Builder(context, CHANNEL)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("DataControl sonne")
.setContentText("Demandé par le parent — touchez pour arrêter.")
.setPriority(NotificationCompat.PRIORITY_MAX)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setOngoing(true)
.setAutoCancel(true)
.setContentIntent(stop)
.setFullScreenIntent(stop, true)
.build())
}
companion object {
private const val CHANNEL = "datacontrol_ring"
private const val NOTIF_ID = 3102
}
}
/** Arrêt de la sonnerie depuis la notification (touche de l'enfant). */
class RingStopReceiver : BroadcastReceiver() {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface Deps {
fun ringer(): Ringer
}
override fun onReceive(context: Context, intent: Intent) {
EntryPointAccessors.fromApplication(context.applicationContext, Deps::class.java).ringer().stop(silent = false)
}
}
@@ -0,0 +1,193 @@
package com.jttools.datacontrol.remote
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.graphics.Bitmap
import android.graphics.PixelFormat
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.media.ImageReader
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.os.IBinder
import android.os.SystemClock
import android.util.DisplayMetrics
import android.view.WindowManager
import androidx.core.app.NotificationCompat
import com.jttools.datacontrol.R
import com.jttools.datacontrol.log.AppLog
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.launch
import java.io.ByteArrayOutputStream
import javax.inject.Inject
/**
* Partage d'écran par MediaProjection — fonctionne dès Android 5, contrairement à la capture par le
* service d'accessibilité ([com.jttools.datacontrol.guard.AppGuardService]) réservée à Android 11+.
*
* Démarré par [ScreenConsentActivity] après le consentement du système (ou son auto-validation par
* l'accessibilité en mode propriétaire). Capture l'écran tant que le parent partage
* ([RemoteControl.screenSharing]), redimensionne et envoie les images au serveur par le tunnel.
*/
@AndroidEntryPoint
class ScreenCaptureService : Service() {
@Inject lateinit var remoteSync: RemoteSync
@Inject lateinit var remoteControl: RemoteControl
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var projection: MediaProjection? = null
private var virtualDisplay: VirtualDisplay? = null
private var imageReader: ImageReader? = null
private var thread: HandlerThread? = null
private var lastSent = 0L
@Volatile private var busy = false
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) { stop(); return START_NOT_STICKY }
startForegroundNotif()
val code = intent?.getIntExtra(EXTRA_CODE, 0) ?: 0
val data: Intent? = intent?.let {
if (Build.VERSION.SDK_INT >= 33) it.getParcelableExtra(EXTRA_DATA, Intent::class.java)
else @Suppress("DEPRECATION") it.getParcelableExtra(EXTRA_DATA)
}
if (data == null || code == 0) { stop(); return START_NOT_STICKY }
running = true
val mpm = getSystemService(MediaProjectionManager::class.java)
projection = try {
mpm.getMediaProjection(code, data)
} catch (e: Exception) {
AppLog.e("Écran", "MediaProjection impossible", e); stop(); return START_NOT_STICKY
}
projection?.registerCallback(object : MediaProjection.Callback() {
override fun onStop() { stop() }
}, Handler(mainLooper))
startCapture()
scope.launch { // s'arrête dès que le parent cesse de partager
while (running) {
if (!remoteControl.screenSharing) { stop(); break }
delay(1000)
}
}
return START_NOT_STICKY
}
private fun startCapture() {
val metrics = DisplayMetrics()
@Suppress("DEPRECATION")
getSystemService(WindowManager::class.java).defaultDisplay.getRealMetrics(metrics)
val scale = if (metrics.widthPixels > MAX_WIDTH) MAX_WIDTH.toFloat() / metrics.widthPixels else 1f
val w = (metrics.widthPixels * scale).toInt().coerceAtLeast(1)
val h = (metrics.heightPixels * scale).toInt().coerceAtLeast(1)
thread = HandlerThread("screencap").apply { start() }
val handler = Handler(thread!!.looper)
val reader = ImageReader.newInstance(w, h, PixelFormat.RGBA_8888, 2)
imageReader = reader
virtualDisplay = projection?.createVirtualDisplay(
"datacontrol-screen", w, h, metrics.densityDpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, reader.surface, null, handler
)
reader.setOnImageAvailableListener({ r ->
val image = r.acquireLatestImage() ?: return@setOnImageAvailableListener
val now = SystemClock.elapsedRealtime()
if (busy || now - lastSent < CAPTURE_INTERVAL_MS) { image.close(); return@setOnImageAvailableListener }
lastSent = now
busy = true
try {
val plane = image.planes[0]
val pixelStride = plane.pixelStride
val rowPadding = plane.rowStride - pixelStride * w
val bmp = Bitmap.createBitmap(w + rowPadding / pixelStride, h, Bitmap.Config.ARGB_8888)
bmp.copyPixelsFromBuffer(plane.buffer)
image.close()
val frame = if (rowPadding == 0) bmp else Bitmap.createBitmap(bmp, 0, 0, w, h).also { bmp.recycle() }
scope.launch {
val jpeg = ByteArrayOutputStream().use { out ->
frame.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out); out.toByteArray()
}
frame.recycle()
runCatching { remoteSync.pushScreen(jpeg) }
busy = false
}
} catch (e: Exception) {
runCatching { image.close() }
busy = false
AppLog.w("Écran", "Capture impossible : ${e.message}")
}
}, handler)
AppLog.i("Écran", "Partage d'écran (MediaProjection) démarré — $w×$h")
}
private fun stop() {
running = false
runCatching { virtualDisplay?.release() }
runCatching { imageReader?.close() }
runCatching { projection?.stop() }
virtualDisplay = null; imageReader = null; projection = null
thread?.quitSafely(); thread = null
scope.cancel()
runCatching { stopForeground(STOP_FOREGROUND_REMOVE) }
stopSelf()
}
override fun onDestroy() { running = false; super.onDestroy() }
private fun startForegroundNotif() {
val nm = getSystemService(NotificationManager::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
nm.createNotificationChannel(
NotificationChannel(CHANNEL, "Partage d'écran", NotificationManager.IMPORTANCE_LOW)
)
}
val notif = NotificationCompat.Builder(this, CHANNEL)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("Écran partagé avec le parent")
.setContentText("DataControl partage l'écran de ce téléphone.")
.setOngoing(true)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
} else {
startForeground(NOTIF_ID, notif)
}
}
companion object {
@Volatile var running = false
private set
private const val CHANNEL = "screen_share"
private const val NOTIF_ID = 4242
private const val EXTRA_CODE = "code"
private const val EXTRA_DATA = "data"
private const val ACTION_STOP = "stop"
private const val MAX_WIDTH = 720
private const val JPEG_QUALITY = 50
private const val CAPTURE_INTERVAL_MS = 1200L
fun start(context: Context, resultCode: Int, data: Intent) {
val i = Intent(context, ScreenCaptureService::class.java)
.putExtra(EXTRA_CODE, resultCode).putExtra(EXTRA_DATA, data)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(i)
else context.startService(i)
}
fun stop(context: Context) {
if (!running) return
context.startService(Intent(context, ScreenCaptureService::class.java).setAction(ACTION_STOP))
}
}
}
@@ -0,0 +1,43 @@
package com.jttools.datacontrol.remote
import android.content.Context
import android.content.Intent
import android.media.projection.MediaProjectionManager
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import com.jttools.datacontrol.log.AppLog
/**
* Demande le consentement du système au partage d'écran (MediaProjection) puis lance
* [ScreenCaptureService]. Transparente et sans interface : en mode propriétaire, le service
* d'accessibilité peut valider seul le dialogue « Démarrer la capture » (sinon un appui est requis).
*/
class ScreenConsentActivity : ComponentActivity() {
private val launcher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { res ->
val data = res.data
if (res.resultCode == RESULT_OK && data != null) {
ScreenCaptureService.start(this, res.resultCode, data)
} else {
AppLog.w("Écran", "Partage d'écran refusé sur le téléphone")
}
finish()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val mpm = getSystemService(MediaProjectionManager::class.java)
try {
launcher.launch(mpm.createScreenCaptureIntent())
} catch (e: Exception) {
AppLog.e("Écran", "Demande de partage d'écran impossible", e)
finish()
}
}
companion object {
fun intent(context: Context): Intent =
Intent(context, ScreenConsentActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
@@ -23,7 +23,8 @@ class BootReceiver : BroadcastReceiver() {
} }
override fun onReceive(context: Context, intent: Intent?) { override fun onReceive(context: Context, intent: Intent?) {
if (intent?.action != Intent.ACTION_BOOT_COMPLETED) return // Redémarrage de l'appareil, ou DataControl vient d'être mis à jour (déploiement depuis le serveur).
if (intent?.action != Intent.ACTION_BOOT_COMPLETED && intent?.action != Intent.ACTION_MY_PACKAGE_REPLACED) return
val appContext = context.applicationContext val appContext = context.applicationContext
val entryPoint = EntryPointAccessors.fromApplication(appContext, BootReceiverEntryPoint::class.java) val entryPoint = EntryPointAccessors.fromApplication(appContext, BootReceiverEntryPoint::class.java)
val pendingResult = goAsync() val pendingResult = goAsync()
@@ -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
@@ -34,13 +35,36 @@ class ConnectivityObserver @Inject constructor(
else -> NetworkTransport.OTHER else -> NetworkTransport.OTHER
} }
// Réseaux PHYSIQUES connectés. Le tunnel VPN du pare-feu est lui-même un réseau : s'il était
// pris en compte, son apparition (transport VPN ou hérité) ferait croire à un changement de
// réseau, le pare-feu s'arrêterait aussitôt et le VPN ne resterait jamais monté.
val physical = mutableMapOf<Network, NetworkTransport>()
fun publish() {
val values = synchronized(physical) { physical.values.toList() }
// Android privilégie le Wi-Fi quand les deux sont connectés.
val result = (
when {
NetworkTransport.WIFI in values -> NetworkTransport.WIFI
NetworkTransport.CELLULAR in values -> NetworkTransport.CELLULAR
values.isNotEmpty() -> NetworkTransport.OTHER
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) {
trySend(transportOf(caps)) if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return // notre propre tunnel : ignoré
synchronized(physical) { physical[network] = transportOf(caps) }
AppLog.i("Réseau", "réseau physique ${transportOf(caps)} (caps mis à jour)")
publish()
} }
override fun onLost(network: Network) { override fun onLost(network: Network) {
trySend(NetworkTransport.NONE) val removed = synchronized(physical) { physical.remove(network) != null }
if (removed) publish()
} }
} }
@@ -48,7 +72,6 @@ class ConnectivityObserver @Inject constructor(
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build() .build()
trySend(transportOf(cm.getNetworkCapabilities(cm.activeNetwork)))
cm.registerNetworkCallback(request, callback) cm.registerNetworkCallback(request, callback)
awaitClose { cm.unregisterNetworkCallback(callback) } awaitClose { cm.unregisterNetworkCallback(callback) }
@@ -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
} }
} }
@@ -4,23 +4,30 @@ import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
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
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.jttools.datacontrol.MainActivity import com.jttools.datacontrol.MainActivity
import com.jttools.datacontrol.R import com.jttools.datacontrol.R
import com.jttools.datacontrol.admin.OwnerPolicy
import com.jttools.datacontrol.data.AppSettings 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.RemoteSync
import com.jttools.datacontrol.vpn.TunnelManager
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.LocalDate import java.time.LocalDate
@@ -28,15 +35,12 @@ import javax.inject.Inject
import kotlin.math.max import kotlin.math.max
/** /**
* Service de premier plan qui surveille en continu la consommation de données mobiles, * Service de premier plan : garde le tunnel WireGuard vers le serveur familyd monté tant que le
* pilote le pare-feu [FirewallVpnService] et met à jour la notification permanente. * contrôle est activé (le serveur décide de ce qui passe), suit la consommation de données mobiles
* et met à jour la notification permanente.
* *
* Règles appliquées (voir README) : * Si le tunnel tombe (VPN retiré, autre VPN, réseau), le contrôle reste ACTIVÉ : la notification
* - Sur Wi-Fi : aucun filtrage par défaut (le pare-feu est arrêté), sauf si le filtrage Wi-Fi * passe en rouge, le parent est prévenu, et le tunnel est remonté dès que possible.
* est explicitement activé (Liste blanche Wi-Fi).
* - Sur données mobiles : liste blanche active en permanence (seules les apps cochées ont accès),
* ET une coupure totale cyclique (même liste blanche) se déclenche automatiquement toutes les
* X minutes pendant une courte pulsation, que le forfait soit dépassé ou non.
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class DataMonitorService : LifecycleService() { class DataMonitorService : LifecycleService() {
@@ -45,59 +49,74 @@ class DataMonitorService : LifecycleService() {
@Inject lateinit var usageRepository: UsageRepository @Inject lateinit var usageRepository: UsageRepository
@Inject lateinit var connectivityObserver: ConnectivityObserver @Inject lateinit var connectivityObserver: ConnectivityObserver
@Inject lateinit var stateHolder: MonitorStateHolder @Inject lateinit var stateHolder: MonitorStateHolder
@Inject lateinit var deviceReporter: DeviceReporter
@Inject lateinit var remoteSync: RemoteSync
@Inject lateinit var guardEngine: GuardEngine
@Inject lateinit var tunnel: TunnelManager
@Inject lateinit var remoteControl: com.jttools.datacontrol.remote.RemoteControl
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 private var lastControlEnabled: Boolean? = null
private var lastTunnelAttempt = 0L
private var lastAppliedMode: VpnMode? = null private var tunnelJob: Job? = null
private var lastAppliedWhitelist: Set<String> = emptySet() private var lastDiagnostic = ""
/** Dernière poignée de main récente avec le serveur (temps monotone), pour le garde-fou du mode propriétaire. */
private var cutoffCycleAnchorMillis: Long? = null private var serverSeenAt = SystemClock.elapsedRealtime()
private var inPulse = false private var lockdownRelaxed = false
private var pulseStartMillis: Long = 0L private var lastProvisionAttempt = 0L
private var settingsLoaded = false private var provisionJob: Job? = null
private val vpnRevokedReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
vpnRevoked = true
lifecycleScope.launch { settingsRepository.setControlEnabled(false) }
}
}
private enum class VpnMode { FILTER, BLOCK_ALL, STOPPED }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId) super.onStartCommand(intent, flags, startId)
// START_STICKY : si le système tue ce service (mémoire faible), il le relance // START_STICKY : si le système tue ce service, il le relance dès que possible (Intent null).
// automatiquement (avec un Intent null) dès que les ressources redeviennent disponibles.
return START_STICKY return START_STICKY
} }
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
ContextCompat.registerReceiver(
this, vpnRevokedReceiver, IntentFilter(FirewallVpnService.ACTION_VPN_REVOKED),
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 {
// Ne recopier l'état persisté du minuteur de coupure qu'à la toute première valeur
// réelle lue en base — pas question d'écraser avec les valeurs par défaut si un
// tick() déclenché par une autre coroutine s'exécute avant cette première émission.
if (!settingsLoaded) {
cutoffCycleAnchorMillis = it.cutoffCycleAnchorMillis
inPulse = it.cutoffInPulse
pulseStartMillis = it.cutoffPulseStartMillis
settingsLoaded = true
}
latestSettings = it latestSettings = it
if (!it.controlEnabled) vpnRevoked = false if (lastControlEnabled != null && lastControlEnabled != it.controlEnabled && it.isPaired) {
launch {
remoteSync.pushEvent(
"control_changed",
if (it.controlEnabled) "Contrôle activé" else "Contrôle désactivé sur le téléphone"
)
}
}
lastControlEnabled = it.controlEnabled
tick() tick()
} }
} }
lifecycleScope.launch {
var wasUp = false
tunnel.up.collect { up ->
val s = latestSettings
if (wasUp && !up && s.controlEnabled && s.isPaired) {
// Retrait du VPN par l'utilisateur ou un autre VPN : le contrôle reste actif.
lastTunnelAttempt = SystemClock.elapsedRealtime()
remoteSync.pushEvent("vpn_revoked", "Tunnel coupé sur le téléphone : trafic non filtré tant qu'il n'est pas rétabli")
}
wasUp = up
tick()
}
}
guardEngine.start()
deviceReporter.start(lifecycleScope)
remoteControl.start(lifecycleScope)
// Verrouillage parental encore actif après un redémarrage : on le rétablit à l'ouverture.
lifecycleScope.launch {
if (settingsRepository.settingsFlow.first().parentalLocked) {
runCatching {
startActivity(com.jttools.datacontrol.guard.ParentalLockActivity.intent(this@DataMonitorService))
}
}
}
lifecycleScope.launch { lifecycleScope.launch {
connectivityObserver.observe().collect { connectivityObserver.observe().collect {
latestTransport = it latestTransport = it
@@ -107,7 +126,7 @@ class DataMonitorService : LifecycleService() {
lifecycleScope.launch { lifecycleScope.launch {
while (isActive) { while (isActive) {
tick() tick()
kotlinx.coroutines.delay(TICK_INTERVAL_MS) delay(TICK_INTERVAL_MS)
} }
} }
} }
@@ -115,9 +134,7 @@ class DataMonitorService : LifecycleService() {
private fun tick() { private fun tick() {
val settings = latestSettings val settings = latestSettings
// Le calcul de la consommation doit rester à jour même quand le contrôle est désactivé // Consommation de données mobiles (compteur local, pour le tableau de bord et le forfait affiché).
// (ex: pour refléter immédiatement une valeur définie manuellement dans Réglages), il ne
// doit donc pas dépendre de settings.controlEnabled.
val consumedBytes = usageRepository.consumedBytesSince(settings.baselineMobileBytesAtReset) val consumedBytes = usageRepository.consumedBytesSince(settings.baselineMobileBytesAtReset)
if (usageRepository.hasCounterReset(settings.baselineMobileBytesAtReset)) { if (usageRepository.hasCounterReset(settings.baselineMobileBytesAtReset)) {
lifecycleScope.launch { settingsRepository.rebaseline(usageRepository.totalMobileBytes()) } lifecycleScope.launch { settingsRepository.rebaseline(usageRepository.totalMobileBytes()) }
@@ -126,78 +143,37 @@ class DataMonitorService : LifecycleService() {
val overageMo = max(0L, consumedMo - settings.planLimitMo) val overageMo = max(0L, consumedMo - settings.planLimitMo)
val estimatedTotal = settings.planPriceEuro + overageMo * settings.overagePricePerMoEuro val estimatedTotal = settings.planPriceEuro + overageMo * settings.overagePricePerMoEuro
val percent = if (settings.planLimitMo > 0) consumedMo.toFloat() / settings.planLimitMo else 0f val percent = if (settings.planLimitMo > 0) consumedMo.toFloat() / settings.planLimitMo else 0f
if (settings.autoResetEnabled && !LocalDate.now().isBefore(settings.nextAutoResetDate())) {
if (!settings.controlEnabled) { lifecycleScope.launch {
applyVpnMode(VpnMode.STOPPED, emptySet()) settingsRepository.resetPlan(usageRepository.totalMobileBytes(), settings.nextAutoResetDate())
stateHolder.update {
it.copy(
consumedMo = consumedMo,
planLimitMo = settings.planLimitMo,
overageMo = overageMo,
estimatedTotalPriceEuro = estimatedTotal,
dataState = if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK,
nextAutoResetDate = settings.nextAutoResetDate(),
serviceRunning = false
)
} }
updateNotification(stateHolder.snapshot.value)
return
} }
maybeAutoReset(settings) if (!settings.isPaired) maybeProvisionPairing()
val dataState: DataState
var nextCutoffAt: Long? = null
// Tunnel : monté si contrôle activé + appareil associé ; sinon arrêté. En mode propriétaire, les
// verrous (dont l'autorisation VPN accordée par le système) passent avant la montée du tunnel.
val wanted = settings.controlEnabled && settings.isPaired
val up = tunnel.up.value
applyOwnerPolicy(wanted, up)
when { when {
latestTransport == NetworkTransport.WIFI -> { wanted && !up -> maybeBringUp(settings)
if (settings.wifiFilterEnabled && FirewallVpnService.isPrepared(this)) { !wanted && up && tunnelJob?.isActive != true -> {
applyVpnMode(VpnMode.FILTER, settings.whitelistedPackagesWifi) tunnelJob = lifecycleScope.launch { tunnel.down() }
} else {
applyVpnMode(VpnMode.STOPPED, emptySet())
}
resetCutoffCycle()
dataState = DataState.WIFI
}
latestTransport == NetworkTransport.CELLULAR && !vpnRevoked && FirewallVpnService.isPrepared(this) -> {
// 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.
val now = System.currentTimeMillis()
val intervalMs = settings.cutoffIntervalMinutes * 60_000L
if (cutoffCycleAnchorMillis == null) {
cutoffCycleAnchorMillis = now
persistCutoffState()
}
val anchor = cutoffCycleAnchorMillis!!
if (inPulse) {
if (now - pulseStartMillis >= PULSE_DURATION_MS) {
inPulse = false
cutoffCycleAnchorMillis = now
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
dataState = if (inPulse) DataState.CUT else if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK
}
else -> {
// Pas de réseau, transport inconnu, ou VPN non préparé/révoqué : rien à filtrer.
applyVpnMode(VpnMode.STOPPED, emptySet())
resetCutoffCycle()
dataState = if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK
} }
} }
val fault = wanted && !up
val dataState = when {
!settings.controlEnabled -> if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK
// « blocklist » laisse tout passer (sauf la liste noire DNS) : traité comme « open », pas comme coupé.
settings.serverMode == "allowlist" || settings.serverMode == "cut" -> DataState.CUT
latestTransport == NetworkTransport.WIFI -> DataState.WIFI
percent >= WARNING_THRESHOLD -> DataState.WARNING
else -> DataState.OK
}
logDiagnostic(settings, up)
stateHolder.update { stateHolder.update {
it.copy( it.copy(
consumedMo = consumedMo, consumedMo = consumedMo,
@@ -206,79 +182,143 @@ class DataMonitorService : LifecycleService() {
estimatedTotalPriceEuro = estimatedTotal, estimatedTotalPriceEuro = estimatedTotal,
dataState = dataState, dataState = dataState,
nextAutoResetDate = settings.nextAutoResetDate(), nextAutoResetDate = settings.nextAutoResetDate(),
nextCutoffAtMillis = nextCutoffAt, serviceRunning = settings.controlEnabled,
serviceRunning = true firewallFault = fault,
transport = latestTransport,
paired = settings.isPaired,
tunnelUp = up,
lastHandshakeMs = if (up) tunnel.lastHandshakeMillis() else null,
serverMode = settings.serverMode,
serverModeReason = settings.serverModeReason,
tunnelError = tunnel.lastError
) )
} }
updateNotification(stateHolder.snapshot.value) updateNotification(stateHolder.snapshot.value)
} }
private fun resetCutoffCycle() { /**
if (cutoffCycleAnchorMillis == null && !inPulse) return * Appairage prévu par le QR de provisionnement : tenté toutes les minutes jusqu'à réussite ; la
cutoffCycleAnchorMillis = null * protection s'active aussitôt. Un code refusé (expiré, déjà utilisé) est abandonné : appairage à la main.
inPulse = false */
persistCutoffState() private fun maybeProvisionPairing() {
} val now = SystemClock.elapsedRealtime()
if (provisionJob?.isActive == true || (lastProvisionAttempt != 0L && now - lastProvisionAttempt < 60_000L)) return
private fun persistCutoffState() { lastProvisionAttempt = now
val anchor = cutoffCycleAnchorMillis provisionJob = lifecycleScope.launch {
val pulse = inPulse val (url, code, name) = settingsRepository.pendingProvision() ?: return@launch
val pulseStart = pulseStartMillis val input = com.jttools.datacontrol.remote.PairingInput.parse("familyd://pair?url=$url&code=$code") ?: run {
lifecycleScope.launch { settingsRepository.setCutoffCycleState(anchor, pulse, pulseStart) } settingsRepository.clearPendingProvision()
} return@launch
private fun maybeAutoReset(settings: AppSettings) {
if (settings.autoResetEnabled && !LocalDate.now().isBefore(settings.nextAutoResetDate())) {
lifecycleScope.launch {
settingsRepository.resetPlan(usageRepository.totalMobileBytes(), settings.nextAutoResetDate())
} }
remoteSync.pair(input, name.ifBlank { android.os.Build.MODEL }).fold(
onSuccess = {
settingsRepository.clearPendingProvision()
settingsRepository.setControlEnabled(true)
AppLog.i("Propriétaire", "Associé automatiquement après provisionnement : protection activée")
},
onFailure = { e ->
if ((e as? com.jttools.datacontrol.remote.ApiException)?.code in setOf(404, 409, 422)) {
settingsRepository.clearPendingProvision()
AppLog.w("Propriétaire", "Code du QR refusé (${e.message}) : associer à la main")
}
}
)
} }
} }
private fun applyVpnMode(mode: VpnMode, whitelist: Set<String>) { /**
if (mode == lastAppliedMode && (mode != VpnMode.FILTER || whitelist == lastAppliedWhitelist)) return * Mode propriétaire : VPN toujours actif et réglages verrouillés tant que la protection est voulue.
lastAppliedMode = mode * Garde-fou : sans poignée de main avec le serveur depuis [SERVER_LOST_MS], le blocage hors VPN est
lastAppliedWhitelist = whitelist * levé (l'enfant garde Internet si le serveur tombe) ; il revient dès que le serveur répond.
when (mode) { */
VpnMode.STOPPED -> ContextCompat.startForegroundService(this, FirewallVpnService.stopIntent(this)) private fun applyOwnerPolicy(wanted: Boolean, up: Boolean) {
VpnMode.FILTER -> ContextCompat.startForegroundService( if (!OwnerPolicy.isDeviceOwner(this)) return
this, FirewallVpnService.startFilterIntent(this, whitelist) val now = SystemClock.elapsedRealtime()
) val hs = if (up) tunnel.lastHandshakeMillis() else null
VpnMode.BLOCK_ALL -> ContextCompat.startForegroundService( if (hs != null && System.currentTimeMillis() - hs < HANDSHAKE_FRESH_MS) serverSeenAt = now
this, FirewallVpnService.startBlockAllIntent(this) val serverLost = now - serverSeenAt > SERVER_LOST_MS
) if (wanted && serverLost != lockdownRelaxed) {
lockdownRelaxed = serverLost
lifecycleScope.launch {
if (serverLost) remoteSync.pushEvent("lockdown_relaxed", "Serveur injoignable depuis 15 min : blocage hors VPN levé")
else remoteSync.pushEvent("lockdown_restored", "Serveur de nouveau joignable : blocage hors VPN rétabli")
}
}
OwnerPolicy.sync(this, OwnerPolicy.Target(protect = wanted, lockdown = wanted && !serverLost,
blockInstalls = latestSettings.blockInstalls))
}
/** Monte le tunnel, au plus une tentative toutes les [TUNNEL_RETRY_MS] (évite de boucler contre un autre VPN). */
private fun maybeBringUp(settings: AppSettings) {
val link = settings.link ?: return
val now = SystemClock.elapsedRealtime()
if (tunnelJob?.isActive == true || (lastTunnelAttempt != 0L && now - lastTunnelAttempt < TUNNEL_RETRY_MS)) return
if (!TunnelManager.isPrepared(this)) return // autorisation VPN à redonner à l'ouverture de l'appli
lastTunnelAttempt = now
tunnelJob = lifecycleScope.launch {
val key = settingsRepository.wgPrivateKey()
if (key == null) {
AppLog.e("Tunnel", "Clé WireGuard illisible (Keystore réinitialisé ?) : associer de nouveau le téléphone")
return@launch
}
if (tunnel.ensureUp(link, key)) remoteSync.syncOnce()
tick()
}
}
/** Écrit dans le journal POURQUOI le tunnel est (ou n'est pas) monté, à chaque changement de situation. */
private fun logDiagnostic(settings: AppSettings, up: Boolean) {
val prepared = TunnelManager.isPrepared(this)
val expected = when {
!settings.isPaired -> "appareil non associé au serveur"
!settings.controlEnabled -> "contrôle désactivé"
!prepared -> "autorisation VPN absente"
up -> "tunnel monté, filtrage par le serveur (mode ${settings.serverMode})"
else -> "tunnel attendu mais non monté${tunnel.lastError?.let { " : $it" } ?: ""}"
}
val guard = com.jttools.datacontrol.guard.AppGuardService.isEnabled(this)
val diag = "réseau=$latestTransport contrôle=${settings.controlEnabled} associé=${settings.isPaired} " +
"vpnAutorisé=$prepared tunnel=$up accessibilité=$guard → $expected"
if (diag != lastDiagnostic) {
lastDiagnostic = diag
AppLog.i("Tunnel", diag)
} }
} }
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 modeLabel = when (snapshot.serverMode) {
"cut" -> getString(R.string.mode_cut)
"allowlist" -> getString(R.string.mode_allowlist)
"blocklist" -> getString(R.string.mode_blocklist)
else -> getString(R.string.mode_open)
}
val (emoji, stateLabel, color) = when { val (emoji, stateLabel, color) = when {
snapshot.firewallFault -> Triple("⚠️", getString(R.string.dashboard_tunnel_down), Color.parseColor("#C62828"))
!snapshot.serviceRunning -> Triple("⚪", getString(R.string.dashboard_control_disabled), Color.parseColor("#757575")) !snapshot.serviceRunning -> Triple("⚪", getString(R.string.dashboard_control_disabled), Color.parseColor("#757575"))
snapshot.dataState == DataState.OK -> Triple("🟢", getString(R.string.dashboard_state_ok), Color.parseColor("#2E7D32")) snapshot.dataState == DataState.CUT -> Triple("🔴", modeLabel, Color.parseColor("#C62828"))
snapshot.dataState == DataState.WARNING -> Triple("🟠", getString(R.string.dashboard_state_warning), Color.parseColor("#EF6C00")) snapshot.dataState == DataState.WARNING -> Triple("🟠", getString(R.string.dashboard_state_warning), Color.parseColor("#EF6C00"))
snapshot.dataState == DataState.CUT -> Triple("🔴", getString(R.string.dashboard_state_cut), Color.parseColor("#C62828")) snapshot.dataState == DataState.WIFI -> Triple("🔵", getString(R.string.dashboard_state_wifi), Color.parseColor("#1565C0"))
else -> Triple("🔵", getString(R.string.dashboard_state_wifi), Color.parseColor("#1565C0")) else -> Triple("🟢", getString(R.string.dashboard_state_ok), Color.parseColor("#2E7D32"))
} }
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 usageText = "$emoji $stateLabel · ${snapshot.consumedMo}/${snapshot.planLimitMo} Mo ($percentText)" val usageText = "$emoji $stateLabel · ${snapshot.consumedMo}/${snapshot.planLimitMo} Mo ($percentText)"
val costText = "${getString(R.string.dashboard_cost_total)} : $priceText €" val costText = "${getString(R.string.dashboard_cost_total)} : $priceText €"
// Le total est aussi dans la vue repliée : sinon il n'est visible qu'une fois la notification dépliée.
val contentText = "$usageText · $priceText €" val contentText = "$usageText · $priceText €"
val bigText = "$usageText\n$costText" val reason = snapshot.serverModeReason.takeIf { it.isNotBlank() && snapshot.serverMode != "open" }
val bigText = listOfNotNull(usageText, reason?.let { "Raison : $it" }, costText).joinToString("\n")
val openApp = PendingIntent.getActivity( val openApp = PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE
@@ -303,20 +343,26 @@ class DataMonitorService : LifecycleService() {
nm.notify(NOTIF_ID, buildNotification(snapshot)) nm.notify(NOTIF_ID, buildNotification(snapshot))
} }
override fun onDestroy() {
runCatching { unregisterReceiver(vpnRevokedReceiver) }
super.onDestroy()
}
companion object { companion object {
private const val CHANNEL_ID = "datacontrol_monitor" private const val CHANNEL_ID = "datacontrol_monitor"
private const val NOTIF_ID = 1001 private const val NOTIF_ID = 1001
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 TUNNEL_RETRY_MS = 30_000L
private const val WARNING_THRESHOLD = 0.8f private const val WARNING_THRESHOLD = 0.8f
/** WireGuard renouvelle la poignée de main toutes les 2 min tant que du trafic passe (keepalive compris). */
private const val HANDSHAKE_FRESH_MS = 3 * 60_000L
private const val SERVER_LOST_MS = 15 * 60_000L
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) {
@@ -1,175 +0,0 @@
package com.jttools.datacontrol.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.VpnService
import android.os.Build
import android.os.ParcelFileDescriptor
import android.util.Log
import androidx.core.app.NotificationCompat
import com.jttools.datacontrol.MainActivity
import com.jttools.datacontrol.R
import java.io.FileInputStream
/**
* Pare-feu local basé sur [VpnService], sans root.
*
* Ce service ne route ni ne transmet aucun paquet : les applications "autorisées" sont
* exclues du tunnel via [Builder.addDisallowedApplication] (leur trafic continue de
* transiter normalement par la vraie interface réseau). Toutes les autres applications
* voient leur trafic capturé dans le tunnel VPN puis silencieusement ignoré ("trou noir"),
* ce qui coupe leur accès Internet sans jamais toucher au Wi-Fi : ce service n'est démarré
* par [DataMonitorService] que lorsque le réseau actif est la donnée mobile.
*/
class FirewallVpnService : VpnService() {
private var tunInterface: ParcelFileDescriptor? = null
private var readerThread: Thread? = null
@Volatile private var running = false
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Le service est toujours lancé via startForegroundService() (y compris pour ACTION_STOP,
// cf. DataMonitorService.applyVpnMode) : le système impose un appel à startForeground()
// 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.
startForeground(NOTIF_ID, buildNotification())
when (intent?.action) {
ACTION_STOP -> {
stopTunnel()
stopSelf()
return START_NOT_STICKY
}
ACTION_START_FILTER, ACTION_START_BLOCK_ALL -> {
val allowed = intent.getStringArrayListExtra(EXTRA_ALLOWED_PACKAGES)?.toSet() ?: emptySet()
establishTunnel(allowed)
}
}
return START_STICKY
}
private fun establishTunnel(allowedPackages: Set<String>) {
stopTunnel(keepForeground = true)
val builder = Builder()
.setSession("DataControl")
.addAddress(TUNNEL_ADDRESS, 32)
.addRoute("0.0.0.0", 0)
.setBlocking(true)
// Notre propre app doit toujours pouvoir communiquer normalement.
addDisallowed(builder, packageName)
allowedPackages.forEach { addDisallowed(builder, it) }
tunInterface = runCatching { builder.establish() }.getOrElse {
Log.e(TAG, "Échec établissement du tunnel VPN", it)
null
}
val fd = tunInterface ?: return
running = true
readerThread = Thread({ blackhole(fd) }, "datacontrol-vpn-blackhole").apply {
isDaemon = true
start()
}
}
private fun addDisallowed(builder: Builder, packageName: String) {
try {
builder.addDisallowedApplication(packageName)
} catch (e: PackageManager.NameNotFoundException) {
// Application désinstallée entre-temps : rien à exclure.
}
}
/** Lit et jette tout paquet appartenant aux applications non-autorisées : leur trafic n'aboutit jamais. */
private fun blackhole(fd: ParcelFileDescriptor) {
val input = FileInputStream(fd.fileDescriptor)
val buffer = ByteArray(32_767)
try {
while (running) {
val read = input.read(buffer)
if (read < 0) break
}
} catch (_ : Exception) {
// Interface fermée depuis stopTunnel() : fin normale du thread.
}
}
private fun stopTunnel(keepForeground: Boolean = false) {
running = false
readerThread?.interrupt()
readerThread = null
tunInterface?.let { runCatching { it.close() } }
tunInterface = null
if (!keepForeground) {
stopForeground(STOP_FOREGROUND_REMOVE)
}
}
override fun onRevoke() {
stopTunnel()
sendBroadcast(Intent(ACTION_VPN_REVOKED).setPackage(packageName))
super.onRevoke()
}
override fun onDestroy() {
stopTunnel()
super.onDestroy()
}
private fun buildNotification(): Notification {
val nm = getSystemService(NotificationManager::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_MIN
)
nm.createNotificationChannel(channel)
}
val openApp = PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.notification_title))
.setSmallIcon(R.drawable.ic_notification)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setContentIntent(openApp)
.setOngoing(true)
.build()
}
companion object {
private const val TAG = "FirewallVpnService"
private const val CHANNEL_ID = "datacontrol_vpn"
private const val NOTIF_ID = 2001
private const val TUNNEL_ADDRESS = "10.10.10.1"
const val ACTION_START_FILTER = "com.jttools.datacontrol.action.VPN_START_FILTER"
const val ACTION_START_BLOCK_ALL = "com.jttools.datacontrol.action.VPN_START_BLOCK_ALL"
const val ACTION_STOP = "com.jttools.datacontrol.action.VPN_STOP"
const val ACTION_VPN_REVOKED = "com.jttools.datacontrol.action.VPN_REVOKED"
const val EXTRA_ALLOWED_PACKAGES = "allowed_packages"
fun isPrepared(context: Context): Boolean = prepare(context) == null
fun startFilterIntent(context: Context, allowedPackages: Collection<String>): Intent =
Intent(context, FirewallVpnService::class.java)
.setAction(ACTION_START_FILTER)
.putStringArrayListExtra(EXTRA_ALLOWED_PACKAGES, ArrayList(allowedPackages))
fun startBlockAllIntent(context: Context): Intent =
Intent(context, FirewallVpnService::class.java)
.setAction(ACTION_START_BLOCK_ALL)
.putStringArrayListExtra(EXTRA_ALLOWED_PACKAGES, ArrayList())
fun stopIntent(context: Context): Intent =
Intent(context, FirewallVpnService::class.java).setAction(ACTION_STOP)
}
}
@@ -14,8 +14,19 @@ data class MonitorSnapshot(
val estimatedTotalPriceEuro: Double = 0.0, val estimatedTotalPriceEuro: Double = 0.0,
val dataState: DataState = DataState.OK, val dataState: DataState = DataState.OK,
val nextAutoResetDate: LocalDate = LocalDate.now(), val nextAutoResetDate: LocalDate = LocalDate.now(),
val nextCutoffAtMillis: Long? = null, /** Contrôle activé (tunnel attendu). */
val serviceRunning: Boolean = false val serviceRunning: Boolean = false,
/** Contrôle activé et appareil associé, mais tunnel non monté : le trafic n'est PAS filtré. */
val firewallFault: Boolean = false,
val transport: NetworkTransport = NetworkTransport.NONE,
val paired: Boolean = false,
val tunnelUp: Boolean = false,
/** Dernière poignée de main avec le serveur (ms), null si aucune. */
val lastHandshakeMs: Long? = null,
/** Mode appliqué par le serveur : open, allowlist ou cut. */
val serverMode: String = "open",
val serverModeReason: String = "",
val tunnelError: String? = null
) )
/** /**
@@ -13,7 +13,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.jttools.datacontrol.service.FirewallVpnService import com.jttools.datacontrol.vpn.TunnelManager
/** /**
* Regroupe les autorisations système nécessaires (VPN, notifications, batterie) pour que * Regroupe les autorisations système nécessaires (VPN, notifications, batterie) pour que
@@ -34,7 +34,61 @@ class SystemActions(private val activity: ComponentActivity) {
ActivityResultContracts.RequestPermission() ActivityResultContracts.RequestPermission()
) { granted -> onNotificationResult?.invoke(granted) } ) { granted -> onNotificationResult?.invoke(granted) }
fun isVpnPrepared(): Boolean = FirewallVpnService.isPrepared(activity) private var onLocationResult: ((Boolean) -> Unit)? = null
private var onBackgroundLocationResult: ((Boolean) -> Unit)? = null
private var onAdminResult: ((Boolean) -> Unit)? = null
private val locationLauncher: ActivityResultLauncher<Array<String>> = activity.registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { granted -> onLocationResult?.invoke(granted.values.any { it }) }
private val backgroundLocationLauncher: ActivityResultLauncher<String> = activity.registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> onBackgroundLocationResult?.invoke(granted) }
private val adminLauncher: ActivityResultLauncher<Intent> = activity.registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result -> onAdminResult?.invoke(result.resultCode == android.app.Activity.RESULT_OK) }
fun hasLocationPermission(): Boolean = ContextCompat.checkSelfPermission(
activity, android.Manifest.permission.ACCESS_FINE_LOCATION
) == android.content.pm.PackageManager.PERMISSION_GRANTED
fun hasBackgroundLocationPermission(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || ContextCompat.checkSelfPermission(
activity, android.Manifest.permission.ACCESS_BACKGROUND_LOCATION
) == android.content.pm.PackageManager.PERMISSION_GRANTED
fun requestLocationPermission(onResult: (Boolean) -> Unit) {
onLocationResult = onResult
locationLauncher.launch(
arrayOf(android.Manifest.permission.ACCESS_FINE_LOCATION, android.Manifest.permission.ACCESS_COARSE_LOCATION)
)
}
/** À demander APRÈS la position au premier plan (exigence Android 11+). */
fun requestBackgroundLocationPermission(onResult: (Boolean) -> Unit) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { onResult(true); return }
onBackgroundLocationResult = onResult
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 requestAdmin(onResult: (Boolean) -> Unit) {
onAdminResult = onResult
adminLauncher.launch(com.jttools.datacontrol.admin.AdminController.requestIntent(activity))
}
fun deactivateAdmin() = com.jttools.datacontrol.admin.AdminController.deactivate(activity)
fun isVpnPrepared(): Boolean = TunnelManager.isPrepared(activity)
fun requestVpnPermission(onResult: (Boolean) -> Unit) { fun requestVpnPermission(onResult: (Boolean) -> Unit) {
val intent = VpnService.prepare(activity) val intent = VpnService.prepare(activity)
@@ -0,0 +1,138 @@
package com.jttools.datacontrol.ui.about
/**
* Contenu des sous-onglets de « À propos ». Chaque affirmation doit se vérifier dans le code, le README
* ou le CHANGELOG du dépôt : 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("Protection par le serveur", listOf(
AboutEntry("Tunnel WireGuard permanent", "Une fois le téléphone associé et la protection activée, tout son trafic (Wi-Fi comme données mobiles) passe par le serveur du parent, qui le filtre."),
AboutEntry("Trois modes décidés par le parent", "Accès libre, liste blanche seulement (par exemple Pronote), ou Internet coupé — à la main, ou selon des plages horaires, un forfait de données mobiles ou une coupure cyclique."),
AboutEntry("Services bloqués", "Le parent bloque des services (réseaux sociaux, jeux, vidéo…) par le DNS filtrant du serveur."),
AboutEntry("Notification permanente", "Mode en cours et sa raison, consommation et total estimé. « PROTECTION INACTIVE » s'affiche si le tunnel est coupé.")
)),
AboutSection("Applications et temps d'écran", listOf(
AboutEntry("Plages horaires, temps d'écran, applications interdites", "Appliqués sur le téléphone par un service d'accessibilité : verrouillage hors plage, limite quotidienne globale ou par application, ajout de temps, lancement refusé."),
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.")
)),
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."),
AboutEntry("Réinitialisation", "Automatique chaque mois à la date anniversaire, ou manuelle depuis le tableau de bord.")
)),
AboutSection("Sur le téléphone", listOf(
AboutEntry("Mot de passe de verrouillage", "Protège l'accès aux Réglages et l'effacement du journal. Sur un téléphone associé, la désactivation de la protection et la sortie du mode propriétaire exigent en plus un code du serveur dicté par le parent."),
AboutEntry("Code de désactivation / déverrouillage", "Couper la protection, dissocier, quitter le mode propriétaire ou lever le verrouillage parental demande un code à usage unique généré par le parent dans l'espace parents : l'enfant ne peut rien désactiver seul."),
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", "État du tunnel, mode appliqué et, si le service d'accessibilité est activé, chaque application utilisée. 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."),
AboutEntry("Mise à jour poussée par le parent", "Depuis l'espace parents : la nouvelle version arrive par le tunnel, son empreinte est vérifiée, puis elle s'installe toute seule en mode propriétaire (sinon, touchez la notification « Mise à jour de DataControl »)."),
AboutEntry("Actions à distance du parent", "Depuis l'espace parents : verrouiller / mettre en veille, couper ou rétablir le son, faire sonner, voir l'écran en direct, et verrouiller le téléphone sur un écran de blocage que seul un code du serveur lève. Le partage d'écran fonctionne dès Android 5 (MediaProjection) ou, sur Android 11+, par le service d'accessibilité ; une notification « Écran partagé avec le parent » reste affichée et le partage s'arrête tout seul au bout de 5 minutes."),
AboutEntry("Blocage des installations", "Le parent peut interdire l'installation de nouvelles applications (Play Store compris) ; appliqué en mode propriétaire de l'appareil."),
AboutEntry("Reprise automatique", "La surveillance et le tunnel redémarrent après un redémarrage de l'appareil, et sont relancés périodiquement si le système les a arrêtés.")
))
)
val howTo = listOf(
"Sur l'espace parents (family.tuxtech.fr), touchez « Associer un téléphone » : un code de 8 caractères s'affiche pendant 10 minutes.",
"Dans DataControl, ouvrez « Réglages » → « Contrôle parental », saisissez le code et touchez « Associer ».",
"Sur le « Tableau de bord », activez la protection et acceptez la demande VPN d'Android : l'icône clé apparaît.",
"Dans « Réglages » → « Autorisations », accordez « Notifications » et « Optimisation de la batterie » ; dans « Contrôle parental », activez le service d'accessibilité et la position si besoin.",
"Recommandé : définissez un mot de passe dans « Réglages » → « Sécurité », puis activez la protection anti-désinstallation."
)
val everyday = listOf(
AboutEntry("Changer l'accès à Internet", "Depuis l'espace parents : Libre, Liste blanche ou Coupé, pour une durée ou sans limite ; le téléphone l'applique en quelques secondes."),
AboutEntry("Consulter la consommation", "Le Tableau de bord affiche les Mo consommés, le restant et le total estimé ; l'espace parents affiche le trafic mesuré par le serveur."),
AboutEntry("Repartir à zéro", "« Réinitialiser le forfait maintenant » sur le Tableau de bord, ou « Consommation actuelle » dans les Réglages.")
)
val troubleshooting = listOf(
AboutEntry("« PROTECTION INACTIVE » dans la notification", "Le tunnel est coupé : autorisation VPN retirée, autre VPN actif, ou serveur injoignable. Rouvrez DataControl pour redonner l'autorisation VPN ; le Journal donne la raison exacte."),
AboutEntry("Un autre VPN a pris la main", "Un seul VPN peut être actif sous Android. La protection reste activée mais ne filtre plus : la notification passe en rouge, le parent est alerté et DataControl retente toutes les 30 s."),
AboutEntry("« Code inconnu, déjà utilisé ou expiré »", "Un code ne sert qu'une fois et vit 10 minutes : créez-en un nouveau dans l'espace parents."),
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 tunnel repose sur le VPN standard d'Android."),
AboutEntry("Un seul VPN à la fois", "DataControl est incompatible avec un autre VPN actif en même temps.")
)),
AboutSection("Autorisations", listOf(
AboutEntry("VPN (obligatoire)", "Consentement système demandé à l'activation de la protection."),
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 que le parent choisisse les applications à limiter."),
AboutEntry("Facultatif", "Position (avec « Toujours autoriser » pour l'arrière-plan), administrateur de l'appareil, service d'accessibilité.")
)),
AboutSection("Réseau", listOf(
AboutEntry("Sortant, UDP 51721", "Tunnel WireGuard vers le serveur du parent (fam-vpn.tuxtech.fr)."),
AboutEntry("Sortant, HTTPS (TCP 443)", "Appairage (family.tuxtech.fr) et recherche de mise à jour (git.tuxtech.fr). Aucun port entrant.")
))
)
val security = listOf(
AboutSection("Tunnel", listOf(
AboutEntry("WireGuard officiel", "Bibliothèque com.wireguard.android (wireguard-go) : chiffrement moderne, aucune donnée en clair entre le téléphone et le serveur."),
AboutEntry("Clé privée sur le téléphone", "La paire de clés est générée sur le téléphone à l'appairage ; la clé privée ne le quitte jamais et est chiffrée par l'Android Keystore."),
AboutEntry("Tout le trafic, IPv4 et IPv6", "Le tunnel couvre 0.0.0.0/0 et ::/0 : aucune application ne le contourne en IPv6."),
AboutEntry("Reprise si le tunnel tombe", "La protection reste activée, le parent est prévenu et le tunnel est remonté dès que possible.")
)),
AboutSection("Liaison avec le serveur", listOf(
AboutEntry("Appairage", "Code aléatoire à usage unique, valable 10 minutes, créé par le parent ; l'appairage passe en HTTPS."),
AboutEntry("Jeton lié au tunnel", "Le jeton de l'appareil, chiffré par l'Android Keystore, n'est accepté par le serveur que depuis l'adresse du téléphone dans le tunnel."),
AboutEntry("Filtrage côté serveur", "Le téléphone ne peut pas lever lui-même une coupure décidée par le parent : elle s'applique sur le serveur.")
)),
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 ; le parent en est alerté."),
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("Retrait du VPN", "Sans le mode « propriétaire de l'appareil » (prévu plus tard), le VPN peut être coupé dans les réglages Android : le serveur le voit et alerte le parent, sans pouvoir l'empêcher."),
AboutEntry("Téléchargement des mises à jour", "L'APK est récupéré en HTTPS depuis la release Gitea ; Android vérifie qu'il porte la même signature que l'application installée.")
))
)
val guarantees = listOf(
AboutSection("Vos données", listOf(
AboutEntry("Aucune télémétrie", "Le code ne contient ni analytique ni publicité. Les seuls échanges sont avec le serveur du parent et la recherche de mise à jour."),
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 et modèle, batterie, position (si autorisée), type de réseau, état des protections, liste des applications installées, temps d'écran par application, évènements.")
)),
AboutSection("Ce que voit le serveur", listOf(
AboutEntry("Noms de domaine et volume", "Comme tout le trafic passe par le serveur du parent, celui-ci voit les sites demandés (journal DNS conservé 7 jours) et le volume échangé."),
AboutEntry("Pas le contenu", "Les échanges chiffrés des applications (HTTPS) restent illisibles pour le serveur ; le service d'accessibilité ne lit pas le contenu des écrans."),
AboutEntry("Partage d'écran visible", "Le parent ne peut voir l'écran que pendant un partage qu'il déclenche ; le téléphone l'affiche alors en permanence (bandeau rouge) et le partage s'arrête seul au bout de 5 minutes. Aucune capture en dehors de ces moments.")
)),
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("Filtrage sur le serveur plutôt que sur le téléphone", "Le téléphone ne peut pas contourner une règle appliquée ailleurs ; le filtrage couvre aussi le Wi-Fi et se pilote depuis l'espace parents."),
AboutEntry("WireGuard plutôt qu'un autre VPN", "Protocole léger et sobre en batterie, bibliothèque officielle ; un seul VPN sur le téléphone, donc aucun conflit."),
AboutEntry("Serveur auto-hébergé plutôt que Firebase", "Aucune dépendance à un service tiers : les données restent sur le serveur du parent."),
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", "Quelques appels simples vers le serveur et Gitea : pas de dépendance réseau supplémentaire."),
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("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++ }
}
}
}
}
@@ -27,6 +27,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType 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
@@ -35,14 +36,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 +55,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 +70,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 +102,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 +119,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
)
} }
} }
}, },
@@ -139,3 +137,49 @@ fun PasswordPromptDialog(
} }
) )
} }
/**
* Boîte de dialogue qui exige un code du serveur (dicté par le parent) pour une action sensible :
* désactiver le contrôle, se dissocier, sortir du mode propriétaire. Remplace le mot de passe local.
*/
@Composable
fun UnlockCodeDialog(
title: String,
message: String,
error: String?,
busy: Boolean,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit
) {
var code by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column {
Text(message, style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = code,
onValueChange = { code = it.take(20) },
label = { Text(stringResource(R.string.unlock_code_field)) },
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Characters),
isError = error != null,
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
error?.let {
Spacer(Modifier.height(4.dp))
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
}
},
confirmButton = {
Button(enabled = !busy, onClick = { onConfirm(code) }) { Text(stringResource(R.string.common_confirm)) }
},
dismissButton = {
OutlinedButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) }
}
)
}
@@ -42,6 +42,7 @@ import com.jttools.datacontrol.data.DataState
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.components.PasswordPromptDialog import com.jttools.datacontrol.ui.components.PasswordPromptDialog
import com.jttools.datacontrol.ui.components.UnlockCodeDialog
import com.jttools.datacontrol.ui.theme.StateCut import com.jttools.datacontrol.ui.theme.StateCut
import com.jttools.datacontrol.ui.theme.StateOk import com.jttools.datacontrol.ui.theme.StateOk
import com.jttools.datacontrol.ui.theme.StateWarning import com.jttools.datacontrol.ui.theme.StateWarning
@@ -56,9 +57,13 @@ 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) }
var showUnlockCodeDialog by remember { mutableStateOf(false) }
var unlockCodeError by remember { mutableStateOf<String?>(null) }
var unlockBusy by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val stateColor = when { val stateColor = when {
@@ -69,10 +74,12 @@ fun DashboardScreen(systemActions: SystemActions) {
else -> StateCut else -> StateCut
} }
val stateLabel = when { val stateLabel = when {
snapshot.firewallFault -> stringResource(R.string.dashboard_tunnel_down)
!snapshot.serviceRunning -> stringResource(R.string.dashboard_control_disabled) !snapshot.serviceRunning -> stringResource(R.string.dashboard_control_disabled)
snapshot.dataState == DataState.OK -> stringResource(R.string.dashboard_state_ok) snapshot.dataState == DataState.OK -> stringResource(R.string.dashboard_state_ok)
snapshot.dataState == DataState.WARNING -> stringResource(R.string.dashboard_state_warning) snapshot.dataState == DataState.WARNING -> stringResource(R.string.dashboard_state_warning)
snapshot.dataState == DataState.CUT -> stringResource(R.string.dashboard_state_cut) snapshot.dataState == DataState.CUT ->
stringResource(if (snapshot.serverMode == "cut") R.string.mode_cut else R.string.mode_allowlist)
else -> stringResource(R.string.dashboard_state_wifi) else -> stringResource(R.string.dashboard_state_wifi)
} }
@@ -160,21 +167,31 @@ fun DashboardScreen(systemActions: SystemActions) {
Switch( Switch(
checked = settings.controlEnabled, checked = settings.controlEnabled,
onCheckedChange = { checked -> onCheckedChange = { checked ->
if (checked) { if (checked && !settings.isPaired) {
android.widget.Toast.makeText(context, context.getString(R.string.dashboard_pair_first), android.widget.Toast.LENGTH_LONG).show()
} else 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 { // désactivation du contrôle
if (settings.hasPassword) { when {
disablePasswordError = false // Appareil associé : seul un code du serveur (dicté par le parent) l'autorise.
showDisablePasswordDialog = true settings.isPaired -> {
} else { unlockCodeError = null
viewModel.setControlEnabled(false) unlockBusy = false
showUnlockCodeDialog = true
}
passwordSet == true -> {
disablePasswordError = null
showDisablePasswordDialog = true
}
passwordSet != null -> viewModel.setControlEnabled(false)
} }
} }
} }
@@ -190,24 +207,30 @@ fun DashboardScreen(systemActions: SystemActions) {
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
val nextCutoffText = when { // Mode décidé par le serveur familyd et état du tunnel.
!settings.controlEnabled -> stringResource(R.string.dashboard_control_disabled) val modeText = when (snapshot.serverMode) {
snapshot.dataState == DataState.WIFI -> stringResource(R.string.dashboard_cutoff_wifi) "cut" -> stringResource(R.string.mode_cut)
snapshot.dataState == DataState.CUT -> stringResource(R.string.dashboard_cutoff_in_progress) "allowlist" -> stringResource(R.string.mode_allowlist)
snapshot.nextCutoffAtMillis != null -> { "blocklist" -> stringResource(R.string.mode_blocklist)
val remainingMs = (snapshot.nextCutoffAtMillis!! - System.currentTimeMillis()).coerceAtLeast(0L) else -> stringResource(R.string.mode_open)
stringResource( }
R.string.dashboard_cutoff_countdown, val tunnelText = when {
remainingMs / 60_000, !snapshot.paired -> stringResource(R.string.dashboard_not_paired)
(remainingMs / 1000) % 60 !settings.controlEnabled -> stringResource(R.string.dashboard_control_disabled)
) snapshot.tunnelUp -> stringResource(R.string.dashboard_tunnel_up) +
} (snapshot.lastHandshakeMs?.let { " · " + DateTimeFormatter.ofPattern("HH:mm:ss")
else -> stringResource(R.string.dashboard_cutoff_unavailable) .format(java.time.Instant.ofEpochMilli(it).atZone(java.time.ZoneId.systemDefault())) } ?: "")
else -> stringResource(R.string.dashboard_tunnel_down) + (snapshot.tunnelError?.let { " ($it)" } ?: "")
}
Text("${stringResource(R.string.dashboard_tunnel)} : $tunnelText", style = MaterialTheme.typography.bodyMedium,
color = if (snapshot.firewallFault) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface)
if (snapshot.paired) {
Text(
"${stringResource(R.string.dashboard_server_mode)} : $modeText" +
(snapshot.serverModeReason.takeIf { it.isNotBlank() && snapshot.serverMode != "open" }?.let { " ($it)" } ?: ""),
style = MaterialTheme.typography.bodyMedium
)
} }
Text(
"${stringResource(R.string.dashboard_next_cutoff)}: $nextCutoffText",
style = MaterialTheme.typography.bodyMedium
)
OutlinedButton(onClick = { showResetDialog = true }, modifier = Modifier.fillMaxWidth()) { OutlinedButton(onClick = { showResetDialog = true }, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.dashboard_reset_now)) Text(stringResource(R.string.dashboard_reset_now))
@@ -235,6 +258,29 @@ fun DashboardScreen(systemActions: SystemActions) {
) )
} }
if (showUnlockCodeDialog) {
UnlockCodeDialog(
title = stringResource(R.string.unlock_code_title),
message = stringResource(R.string.unlock_code_message),
error = unlockCodeError,
busy = unlockBusy,
onDismiss = { showUnlockCodeDialog = false },
onConfirm = { code ->
unlockBusy = true
scope.launch {
val error = viewModel.verifyUnlock(code)
unlockBusy = false
if (error == null) {
viewModel.setControlEnabled(false)
showUnlockCodeDialog = false
} else {
unlockCodeError = error
}
}
}
)
}
if (showDisablePasswordDialog) { if (showDisablePasswordDialog) {
PasswordPromptDialog( PasswordPromptDialog(
title = stringResource(R.string.password_gate_title), title = stringResource(R.string.password_gate_title),
@@ -242,11 +288,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
} }
} }
} }
@@ -5,17 +5,21 @@ 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.data.UsageRepository import com.jttools.datacontrol.data.UsageRepository
import com.jttools.datacontrol.remote.RemoteSync
import com.jttools.datacontrol.service.MonitorStateHolder import com.jttools.datacontrol.service.MonitorStateHolder
import dagger.hilt.android.lifecycle.HiltViewModel 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
class DashboardViewModel @Inject constructor( class DashboardViewModel @Inject constructor(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val usageRepository: UsageRepository, private val usageRepository: UsageRepository,
private val remoteSync: RemoteSync,
stateHolder: MonitorStateHolder stateHolder: MonitorStateHolder
) : ViewModel() { ) : ViewModel() {
@@ -33,5 +37,13 @@ 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)
/** Vérifie le code du serveur (action "deactivate") dicté par le parent. null = accepté. */
suspend fun verifyUnlock(code: String): String? = remoteSync.verifyUnlock(code, "deactivate")
} }
@@ -0,0 +1,293 @@
package com.jttools.datacontrol.ui.family
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.jttools.datacontrol.R
import com.jttools.datacontrol.admin.OwnerPolicy
import com.jttools.datacontrol.ui.SystemActions
import com.jttools.datacontrol.ui.components.UnlockCodeDialog
import com.jttools.datacontrol.ui.theme.StateOk
import kotlinx.coroutines.launch
/** Carte « Contrôle parental » des réglages : liaison avec le serveur familyd, anti-désinstallation, position. */
@Composable
fun FamilySection(systemActions: SystemActions) {
val vm: FamilyViewModel = hiltViewModel()
val settings by vm.settings.collectAsStateWithLifecycle()
val pairing by vm.pairing.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
var adminActive by remember { mutableStateOf(systemActions.isAdminActive()) }
var locationGranted by remember { mutableStateOf(systemActions.hasLocationPermission()) }
var backgroundGranted by remember { mutableStateOf(systemActions.hasBackgroundLocationPermission()) }
var guardEnabled by remember { mutableStateOf(systemActions.isGuardEnabled()) }
var code by remember { mutableStateOf("") }
var deviceName by remember { mutableStateOf(android.os.Build.MODEL) }
// Action sensible en attente de confirmation par mot de passe : "admin", "unpair" ou "owner".
var pendingAction by remember { mutableStateOf<String?>(null) }
var pwd by remember { mutableStateOf("") }
var pwdError by remember { mutableStateOf<String?>(null) }
// Même action, mais confirmée par un code du serveur quand l'appareil est associé (remplace le mot de passe).
var pendingCodeAction by remember { mutableStateOf<String?>(null) }
var codeError by remember { mutableStateOf<String?>(null) }
var codeBusy by remember { mutableStateOf(false) }
// Au retour des écrans système (accessibilité, permissions, admin), on relit l'état réel.
val context = androidx.compose.ui.platform.LocalContext.current
var owner by remember { mutableStateOf(OwnerPolicy.isDeviceOwner(context)) }
var ownerError by remember { mutableStateOf(false) }
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
owner = OwnerPolicy.isDeviceOwner(context)
adminActive = systemActions.isAdminActive()
locationGranted = systemActions.hasLocationPermission()
backgroundGranted = systemActions.hasBackgroundLocationPermission()
guardEnabled = systemActions.isGuardEnabled()
}
fun runProtected(action: String, block: () -> Unit) {
when {
// Appareil associé : un code du serveur (dicté par le parent) est exigé, pas le mot de passe local.
settings.isPaired -> { pendingCodeAction = action; codeError = null; codeBusy = false }
settings.hasPassword -> { pendingAction = action; pwd = ""; pwdError = null }
else -> block()
}
}
fun execute(action: String) {
when (action) {
"admin" -> { systemActions.deactivateAdmin(); adminActive = systemActions.isAdminActive() }
"unpair" -> vm.unpair()
"owner" -> {
ownerError = !OwnerPolicy.release(context)
owner = OwnerPolicy.isDeviceOwner(context)
if (!owner) vm.reportOwnerRemoved()
}
}
}
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
stringResource(R.string.family_title),
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold
)
HorizontalDivider()
// --- Liaison avec le serveur familyd ---
val link = settings.link
if (link != null) {
Text(
stringResource(R.string.family_paired, link.serverUrl.removePrefix("https://"), link.tunnelIp),
color = StateOk, style = MaterialTheme.typography.bodyMedium
)
Text(
"${stringResource(R.string.family_device_name)} : ${link.name}",
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant
)
OutlinedButton(onClick = { runProtected("unpair") { execute("unpair") } }) {
Text(stringResource(R.string.family_unpair))
}
} else {
Text(stringResource(R.string.family_pair_help), style = MaterialTheme.typography.bodyMedium)
OutlinedTextField(
value = code, onValueChange = { code = it.take(300) },
label = { Text(stringResource(R.string.family_code_or_link)) },
singleLine = true, modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = deviceName, onValueChange = { deviceName = it.take(40) },
label = { Text(stringResource(R.string.family_device_name)) },
singleLine = true, modifier = Modifier.fillMaxWidth()
)
pairing.error?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
if (pairing.busy) Text(stringResource(R.string.family_pairing), style = MaterialTheme.typography.bodySmall)
Button(
enabled = code.isNotBlank() && !pairing.busy,
onClick = { vm.pair(code, deviceName) }
) { Text(stringResource(R.string.family_pair)) }
}
pairing.done?.let {
Text(stringResource(R.string.family_paired_now), color = StateOk, style = MaterialTheme.typography.bodySmall)
}
HorizontalDivider()
// --- Mode propriétaire de l'appareil (Device Owner) ---
Text(stringResource(R.string.family_owner_title), fontWeight = FontWeight.Medium)
if (owner) {
Text(stringResource(R.string.family_owner_on), color = StateOk, style = MaterialTheme.typography.bodyMedium)
Text(
stringResource(if (settings.controlEnabled && settings.isPaired) R.string.family_owner_rules_active
else R.string.family_owner_rules_idle),
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant
)
OutlinedButton(onClick = { runProtected("owner") { execute("owner") } }) {
Text(stringResource(R.string.family_owner_release))
}
if (ownerError) Text(stringResource(R.string.family_owner_release_error),
color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
} else {
Text(stringResource(R.string.family_owner_off), style = MaterialTheme.typography.bodyMedium)
androidx.compose.foundation.text.selection.SelectionContainer {
Text(
"adb shell dpm set-device-owner com.jttools.datacontrol/.admin.AppDeviceAdminReceiver",
style = MaterialTheme.typography.bodySmall,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
)
}
}
HorizontalDivider()
// --- Anti-désinstallation ---
Text(stringResource(R.string.family_admin_title), fontWeight = FontWeight.Medium)
Text(
stringResource(if (adminActive) R.string.family_admin_on else R.string.family_admin_off),
style = MaterialTheme.typography.bodyMedium,
color = if (adminActive) StateOk else MaterialTheme.colorScheme.onSurfaceVariant
)
if (owner) {
// Le propriétaire de l'appareil ne peut pas être désinstallé : rien à retirer ici.
} else if (adminActive) {
OutlinedButton(onClick = { runProtected("admin") { execute("admin") } }) {
Text(stringResource(R.string.family_admin_disable))
}
} else {
Button(onClick = { systemActions.requestAdmin { adminActive = systemActions.isAdminActive() } }) {
Text(stringResource(R.string.family_admin_enable))
}
}
if (!settings.hasPassword) {
Text(
stringResource(R.string.family_admin_needs_password),
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error
)
}
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 ---
Text(stringResource(R.string.family_location_title), fontWeight = FontWeight.Medium)
Text(
stringResource(R.string.family_location_desc),
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant
)
when {
!locationGranted -> Button(onClick = {
systemActions.requestLocationPermission { locationGranted = it }
}) { Text(stringResource(R.string.family_location_grant)) }
!backgroundGranted -> Button(onClick = {
systemActions.requestBackgroundLocationPermission {
backgroundGranted = systemActions.hasBackgroundLocationPermission()
}
}) { Text(stringResource(R.string.family_location_background)) }
else -> Text(stringResource(R.string.settings_perm_granted), color = StateOk)
}
}
}
pendingCodeAction?.let { action ->
UnlockCodeDialog(
title = stringResource(R.string.unlock_code_title),
message = stringResource(R.string.unlock_code_message),
error = codeError,
busy = codeBusy,
onDismiss = { pendingCodeAction = null },
onConfirm = { code ->
codeBusy = true
scope.launch {
val err = vm.verifyUnlock(code)
codeBusy = false
if (err == null) { pendingCodeAction = null; execute(action) } else codeError = err
}
}
)
}
pendingAction?.let { action ->
AlertDialog(
onDismissRequest = { pendingAction = null },
title = { Text(stringResource(R.string.password_gate_title)) },
text = {
Column {
OutlinedTextField(
value = pwd, onValueChange = { pwd = it; pwdError = null },
label = { Text(stringResource(R.string.password_field_label)) },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
isError = pwdError != null, singleLine = true, modifier = Modifier.fillMaxWidth()
)
pwdError?.let { Text(
it,
color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall
) }
}
},
confirmButton = {
Button(onClick = {
scope.launch {
val err = vm.checkPassword(pwd)
if (err == null) { pendingAction = null; execute(action) } else pwdError = err
}
}) { Text(stringResource(R.string.common_confirm)) }
},
dismissButton = {
OutlinedButton(onClick = { pendingAction = null }) { Text(stringResource(R.string.common_cancel)) }
}
)
}
}
@@ -0,0 +1,69 @@
package com.jttools.datacontrol.ui.family
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.jttools.datacontrol.data.AppSettings
import com.jttools.datacontrol.data.SettingsRepository
import com.jttools.datacontrol.remote.PairingInput
import com.jttools.datacontrol.remote.RemoteSync
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
data class PairingUiState(val busy: Boolean = false, val error: String? = null, val done: String? = null)
@HiltViewModel
class FamilyViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
private val remoteSync: RemoteSync
) : ViewModel() {
val settings = settingsRepository.settingsFlow.stateIn(
viewModelScope, SharingStarted.WhileSubscribed(5_000), AppSettings()
)
/** 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)
private val _pairing = MutableStateFlow(PairingUiState())
val pairing: StateFlow<PairingUiState> = _pairing.asStateFlow()
fun pair(input: String, deviceName: String) {
val parsed = PairingInput.parse(input)
if (parsed == null) {
_pairing.value = PairingUiState(error = "Code non reconnu : 8 caractères (lettres et chiffres), ou un lien familyd://…")
return
}
_pairing.value = PairingUiState(busy = true)
viewModelScope.launch {
val result = remoteSync.pair(parsed, deviceName)
_pairing.value = result.fold(
onSuccess = { PairingUiState(done = it.name) },
onFailure = { PairingUiState(error = it.message ?: "Association impossible.") }
)
}
}
fun unpair() {
viewModelScope.launch { remoteSync.forget() }
}
fun reportOwnerRemoved() {
viewModelScope.launch {
remoteSync.pushEvent("device_owner_removed", "Mode propriétaire de l'appareil retiré depuis DataControl")
}
}
/** null si le mot de passe est bon, sinon le message d'erreur. */
suspend fun checkPassword(password: String): String? = settingsRepository.checkPassword(password)
/** Vérifie le code du serveur (action "deactivate") dicté par le parent. null = accepté. */
suspend fun verifyUnlock(code: String): String? = remoteSync.verifyUnlock(code, "deactivate")
}
@@ -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.vpn.TunnelManager
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 viewModel: JournalViewModel = hiltViewModel()
val entries by AppLog.entries.collectAsStateWithLifecycle(initialValue = emptyList())
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, viewModel.tunnelUp())) }
LaunchedEffect(Unit) {
while (true) { live = liveStatus(context, viewModel.tunnelUp()); 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, tunnelUp: Boolean): String = buildString {
appendLine("VPN autorisé (prepare) : ${TunnelManager.isPrepared(context)}")
appendLine("Tunnel WireGuard : ${if (tunnelUp) "monté" else "non monté"}")
appendLine("Suivi des applis (accessibilité) : ${com.jttools.datacontrol.guard.AppGuardService.isEnabled(context)}")
appendLine("Conservation du journal : ${AppLog.RETENTION_DAYS} jours")
append("Attendu : tunnel monté en permanence (Wi-Fi et données mobiles) dès que le contrôle est activé.")
}
@@ -0,0 +1,27 @@
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,
private val tunnel: com.jttools.datacontrol.vpn.TunnelManager
) : ViewModel() {
fun tunnelUp(): Boolean = tunnel.up.value
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)
}
@@ -1,7 +1,7 @@
package com.jttools.datacontrol.ui.navigation 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.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,17 +25,17 @@ 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
private sealed class Destination(val route: String, val labelRes: Int, val icon: androidx.compose.ui.graphics.vector.ImageVector) { private sealed class Destination(val route: String, val labelRes: Int, val icon: androidx.compose.ui.graphics.vector.ImageVector) {
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 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.Journal, Destination.About)
@Composable @Composable
fun DataControlNavHost(systemActions: SystemActions) { fun DataControlNavHost(systemActions: SystemActions) {
@@ -71,7 +71,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.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)
} }
} }
@@ -69,7 +70,6 @@ private fun SettingsContent(
var planLimitText by remember(settings.planLimitMo) { mutableStateOf(settings.planLimitMo.toString()) } var planLimitText by remember(settings.planLimitMo) { mutableStateOf(settings.planLimitMo.toString()) }
var planPriceText by remember(settings.planPriceEuro) { mutableStateOf(settings.planPriceEuro.toString()) } var planPriceText by remember(settings.planPriceEuro) { mutableStateOf(settings.planPriceEuro.toString()) }
var overagePriceText by remember(settings.overagePricePerMoEuro) { mutableStateOf(settings.overagePricePerMoEuro.toString()) } var overagePriceText by remember(settings.overagePricePerMoEuro) { mutableStateOf(settings.overagePricePerMoEuro.toString()) }
var cutoffText by remember(settings.cutoffIntervalMinutes) { mutableStateOf(settings.cutoffIntervalMinutes.toString()) }
var autoReset by remember(settings.autoResetEnabled) { mutableStateOf(settings.autoResetEnabled) } var autoReset by remember(settings.autoResetEnabled) { mutableStateOf(settings.autoResetEnabled) }
var showDatePicker by remember { mutableStateOf(false) } var showDatePicker by remember { mutableStateOf(false) }
@@ -177,24 +177,6 @@ private fun SettingsContent(
} }
} }
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(stringResource(R.string.settings_section_cutoff), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
OutlinedTextField(
value = cutoffText,
onValueChange = { cutoffText = it.filter(Char::isDigit) },
label = { Text(stringResource(R.string.settings_cutoff_interval)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Text(
stringResource(R.string.settings_cutoff_help),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) { Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(stringResource(R.string.settings_section_permissions), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) Text(stringResource(R.string.settings_section_permissions), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
@@ -222,6 +204,8 @@ private fun SettingsContent(
} }
} }
com.jttools.datacontrol.ui.family.FamilySection(systemActions)
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) { Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(stringResource(R.string.settings_section_security), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) Text(stringResource(R.string.settings_section_security), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
@@ -290,7 +274,6 @@ private fun SettingsContent(
planLimitMo = planLimitText.toLongOrNull() ?: settings.planLimitMo, planLimitMo = planLimitText.toLongOrNull() ?: settings.planLimitMo,
planPriceEuro = planPriceText.toDoubleOrNull() ?: settings.planPriceEuro, planPriceEuro = planPriceText.toDoubleOrNull() ?: settings.planPriceEuro,
overagePricePerMoEuro = overagePriceText.toDoubleOrNull() ?: settings.overagePricePerMoEuro, overagePricePerMoEuro = overagePriceText.toDoubleOrNull() ?: settings.overagePricePerMoEuro,
cutoffIntervalMinutes = cutoffText.toIntOrNull()?.coerceAtLeast(1) ?: settings.cutoffIntervalMinutes,
autoResetEnabled = autoReset autoResetEnabled = autoReset
) )
}, },
@@ -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
@@ -33,12 +34,11 @@ class SettingsViewModel @Inject constructor(
planLimitMo: Long, planLimitMo: Long,
planPriceEuro: Double, planPriceEuro: Double,
overagePricePerMoEuro: Double, overagePricePerMoEuro: Double,
cutoffIntervalMinutes: Int,
autoResetEnabled: Boolean autoResetEnabled: Boolean
) { ) {
viewModelScope.launch { viewModelScope.launch {
settingsRepository.updatePlan( settingsRepository.updatePlan(
planLimitMo, planPriceEuro, overagePricePerMoEuro, cutoffIntervalMinutes, autoResetEnabled planLimitMo, planPriceEuro, overagePricePerMoEuro, autoResetEnabled
) )
} }
} }
@@ -55,5 +55,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)
} }
@@ -1,191 +0,0 @@
package com.jttools.datacontrol.ui.whitelist
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Checkbox
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.jttools.datacontrol.R
import com.jttools.datacontrol.data.AppInfo
import com.jttools.datacontrol.ui.components.PasswordGate
@Composable
fun WhitelistScreen() {
val viewModel: WhitelistViewModel = hiltViewModel()
val settings by viewModel.settings.collectAsStateWithLifecycle()
PasswordGate(passwordSet = settings.hasPassword, verify = { viewModel.verifyPassword(it) }) {
WhitelistContent(viewModel, settings)
}
}
@Composable
private fun WhitelistContent(viewModel: WhitelistViewModel, settings: com.jttools.datacontrol.data.AppSettings) {
val apps by viewModel.apps.collectAsStateWithLifecycle()
val loading by viewModel.loading.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
var tabIndex by remember { mutableIntStateOf(0) }
val forWifi = tabIndex == 1
val filtered = apps
.filter { settings.showSystemApps || !it.isSystemApp }
.filter { it.label.contains(query, ignoreCase = true) || it.packageName.contains(query, ignoreCase = true) }
val activeSet = if (forWifi) settings.whitelistedPackagesWifi else settings.whitelistedPackages
Column(Modifier.fillMaxSize().padding(20.dp)) {
Text(stringResource(R.string.whitelist_title), style = MaterialTheme.typography.headlineMedium)
Text(
stringResource(R.string.whitelist_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp, bottom = 14.dp)
)
TabRow(selectedTabIndex = tabIndex) {
Tab(
selected = tabIndex == 0,
onClick = { tabIndex = 0 },
text = { Text(stringResource(R.string.whitelist_tab_mobile)) }
)
Tab(
selected = tabIndex == 1,
onClick = { tabIndex = 1 },
text = { Text(stringResource(R.string.whitelist_tab_wifi)) }
)
}
if (forWifi) {
Row(
Modifier.fillMaxWidth().padding(top = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f)) {
Text(stringResource(R.string.whitelist_wifi_filter_toggle), style = MaterialTheme.typography.bodyMedium)
Text(
stringResource(R.string.whitelist_wifi_filter_help),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(checked = settings.wifiFilterEnabled, onCheckedChange = { viewModel.setWifiFilterEnabled(it) })
}
}
OutlinedTextField(
value = query,
onValueChange = { query = it },
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
label = { Text(stringResource(R.string.whitelist_search_hint)) },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(top = 10.dp)
)
Row(
Modifier.fillMaxWidth().padding(vertical = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(stringResource(R.string.whitelist_system_apps_toggle), style = MaterialTheme.typography.bodyMedium)
Switch(checked = settings.showSystemApps, onCheckedChange = { viewModel.setShowSystemApps(it) })
}
Row(
Modifier.fillMaxWidth().padding(bottom = 10.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
OutlinedButton(
onClick = { viewModel.setWhitelistedBulk(filtered.map { it.packageName }, whitelisted = true, forWifi = forWifi) },
modifier = Modifier.weight(1f)
) { Text(stringResource(R.string.whitelist_select_all)) }
OutlinedButton(
onClick = { viewModel.setWhitelistedBulk(filtered.map { it.packageName }, whitelisted = false, forWifi = forWifi) },
modifier = Modifier.weight(1f)
) { Text(stringResource(R.string.whitelist_select_none)) }
}
HorizontalDivider()
if (loading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
} else {
LazyColumn {
items(filtered, key = { it.packageName }) { app ->
AppRow(
app = app,
checked = activeSet.contains(app.packageName),
onCheckedChange = { viewModel.setWhitelisted(app.packageName, it, forWifi) }
)
}
}
}
}
}
@Composable
private fun AppRow(app: AppInfo, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
Modifier.fillMaxWidth().padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
AppIcon(app)
Column(Modifier.weight(1f)) {
Text(app.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
Text(app.packageName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Checkbox(checked = checked, onCheckedChange = onCheckedChange)
}
}
@Composable
private fun AppIcon(app: AppInfo) {
val bitmap = remember(app.packageName) {
app.icon?.let { runCatching { it.toBitmap(width = 96, height = 96).asImageBitmap() }.getOrNull() }
}
if (bitmap != null) {
Image(bitmap = bitmap, contentDescription = null, modifier = Modifier.size(40.dp))
} else {
Box(
modifier = Modifier
.size(40.dp)
.background(MaterialTheme.colorScheme.surfaceVariant, CircleShape)
)
}
}
@@ -1,66 +0,0 @@
package com.jttools.datacontrol.ui.whitelist
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.jttools.datacontrol.data.AppInfo
import com.jttools.datacontrol.data.AppSettings
import com.jttools.datacontrol.data.InstalledAppsRepository
import com.jttools.datacontrol.data.SettingsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class WhitelistViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
private val installedAppsRepository: InstalledAppsRepository
) : ViewModel() {
val settings = settingsRepository.settingsFlow.stateIn(
viewModelScope, SharingStarted.WhileSubscribed(5_000), AppSettings()
)
private val _apps = MutableStateFlow<List<AppInfo>>(emptyList())
val apps = _apps.asStateFlow()
private val _loading = MutableStateFlow(true)
val loading = _loading.asStateFlow()
init {
viewModelScope.launch {
_apps.value = installedAppsRepository.listInstalledApps()
_loading.value = false
}
}
fun setWhitelisted(packageName: String, whitelisted: Boolean, forWifi: Boolean) {
viewModelScope.launch {
val current = if (forWifi) settings.value.whitelistedPackagesWifi else settings.value.whitelistedPackages
val updated = if (whitelisted) current + packageName else current - packageName
if (forWifi) settingsRepository.setWhitelistedPackagesWifi(updated) else settingsRepository.setWhitelistedPackages(updated)
}
}
/** Sélectionne ou désélectionne en bloc les paquets [packageNames] (ex : la liste actuellement filtrée à l'écran). */
fun setWhitelistedBulk(packageNames: List<String>, whitelisted: Boolean, forWifi: Boolean) {
viewModelScope.launch {
val current = if (forWifi) settings.value.whitelistedPackagesWifi else settings.value.whitelistedPackages
val updated = if (whitelisted) current + packageNames else current - packageNames.toSet()
if (forWifi) settingsRepository.setWhitelistedPackagesWifi(updated) else settingsRepository.setWhitelistedPackages(updated)
}
}
fun setWifiFilterEnabled(enabled: Boolean) {
viewModelScope.launch { settingsRepository.setWifiFilterEnabled(enabled) }
}
fun setShowSystemApps(show: Boolean) {
viewModelScope.launch { settingsRepository.setShowSystemApps(show) }
}
suspend fun verifyPassword(password: String): Boolean = settingsRepository.verifyPassword(password)
}
@@ -0,0 +1,106 @@
package com.jttools.datacontrol.vpn
import android.content.Context
import android.net.VpnService
import com.jttools.datacontrol.log.AppLog
import com.jttools.datacontrol.remote.FamilyLink
import com.wireguard.android.backend.BackendException
import com.wireguard.android.backend.GoBackend
import com.wireguard.android.backend.Tunnel
import com.wireguard.config.Config
import com.wireguard.crypto.KeyPair
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Tunnel WireGuard vers le serveur familyd, par la bibliothèque officielle (wireguard-go embarqué).
* Un seul VPN sur l'appareil : tout le trafic du téléphone passe par le serveur, qui le filtre.
*/
@Singleton
class TunnelManager @Inject constructor(
@ApplicationContext private val context: Context
) {
private val backend by lazy { GoBackend(context) }
private val mutex = Mutex()
private var appliedConfig: String? = null
private val _up = MutableStateFlow(false)
/** Vrai tant que le tunnel est monté (faux après un retrait du VPN par l'utilisateur ou un autre VPN). */
val up: StateFlow<Boolean> = _up.asStateFlow()
@Volatile var lastError: String? = null
private set
private val tunnel = object : Tunnel {
override fun getName() = "familyd"
override fun onStateChange(newState: Tunnel.State) {
val isUp = newState == Tunnel.State.UP
if (!isUp && _up.value) AppLog.w("Tunnel", "Tunnel arrêté (VPN retiré ou autre VPN prioritaire)")
_up.value = isUp
if (!isUp) appliedConfig = null
}
}
/** Monte (ou reconfigure) le tunnel. Sans effet si la configuration n'a pas changé et que le tunnel tourne. */
suspend fun ensureUp(link: FamilyLink, privateKey: String): Boolean = withContext(Dispatchers.IO) {
mutex.withLock {
val text = link.wgQuickConfig(privateKey)
if (text == appliedConfig && backend.getState(tunnel) == Tunnel.State.UP) return@withLock true
try {
val config = Config.parse(text.byteInputStream())
backend.setState(tunnel, Tunnel.State.UP, config)
appliedConfig = text
lastError = null
_up.value = true
AppLog.i("Tunnel", "Tunnel monté vers ${link.endpoint} (adresse ${link.tunnelIp})")
true
} catch (e: BackendException) {
lastError = describe(e.reason)
AppLog.e("Tunnel", "Tunnel non monté : $lastError")
false
} catch (e: Exception) {
lastError = e.message ?: e.javaClass.simpleName
AppLog.e("Tunnel", "Tunnel non monté", e)
false
}
}
}
suspend fun down() = withContext(Dispatchers.IO) {
mutex.withLock {
runCatching { backend.setState(tunnel, Tunnel.State.DOWN, null) }
.onFailure { AppLog.w("Tunnel", "Arrêt du tunnel : ${it.message}") }
appliedConfig = null
_up.value = false
}
}
/** Dernière poignée de main avec le serveur (ms), ou null si aucune. */
fun lastHandshakeMillis(): Long? = runCatching {
val stats = backend.getStatistics(tunnel)
stats.peers().firstOrNull()?.let { stats.peer(it)?.latestHandshakeEpochMillis() }?.takeIf { it > 0 }
}.getOrNull()
private fun describe(reason: BackendException.Reason): String = when (reason) {
BackendException.Reason.VPN_NOT_AUTHORIZED -> "autorisation VPN absente (à redonner dans DataControl)"
BackendException.Reason.DNS_RESOLUTION_FAILURE -> "adresse du serveur introuvable (DNS)"
BackendException.Reason.UNABLE_TO_START_VPN -> "Android refuse de démarrer le VPN"
BackendException.Reason.TUN_CREATION_ERROR -> "création de l'interface VPN impossible"
else -> reason.name
}
companion object {
fun isPrepared(context: Context): Boolean = VpnService.prepare(context) == null
/** Nouvelle paire de clés WireGuard : (clé privée, clé publique) en base64. */
fun newKeyPair(): Pair<String, String> = KeyPair().let { it.privateKey.toBase64() to it.publicKey.toBase64() }
}
}
+61 -36
View File
@@ -5,20 +5,17 @@
<!-- Navigation --> <!-- Navigation -->
<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_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_wifi">Wi-Fi actif</string>
<string name="dashboard_state_wifi">Wi-Fi actif — aucun filtrage</string>
<string name="dashboard_cost_title">Estimation du coût</string> <string name="dashboard_cost_title">Estimation du coût</string>
<string name="dashboard_cost_plan">Prix du forfait</string> <string name="dashboard_cost_plan">Prix du forfait</string>
<string name="dashboard_cost_overage">Coût hors-forfait</string> <string name="dashboard_cost_overage">Coût hors-forfait</string>
@@ -29,13 +26,7 @@
<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_cutoff_wifi">Wi-Fi actif (pas de coupure)</string>
<string name="dashboard_cutoff_in_progress">En cours…</string>
<string name="dashboard_cutoff_countdown">dans %1$d min %2$d s</string>
<string name="dashboard_cutoff_unavailable">indisponible (pare-feu inactif)</string>
<!-- Settings --> <!-- Settings -->
<string name="settings_title">Réglages</string> <string name="settings_title">Réglages</string>
@@ -47,12 +38,7 @@
<string name="settings_overage_price">Prix par Mo hors-forfait (€)</string> <string name="settings_overage_price">Prix par Mo hors-forfait (€)</string>
<string name="settings_start_date">Date de début du forfait</string> <string name="settings_start_date">Date de début du forfait</string>
<string name="settings_auto_reset">Réinitialisation automatique mensuelle</string> <string name="settings_auto_reset">Réinitialisation automatique mensuelle</string>
<string name="settings_section_cutoff">Coupure automatique</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_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 +49,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 +64,21 @@
<!-- 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>
<!-- Verrouillage parental (écran plein écran levé par un code du serveur) -->
<string name="parental_lock_title">Téléphone verrouillé</string>
<string name="parental_lock_message">Ce téléphone a été verrouillé à distance par le parent. Demandez-lui le code de déverrouillage.</string>
<string name="parental_lock_code_label">Code de déverrouillage</string>
<string name="parental_lock_unlock">Déverrouiller</string>
<!-- Code du serveur exigé pour désactiver le contrôle (remplace le mot de passe local) -->
<string name="unlock_code_title">Code de désactivation</string>
<string name="unlock_code_message">Pour désactiver le contrôle, demandez au parent de générer un code de désactivation depuis l\'espace parents, puis saisissez-le ici.</string>
<string name="unlock_code_field">Code du parent</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>
@@ -102,25 +92,60 @@
<string name="about_author_value">JT-Tools — Johnny</string> <string name="about_author_value">JT-Tools — Johnny</string>
<!-- Whitelist --> <!-- Whitelist -->
<string name="whitelist_title">Liste blanche</string>
<string name="whitelist_description">Ces applications gardent toujours accès aux données mobiles, y compris en dépassement de forfait (sauf pendant les coupures cycliques totales).</string>
<string name="whitelist_search_hint">Rechercher une application</string>
<string name="whitelist_system_apps_toggle">Afficher les applications système</string>
<string name="whitelist_tab_mobile">Données mobiles</string>
<string name="whitelist_tab_wifi">Wi-Fi</string>
<string name="whitelist_wifi_filter_toggle">Filtrer aussi le Wi-Fi</string>
<string name="whitelist_wifi_filter_help">Par défaut, le Wi-Fi n\'est jamais filtré. Activez pour restreindre l\'accès Wi-Fi aux applications cochées ci-dessous.</string>
<string name="whitelist_select_all">Tout sélectionner</string>
<string name="whitelist_select_none">Tout désélectionner</string>
<!-- Notification --> <!-- Notification -->
<string name="notification_channel_name">Surveillance des données</string> <string name="notification_channel_name">Surveillance des données</string>
<string name="notification_channel_description">Affiche la consommation de données et l\'état de la coupure</string> <string name="notification_channel_description">Affiche la consommation de données et l\'état de la protection</string>
<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</string>
<string name="family_paired">Téléphone associé au serveur %1$s (adresse %2$s dans le tunnel). Le parent gère l\'accès à Internet et voit la batterie, la position et le temps d\'écran.</string>
<string name="family_unpair">Oublier cette liaison</string>
<string name="family_pair_help">Dans l\'espace parents (family.tuxtech.fr), touchez « Associer un téléphone », puis saisissez ici le code affiché (ou collez le lien familyd://…).</string>
<string name="family_code">Code d\'appairage</string>
<string name="family_device_name">Nom de l\'appareil</string>
<string name="family_pair">Associer</string>
<string name="family_admin_title">Protection anti-désinstallation</string>
<string name="family_admin_on">Active : DataControl ne peut pas être désinstallé sans désactiver d\'abord cette protection (mot de passe demandé ici).</string>
<string name="family_admin_off">Inactive : l\'application peut être désinstallée librement.</string>
<string name="family_admin_enable">Activer la protection</string>
<string name="family_admin_disable">Désactiver la protection</string>
<string name="family_admin_needs_password">Définissez un mot de passe (section Sécurité) : sans lui, la protection peut être retirée librement.</string>
<string name="family_location_title">Position de l\'appareil</string>
<string name="family_location_desc">Partagée avec le parent, y compris en arrière-plan (choisir « Toujours autoriser »).</string>
<string name="family_location_grant">Autoriser la position</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>
<!-- v0.2 : tunnel WireGuard et serveur familyd -->
<string name="mode_open">Accès libre</string>
<string name="mode_blocklist">Liste noire (certains services bloqués)</string>
<string name="mode_allowlist">Liste blanche seulement</string>
<string name="mode_cut">Internet coupé</string>
<string name="dashboard_tunnel">Tunnel</string>
<string name="dashboard_tunnel_up">connecté au serveur</string>
<string name="dashboard_tunnel_down">PROTECTION INACTIVE — tunnel coupé</string>
<string name="dashboard_server_mode">Accès décidé par le parent</string>
<string name="dashboard_not_paired">téléphone non associé (Réglages → Contrôle parental)</string>
<string name="dashboard_pair_first">Associez d\'abord le téléphone (Réglages → Contrôle parental).</string>
<string name="family_code_or_link">Code d\'appairage ou lien familyd://</string>
<string name="family_code_invalid">Code non reconnu : 8 caractères (lettres et chiffres), ou un lien familyd://…</string>
<string name="family_pairing">Association en cours…</string>
<string name="family_paired_now">Téléphone associé. Activez la protection sur le tableau de bord.</string>
<string name="family_owner_title">Mode propriétaire de l\'appareil</string>
<string name="family_owner_on">Actif : DataControl verrouille le VPN, la date et l\'heure, la réinitialisation et sa propre désinstallation.</string>
<string name="family_owner_rules_active">Protection en cours : VPN toujours actif, connexions bloquées hors VPN (levées automatiquement si le serveur ne répond plus pendant 15 min).</string>
<string name="family_owner_rules_idle">En attente : les verrous s\'appliquent dès que le téléphone est associé et la protection activée.</string>
<string name="family_owner_release">Retirer le mode propriétaire</string>
<string name="family_owner_release_error">Retrait impossible : voir le journal.</string>
<string name="family_owner_off">Inactif. Pour l\'activer (une seule fois, téléphone branché au PC, tous les comptes retirés le temps de la commande) :</string>
<string name="screen_shared_banner">🔴 Écran partagé avec le parent</string>
</resources> </resources>
@@ -0,0 +1,8 @@
<?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"
android:canTakeScreenshot="true" />
+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>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<device-admin>
<uses-policies>
<force-lock />
</uses-policies>
</device-admin>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- HTTPS partout. Seule exception : l'API familyd dans le tunnel WireGuard (déjà chiffré de bout en bout). -->
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.78.0.1</domain>
</domain-config>
</network-security-config>
@@ -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,56 @@
package com.jttools.datacontrol.remote
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class FamilyLinkTest {
private val pairResponse = """
{"device_id":"5b1f","token":"fd_secret","name":"Tél. Lilou","ready":true,
"wireguard":{"address":"10.78.0.11/32","dns":"10.78.0.1","mtu":1280,
"server_public_key":"JavjzwIltCzZB49zUDJCVrbUu9SGbxHH6gcfPB3Y81k=",
"endpoint":"fam-vpn.tuxtech.fr:51721","allowed_ips":["0.0.0.0/0","::/0"],
"persistent_keepalive":25},
"api":"http://10.78.0.1:8100/"}
""".trimIndent()
@Test fun `code seul, avec espaces ou tirets`() {
assertEquals(PairingInput("https://family.tuxtech.fr", "ABCD2345"), PairingInput.parse("abcd 2345"))
assertEquals("ABCD2345", PairingInput.parse("ABCD-2345")?.code)
assertNull(PairingInput.parse("ABC"))
assertNull(PairingInput.parse("ABCD234O")) // O et 0 exclus de l'alphabet
}
@Test fun `lien familyd`() {
val p = PairingInput.parse("familyd://pair?url=https%3A%2F%2Fautre.exemple.fr%2F&code=WXYZ2345")
assertEquals(PairingInput("https://autre.exemple.fr", "WXYZ2345"), p)
assertEquals("https://family.tuxtech.fr", PairingInput.parse("familyd://pair?url=https://family.tuxtech.fr&code=WXYZ2345")?.serverUrl)
assertNull(PairingInput.parse("familyd://pair?url=http://non.chiffre.fr&code=WXYZ2345")) // HTTPS obligatoire
assertNull(PairingInput.parse("familyd://pair?code=WXYZ2345"))
}
@Test fun `reponse d appairage`() {
val (link, token) = FamilyLink.fromPairResponse("https://family.tuxtech.fr", pairResponse)
assertEquals("fd_secret", token)
assertEquals("10.78.0.11", link.tunnelIp)
assertEquals("http://10.78.0.1:8100", link.apiBase)
assertEquals(listOf("0.0.0.0/0", "::/0"), link.allowedIps)
}
@Test fun `configuration wg-quick`() {
val (link, _) = FamilyLink.fromPairResponse("https://family.tuxtech.fr", pairResponse)
val conf = link.wgQuickConfig("PRIVEE=")
assertTrue(conf.startsWith("[Interface]\nPrivateKey = PRIVEE=\nAddress = 10.78.0.11/32\nDNS = 10.78.0.1\nMTU = 1280"))
assertTrue("AllowedIPs = 0.0.0.0/0, ::/0" in conf && "Endpoint = fam-vpn.tuxtech.fr:51721" in conf)
assertTrue("PersistentKeepalive = 25" in conf)
}
@Test fun `sauvegarde et relecture`() {
val (link, _) = FamilyLink.fromPairResponse("https://family.tuxtech.fr", pairResponse)
assertEquals(link, FamilyLink.fromJson(link.toJson()))
assertNull(FamilyLink.fromJson("{pas du json"))
assertNull(FamilyLink.fromJson(null))
}
}
+425
View File
@@ -0,0 +1,425 @@
# Plan : contrôle parental par WireGuard et serveur auto-hébergé
> Statut : **proposition**, rien n'est réalisé. Rédigé le 2026-10-25.
> Concerne DataControl (téléphone de l'enfant), FamilyControl (téléphone du parent) et un nouveau
> service serveur, `familyd`, hébergé sur le CT 233 de pve-home (192.168.77.233).
## 1. Objectif
Remplacer le fonctionnement actuel (pare-feu VPN local « trou noir » + Firebase) par :
- **un tunnel WireGuard permanent** entre le téléphone de l'enfant et le serveur : tout le trafic
du téléphone (Wi-Fi comme 4G/5G) passe par le serveur ;
- **un filtrage côté serveur** : coupures, plages horaires, quota, sites et services interdits ;
- **un canal de contrôle** par ce même tunnel : consignes du parent, état de l'appareil, alertes ;
- **plus aucune dépendance à Firebase / Google**.
Ce qui reste sur le téléphone : ce que seul le téléphone peut savoir (application au premier plan,
temps d'écran, batterie, position, type de réseau) et le blocage du lancement des applications.
## 2. Principe et contrainte fondatrice
WireGuard transporte des **paquets IP** : le serveur voit des adresses, des ports et (via le DNS)
des noms de domaine, **jamais l'application qui a émis le paquet**. D'où la répartition :
| Besoin | Où | Comment |
|---|---|---|
| Couper Internet (horaire, punition, coupure cyclique) | Serveur | Règle nftables de l'appareil → « coupé » |
| Bloquer un service (TikTok, YouTube…) ou une catégorie | Serveur | DNS filtrant par appareil |
| « Seul Pronote passe » (liste blanche) | Serveur | DNS limité aux domaines autorisés + jeu d'IP rempli par les réponses DNS |
| Quota de données | Serveur | Compteur d'octets du pair WireGuard (fiable, non falsifiable) |
| Interdire / limiter le temps d'une application | Téléphone | Service d'accessibilité existant (blocage au lancement) |
| Temps d'écran, batterie, position, type de réseau | Téléphone | Remontés à l'API par le tunnel |
| Empêcher de couper le VPN, de changer l'heure, de désinstaller | Téléphone | Mode Device Owner (§8) |
Le filtrage réseau **par application** n'est pas repris en phase 1 : une application interdite est
bloquée à son lancement, ce qui couvre l'usage réel. Il reste possible en phase 5 (§11).
## 3. Architecture
```
Téléphone enfant (DataControl) CT 233 srv-home-zoompilot (192.168.77.233)
┌──────────────────────────────┐ ┌──────────────────────────────────────────────┐
│ VPN = client WireGuard │ UDP 51721 │ wg1 10.78.0.1/24 (wg0 ZoomPilot inchangé) │
│ AllowedIPs 0.0.0.0/0, ::/0 ├────────────────►│ ├ nftables table inet familyd │
│ DNS 10.78.0.1, MTU 1280 │ Freebox │ │ modes par appareil, NAT sortant, │
│ Service d'accessibilité │ 82.67.111.224 │ │ DNS forcé, DoT/DoH bloqués │
│ Client API (+ WebSocket) │ │ ├ DNS filtrant (AdGuard Home) 10.78.0.1:53 │
└──────────────────────────────┘ │ ├ familyd (FastAPI + SQLite) │
│ │ 10.78.0.1:8100 (appareils, via wg1) │
FamilyControl (parent) │ │ 192.168.77.233:8100 (Traefik, parents) │
┌──────────────────────────────┐ HTTPS │ ├ compteurs `wg show wg1 transfer` │
│ API REST + WebSocket ├──► Traefik CT 201 ─►└ ntfy :8090 (existant) pour les alertes │
│ Alertes : appli ntfy │ family.tuxtech.fr │
└──────────────────────────────┘ └──────────────────────────────────────────────┘
```
### Cohabitation avec ZoomPilot sur le CT 233
Tout est séparé pour qu'un changement d'un côté ne touche pas l'autre :
| | ZoomPilot (existant) | Contrôle parental (nouveau) |
|---|---|---|
| Interface WireGuard | `wg0` 10.77.0.1/24, UDP 51720 | `wg1` 10.78.0.1/24, UDP **51721** |
| Pare-feu | `table inet filter`, `table ip zp_nat` | `table inet familyd` (dédiée) |
| Service | `zoompilot` :8000 | `familyd` :8100, `adguardhome` :53 + :3000 (admin, local) |
| Utilisateur système | `zoompilot` | `familyd` |
| Données | /var/lib/zoompilot | /var/lib/familyd |
Plages et ports vérifiés libres le 2026-10-25 : 10.6.0.0/24, 10.8.0.0/23 et 10.77.0.0/24 sont pris ;
UDP 51720, 51820, 51900 pris.
**Risque assumé** : le CT devient critique pour l'accès Internet de l'enfant (voir §9). Un CT dédié
(ex. 234) isolerait mieux ; le plan reste valable tel quel en changeant l'IP.
## 4. Serveur
### 4.1 Ressources du CT 233
Actuel : 1 cœur, 512 Mo, disque 3,9 Go (2,4 Go libres). À porter à **2 cœurs, 1,5 Go de RAM,
disque +8 Go** (journaux DNS, historique SQLite). Sur pve-home, lire `/etc/pve` plutôt que
`pct list` (stockage lent) ; le CT est sur `local-lvm`.
```bash
# sur pve-home
pct set 233 --cores 2 --memory 1536
pct resize 233 rootfs +8G
```
### 4.2 Réseau
- **Freebox** : rediriger **UDP 51721** → 192.168.77.233.
- **Nom** : `fam-vpn.tuxtech.fr` → 82.67.111.224 (point de terminaison WireGuard ; un nom plutôt
qu'une IP, pour survivre à un changement d'adresse).
- **Traefik (CT 201)** : `family.tuxtech.fr` → 192.168.77.233:8100, certificat Let's Encrypt,
**uniquement** les routes parent et appairage (`/api/parent/*`, `/api/pair`). Les routes
appareil ne sont servies que sur 10.78.0.1.
- **Retour en boucle (hairpin)** : quand le téléphone est sur le Wi-Fi de la maison, il joint
82.67.111.224 depuis le réseau local. À vérifier sur la Freebox en phase 0 ; sinon, résolution
DNS locale de `fam-vpn.tuxtech.fr` vers 192.168.77.233.
- **IPv6** : le tunnel annonce `::/0` pour qu'aucun trafic IPv6 ne contourne le serveur ; le
serveur ne route pas l'IPv6 (les applications retombent sur IPv4). NAT66 éventuel plus tard.
### 4.3 WireGuard `wg1`
```ini
# /etc/wireguard/wg1.conf
[Interface]
Address = 10.78.0.1/24
ListenPort = 51721
PrivateKey = <clé serveur, chmod 600>
# Les pairs sont ajoutés par familyd (wg set + wg-quick save) — pas d'édition à la main.
```
Pair type (écrit par familyd à l'appairage) :
```ini
[Peer]
# device=<uuid> family=<id>
PublicKey = <clé publique générée sur le téléphone>
AllowedIPs = 10.78.0.10/32
```
Côté téléphone : `PersistentKeepalive = 25` (indispensable derrière le NAT des opérateurs), MTU 1280.
### 4.4 Pare-feu nftables (`table inet familyd`)
Chaque appareil a un **mode** appliqué par familyd, via une map plutôt qu'une réécriture des
règles :
| Mode | Effet |
|---|---|
| `open` | Tout passe (filtrage DNS seul) |
| `allowlist` | Seules les IP du jeu `allow4` (remplies par les réponses DNS des domaines autorisés) passent |
| `cut` | Rien ne sort, sauf le serveur lui-même (API + DNS sur 10.78.0.1) |
```nft
table inet familyd {
map mode4 { type ipv4_addr : verdict; } # 10.78.0.10 : jump m_open …
set allow4 { type ipv4_addr; flags timeout; timeout 1h; } # rempli par dnsmasq (nftset)
set doh4 { type ipv4_addr; flags interval; } # résolveurs DoH connus
chain prerouting {
type nat hook prerouting priority dstnat;
iifname "wg1" udp dport 53 dnat ip to 10.78.0.1 # DNS forcé
iifname "wg1" tcp dport 53 dnat ip to 10.78.0.1
}
chain postrouting {
type nat hook postrouting priority srcnat;
iifname "wg1" oifname "eth0" masquerade
}
chain forward {
type filter hook forward priority 0; policy accept;
iifname != "wg1" return
tcp dport 853 drop # DNS sur TLS
udp dport 853 drop
ip daddr @doh4 tcp dport 443 drop # DNS sur HTTPS vers IP connues
ip saddr vmap @mode4
drop # appareil inconnu
}
chain m_open { accept }
chain m_allowlist { ip daddr @allow4 accept; drop; }
chain m_cut { drop }
chain input {
type filter hook input priority 0; policy accept;
iifname "wg1" ip daddr 10.78.0.1 tcp dport { 53, 8100 } accept
iifname "wg1" ip daddr 10.78.0.1 udp dport 53 accept
iifname "wg1" drop # rien d'autre du CT (ZoomPilot, SSH…)
}
}
```
Points à respecter :
- règles **idempotentes**, rechargées au démarrage de familyd depuis la base (source de vérité :
SQLite, pas nftables) ;
- **isolation entre pairs** : un appareil ne joint ni les autres pairs de `wg1`, ni `wg0`, ni le
réseau 192.168.77.0/24 (règles `ip daddr { 10.77.0.0/24, 10.78.0.0/24, 192.168.77.0/24 } drop`
dans `forward`, sauf 10.78.0.1) ;
- après un changement de mode, purger les connexions en cours de l'appareil
(`conntrack -D -s 10.78.0.x`), sinon une connexion ouverte survit à la coupure.
### 4.5 DNS filtrant
**Choix : AdGuard Home** (binaire unique, API REST, réglages par client) :
- un **client par appareil** (identifié par son IP 10.78.0.x) avec ses **services bloqués**
(liste intégrée : TikTok, YouTube, Snapchat…), contrôle parental, recherche sécurisée ;
- règles par appareil avec le modificateur `$client` (mode liste blanche :
`/.*/$client='10.78.0.10'` + `@@||index-education.net^$client='10.78.0.10'`) ;
- blocage des domaines DoH connus (`dns.google`, `cloudflare-dns.com`…) et du domaine canari
`use-application-dns.net` (désactive le DoH de Firefox) ;
- journal des requêtes conservé **7 jours** (consultable par le parent, à mentionner à l'enfant) ;
- interface d'administration sur 127.0.0.1:3003 uniquement, pilotée par familyd ; les règles de
blocage DoH portent `$important` (sinon les exceptions du filtre AdGuard par défaut l'emportent).
**Liste blanche (validé en phase 0)** : la fonction `ipset` d'AdGuard Home ne remplit que les
anciens jeux « ipset », illisibles par nftables. On utilise donc une petite instance **dnsmasq**
(`familyd-dnsmasq`, 127.0.0.1:5353, option `nftset=`) à laquelle AdGuard Home transmet les seuls
domaines de la liste blanche ; chaque adresse obtenue entre dans le jeu `allow4` (expiration 1 h,
renouvelée à chaque requête ; AdGuard Home plafonne son cache à 300 s pour que le renouvellement
ait lieu). Conséquence : `allow4` est **commun à tous les appareils** (union des listes blanches) ;
la liste propre à chaque appareil s'applique au niveau DNS (règles `$client`).
### 4.6 Service `familyd` (FastAPI + SQLite)
Même pile que ZoomPilot (déjà maîtrisée et déployée sur ce CT) : `/opt/familyd`, venv,
`/etc/familyd/familyd.env` (600), données `/var/lib/familyd`, unité systemd, utilisateur
`familyd`. Les opérations privilégiées (`wg set`, `nft`, `conntrack`) passent par un petit
assistant root appelé via `sudo` avec une liste de commandes fermée (pas de shell libre).
**Tâches de fond**
- toutes les 60 s : relevé `wg show wg1 transfer` + `latest-handshakes` → octets par appareil
(deltas stockés), « appareil en ligne » (dernière poignée de main < 3 min) ;
- moteur de règles : à chaque minute et à chaque changement, calcule le mode de chaque appareil
(consigne du parent, plage horaire, coupure cyclique sur données mobiles, quota) et l'applique
(nft + AdGuard) ;
- alertes vers **ntfy** (déjà sur le CT) : appareil hors ligne > 15 min, VPN coupé, protection
retirée, heure modifiée, quota atteint.
**Schéma SQLite (esquisse)**
| Table | Contenu |
|---|---|
| `parents` | id, e-mail, hachage argon2, créé le |
| `families` / `family_parents` | famille, parents associés |
| `devices` | id (uuid), famille, nom, IP tunnel, clé publique WG, hachage du jeton, créé le, dissocié le |
| `pairing_codes` | code (8 car.), famille, appareil prévu, expire le, utilisé le |
| `policies` | appareil, mode manuel, plages horaires, quota, coupure cyclique, services bloqués, liste blanche, règles d'applications (JSON), version |
| `device_state` | appareil, batterie, position, réseau (mobile/Wi-Fi), version de l'appli, Device Owner actif, vu le |
| `traffic` | appareil, jour, octets mobile, octets Wi-Fi (attribués selon le réseau déclaré) |
| `usage` | appareil, jour, temps d'écran total et par application |
| `events` | appareil, type, détail, horodatage |
**API (esquisse)**
| Route | Qui | Rôle |
|---|---|---|
| `POST /api/parent/login` | parent | jeton de session |
| `GET/PUT /api/parent/devices/{id}/policy` | parent | lire / modifier les consignes (versionnées) |
| `POST /api/parent/devices/{id}/pairing-code` | parent | créer un code d'appairage (10 min, usage unique) |
| `POST /api/parent/devices/{id}/bonus` | parent | temps supplémentaire du jour |
| `GET /api/parent/devices/{id}/{state,traffic,usage,events,dns-log}` | parent | suivi |
| `WS /api/parent/ws` | parent | changements en direct |
| `POST /api/pair` | appareil (public, limité en débit) | code + clé publique → IP tunnel, clé serveur, point de terminaison, jeton |
| `GET /api/device/policy` | appareil (tunnel) | consignes en vigueur |
| `POST /api/device/{state,usage,events}` | appareil (tunnel) | remontées |
| `WS /api/device/ws` | appareil (tunnel) | consignes poussées en direct |
| `GET /api/device/time` | appareil (tunnel) | heure de référence (horloge de confiance) |
**Authentification**
- parent : mot de passe (argon2) + jeton de session ;
- appareil : jeton remis à l'appairage (stocké haché) **et** adresse source 10.78.0.x qui doit
correspondre à l'appareil du jeton. Un jeton volé est inutilisable hors du tunnel de cet appareil ;
- `/api/pair` : 5 essais par minute et par IP, code invalidé après usage.
## 5. Appairage
```
Parent (FamilyControl) familyd Enfant (DataControl)
│ créer l'appareil ───────────►│ réserve 10.78.0.x
│◄── code ABCD2345 (10 min) ───│
│ affiche code + QR ··································►│ saisit le code (ou scanne)
│ │ │ génère sa paire de clés WG
│ │◄── POST /api/pair ─────│ (clé privée jamais transmise)
│ │ {code, clé publique} │
│ │ wg set wg1 peer … │
│ │ code consommé │
│ │──► {IP, clé serveur, fam-vpn.tuxtech.fr:51721, DNS, jeton}
│ │ │ monte le tunnel
│◄── WS : « appareil en ligne » │◄── 1re poignée de main ─│
```
- Le QR code contient l'URL `https://family.tuxtech.fr` et le code : rien à taper sur le téléphone
de l'enfant.
- La clé privée WireGuard est générée et stockée sur le téléphone (chiffrée par l'Android
Keystore) ; perdue = ré-appairage.
- Dissociation (par le parent) : pair retiré de `wg1`, jeton révoqué, mode `cut` jusqu'au retrait.
## 6. DataControl (téléphone de l'enfant) — v0.2
**Retiré**
- `FirewallVpnService` (trou noir) et la logique de liste blanche locale par application ;
- Firebase (`FirebaseGateway`, `RemoteSync`, dépendances `firebase-*`, écran de configuration
Firebase) — l'APK s'allège d'autant ;
- la coupure cyclique locale (devient une règle serveur).
**Ajouté**
- client WireGuard : bibliothèque officielle `com.wireguard.android:tunnel` (`GoBackend`, qui
fournit son propre `VpnService`) ; un seul VPN sur l'appareil, donc aucun conflit. Compter
quelques Mo par architecture pour la bibliothèque native ; ne livrer que `arm64-v8a` (et
`armeabi-v7a` si un vieil appareil l'exige) ;
- écran d'appairage (code ou QR) ;
- client API (HTTP + WebSocket sur 10.78.0.1:8100) avec file d'attente hors ligne pour les
remontées ;
- surveillance du tunnel : poignée de main trop ancienne → nouvelle tentative, alerte locale,
événement envoyé dès le retour.
**Conservé**
- service d'accessibilité et `GuardEngine` (blocage au lancement, temps d'écran) ; les règles
arrivent par l'API au lieu de Firestore ; l'horloge de confiance se recale sur
`/api/device/time` ;
- mot de passe local (verrouillage des réglages), journal, mises à jour Gitea ;
- suivi de consommation local (`TrafficStats`) à titre de contrôle croisé du compteur serveur.
**Type de réseau** : le téléphone déclare à chaque changement s'il est en données mobiles ou en
Wi-Fi ; le serveur s'en sert pour attribuer les octets (forfait) et pour les règles propres aux
données mobiles (coupure cyclique).
## 7. FamilyControl (parent) — v0.2
- `src/firebase/*` remplacé par un client de l'API familyd (`src/api/`) : même découpage
fonctionnel, pour garder les écrans ;
- écran de configuration : URL du serveur + compte parent au lieu de la configuration Firebase ;
- nouveaux écrans : mode en cours (libre / liste blanche / coupé) avec bascule immédiate, services
bloqués, journal DNS, consommation réelle mesurée par le serveur ;
- alertes : appli **ntfy** abonnée au sujet de la famille (ntfy exposé par Traefik en HTTPS, accès
refusé par défaut, un jeton par parent). Intégration directe dans FamilyControl plus tard ;
- **clé de signature** : FamilyControl est signé aujourd'hui avec la clé de débogage publique du
modèle React Native. La v0.2 est l'occasion de passer à une clé privée (une réinstallation
unique du téléphone parent).
## 8. Verrouillage du téléphone : mode Device Owner
Sans ce mode, l'enfant peut toujours couper le VPN dans les réglages Android (le serveur le voit
hors ligne et alerte le parent, sans pouvoir l'empêcher). Avec ce mode, DataControl impose :
| Politique | API Android |
|---|---|
| VPN toujours actif + blocage des connexions hors VPN | `setAlwaysOnVpnPackage(admin, pkg, lockdown = true, exemptions)` (API 29+ pour les exemptions) |
| Impossible de modifier les VPN | `addUserRestriction(DISALLOW_CONFIG_VPN)` |
| Impossible de changer date et heure | `DISALLOW_CONFIG_DATE_TIME` |
| Pas de réinitialisation ni de mode sans échec | `DISALLOW_FACTORY_RESET`, `DISALLOW_SAFE_BOOT` |
| DataControl indésinstallable | `setUninstallBlocked(admin, pkg, true)` |
| Pas d'autre utilisateur | `DISALLOW_ADD_USER` |
| Mises à jour silencieuses de DataControl | `PackageInstaller` (autorisé au Device Owner) |
**Mise en place** (une fois, câble USB) :
1. retirer temporairement le compte Google du téléphone (Android refuse sinon) ;
2. `adb shell dpm set-device-owner com.jttools.datacontrol/.admin.AppDeviceAdminReceiver` ;
3. remettre le compte Google.
**Précautions**
- **essayer d'abord sur un téléphone de test** ;
- prévoir dans DataControl « Retirer le mode propriétaire » (mot de passe + confirmation du
parent) : sans lui, seule une réinitialisation complète l'enlève ;
- `DISALLOW_DEBUGGING_FEATURES` n'est **pas** activé par défaut (garder adb pour la maintenance) ;
activable à distance par le parent ;
- **politique en cas de serveur injoignable** (choix du parent) :
- *fermé* : plus d'Internet tant que le serveur ne répond pas (sûr, mais une panne du CT prive
l'enfant d'Internet) ;
- *ouvert après délai* (recommandé) : au bout de 15 min sans poignée de main, DataControl lève
le blocage hors VPN (`lockdown = false`), garde le blocage des applications au lancement, et
signale l'incident dès le retour du serveur.
- applications exemptées du VPN (liste du parent) : téléphone et messagerie de l'opérateur,
appels d'urgence.
## 9. Exploitation
- **Disponibilité** : redémarrage automatique du CT (`onboot`), `Restart=on-failure` pour familyd
et AdGuard Home, sonde de santé (`/api/health`) surveillée, alerte ntfy si familyd ou `wg1`
tombe.
- **Sauvegarde** : `sqlite3 familyd.db ".backup …"` quotidien (jamais `cp`), clé privée du
serveur et `wg1.conf`, configuration AdGuard Home ; intégration aux sauvegardes existantes de
pve-home.
- **Débit** : tout le trafic du téléphone (vidéos comprises) passe par la Freebox deux fois
(entrée + sortie). Mesurer en phase 0 ; la fibre Free est en général largement suffisante.
- **Batterie** : WireGuard est le VPN le plus sobre ; `PersistentKeepalive = 25` coûte un peu,
à mesurer sur une journée en phase 0.
- **Vie privée** : le journal DNS et la position sont des données sur un mineur ; durée de
conservation courte (7 jours DNS, 30 jours position), accès réservé aux parents de la famille.
## 10. Migration depuis Firebase
Une seule famille et un ou deux appareils : **bascule franche** plutôt que fonctionnement mixte.
1. Serveur en place et testé (phases 0–1).
2. Publier FamilyControl v0.2 et DataControl v0.2 (sans Firebase).
3. Mettre à jour les deux téléphones, ré-appairer l'enfant (QR code).
4. Vérifier une journée complète (coupures, horaires, quota, alertes).
5. Supprimer le projet Firebase. L'historique Firestore n'est pas repris (option : script d'export
ponctuel si tu veux garder l'historique de trafic).
Tant que la v0.2 n'est pas installée, la v0.1.x continue de fonctionner avec Firebase.
## 11. Phases
| Phase | Contenu | Critère de fin |
|---|---|---|
| **0. Essai** (≈ 1 j) | `wg1` à la main + appli WireGuard officielle sur un téléphone de test ; AdGuard Home ; essais nft `open` / `cut` / `allowlist` | Débit et batterie mesurés ; hairpin Freebox vérifié ; mode liste blanche validé (AdGuard `ipset` ou repli dnsmasq) |
| **1. Serveur** | familyd (API, moteur de règles, compteurs, alertes), assistant root, déploiement documenté, tests pytest | Appairage, changement de mode et quota testés de bout en bout avec un pair manuel |
| **2. DataControl v0.2** | Client WireGuard, appairage, client API, suppression trou noir + Firebase | Téléphone de test piloté uniquement par familyd pendant 48 h |
| **3. FamilyControl v0.2** | Client API, nouveaux écrans, clé de signature privée | Toutes les fonctions actuelles disponibles sans Firebase |
| **4. Device Owner** | Politiques §8, provisionnement, sortie du mode | VPN impossible à couper sur le téléphone de test ; reprise après panne serveur conforme à la politique choisie |
| **5. Option** | Filtrage réseau par application sur le téléphone : couche Go entre l'interface du VPN et wireguard-go, identification de l'application par `getConnectionOwnerUid` (API 29+) | Une application précise coupée sans couper le reste |
## 12. Phase 0 — état au 2026-10-25
**Réalisé sur le CT 233** (sauvegarde des règles et configurations d'origine :
`/root/backup-familyd-phase0/`) :
- CT agrandi : 2 cœurs, 1,5 Go, disque 20 Go ;
- `wg1` 10.78.0.1/24, UDP 51721 (`wg-quick@wg1`), pair de test 10.78.0.10 ;
- `/etc/nftables.conf` : ouvertures minimales pour `wg1` dans `table inet filter` + `include
"/etc/nftables.d/*.nft"` ; `table inet familyd` dans `/etc/nftables.d/familyd.nft` ;
- AdGuard Home v0.107.79 (`/opt/AdGuardHome`, DNS 10.78.0.1:53, admin 127.0.0.1:3003, mot de
passe `/root/adguard-admin.password`), client « test-phase0 » avec TikTok bloqué ;
- `familyd-dnsmasq` (liste blanche → `allow4`) ;
- `fam-mode IP open|allowlist|cut` et `fam-mode list` en attendant familyd ;
- fichiers de travail : `/root/familyd-phase0/` (config et QR du téléphone de test,
`famsim-test.sh` : faux téléphone en espace réseau isolé pour rejouer les tests).
**Tests passés avec le faux téléphone** : les trois modes se comportent comme prévu ; DNS forcé
(une requête « vers 8.8.8.8 » est servie par le DNS filtrant) ; DoH par IP et DoT bloqués ; LAN,
ZoomPilot et ntfy injoignables depuis le tunnel ; ZoomPilot, ntfy et `wg0` inchangés.
**Reste à mesurer avec un vrai téléphone** : redirection Freebox UDP 51721 (en 4G), retour en
boucle sur le Wi-Fi de la maison, débit, batterie sur une journée, blocage TikTok, applications
qui contournent le DNS.
## 13. Questions ouvertes
1. **Politique en cas de serveur injoignable** : *fermé* ou *ouvert après 15 min* (recommandé) ?
2. **Device Owner** : d'accord pour retirer temporairement le compte Google du téléphone de
l'enfant (ou le réinitialiser) ? Existe-t-il un téléphone de test ?
3. **CT dédié** plutôt que le CT 233 partagé avec ZoomPilot ?
4. **Coupure cyclique** (toutes les X minutes sur données mobiles) : la garder dans le nouveau
modèle, ou la remplacer par le quota et les plages horaires ?
5. **Exemptions** du VPN : quelles applications doivent toujours passer (opérateur, banque…) ?
+4
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"
@@ -16,10 +17,12 @@ hiltNavigationCompose = "1.2.0"
datastore = "1.1.1" datastore = "1.1.1"
coroutines = "1.8.1" coroutines = "1.8.1"
workManager = "2.9.1" workManager = "2.9.1"
wireguardTunnel = "1.0.20230706"
[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" }
@@ -42,6 +45,7 @@ androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navig
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" } androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" }
wireguard-tunnel = { group = "com.wireguard.android", name = "tunnel", version.ref = "wireguardTunnel" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
+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.2 versionName=0.10.0
versionCode=3 versionCode=20