Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f33776bbb5 | ||
|
|
eecad06eea | ||
|
|
d7e55dbb91 | ||
|
|
790397da0e | ||
|
|
6086e8aeff | ||
|
|
82d47fcd72 | ||
|
|
c63cc45c57 |
No files matched your search
@@ -16,3 +16,6 @@ local.properties
|
||||
# Jeton Gitea (lecture/écriture) pour "make publish"/"make deploy" : local,
|
||||
# jamais versionné.
|
||||
gitea-token.properties
|
||||
|
||||
# Configuration Firebase locale (clés du projet).
|
||||
firebase.properties
|
||||
@@ -1,5 +1,45 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
- Mise à jour depuis les Releases Gitea : bouton « Rechercher une mise à jour » dans l'onglet À propos (téléchargement + installation de l'APK).
|
||||
- Notification : le total estimé apparaît aussi dans la vue repliée.
|
||||
- Pronote (`com.indexeducation.pronote`) est dans la liste blanche par défaut (données mobiles et Wi-Fi).
|
||||
- Réinitialisation unique du mot de passe de verrouillage au prochain lancement.
|
||||
|
||||
## v0.1.1 – 2026-08-30
|
||||
|
||||
- `make publish`/`make deploy` : publie l'APK signé comme asset d'une Release Gitea (tag + notes extraites du CHANGELOG), sur le même modèle que les autres apps Android du même auteur.
|
||||
|
||||
@@ -15,38 +15,54 @@ GITEA_REPO_NAME := DataControl
|
||||
GITEA_TOKEN ?= $(shell sed -n 's/^giteaToken=//p' gitea-token.properties 2>/dev/null | tr -d '[:space:]')
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: help build release install push version-bump changelog clean tag _check-token _check-apk publish deploy
|
||||
|
||||
## help: Show this help message
|
||||
help:
|
||||
@echo "DataControl v$(VERSION_NAME) (build $(VERSION_CODE))"
|
||||
@echo ""
|
||||
@echo "Available targets:"
|
||||
@grep -E '^## [a-z]' Makefile | sed 's/## / make /' | column -t -s ':'
|
||||
BOLD := \033[1m
|
||||
DIM := \033[2m
|
||||
CYAN := \033[36m
|
||||
GREEN := \033[32m
|
||||
YELLOW := \033[33m
|
||||
RESET := \033[0m
|
||||
|
||||
help:
|
||||
@printf "\n"
|
||||
@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"
|
||||
@printf "\n"
|
||||
@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-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:
|
||||
@echo "[BUILD] Building debug APK..."
|
||||
@printf "$(YELLOW)🔨 Construction de l'APK debug...$(RESET)\n"
|
||||
$(GRADLEW) assembleDebug
|
||||
@echo "[BUILD] Done: $(APK_DEBUG)"
|
||||
|
||||
## release: Build release APK and copy to project root
|
||||
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
|
||||
@cp $(APK_RELEASE) $(OUTPUT_APK)
|
||||
@echo "[RELEASE] Done: $(OUTPUT_APK)"
|
||||
|
||||
## install: Install debug APK on connected device
|
||||
install:
|
||||
@echo "[INSTALL] Installing debug APK..."
|
||||
@printf "$(YELLOW)📲 Installation de l'APK debug...$(RESET)\n"
|
||||
$(GRADLEW) installDebug
|
||||
@echo "[INSTALL] Done."
|
||||
|
||||
## push: Git add, commit, tag and push (uses current version)
|
||||
push:
|
||||
@echo "[PUSH] Committing and pushing v$(VERSION_NAME)..."
|
||||
@printf "$(YELLOW)📤 Commit et push de v$(VERSION_NAME)...$(RESET)\n"
|
||||
git add -A
|
||||
git commit -m "Release v$(VERSION_NAME) (build $(VERSION_CODE))"
|
||||
git tag -a "v$(VERSION_NAME)" -m "Release v$(VERSION_NAME)"
|
||||
@@ -54,8 +70,8 @@ push:
|
||||
git push origin "v$(VERSION_NAME)"
|
||||
@echo "[PUSH] Done."
|
||||
|
||||
## tag: Create and push the git tag for the current version
|
||||
tag:
|
||||
@printf "$(YELLOW)📌 Tag git v$(VERSION_NAME)...$(RESET)\n"
|
||||
@if git rev-parse "v$(VERSION_NAME)" >/dev/null 2>&1; then \
|
||||
echo "Tag v$(VERSION_NAME) already exists, skipping"; \
|
||||
else \
|
||||
@@ -76,9 +92,8 @@ _check-apk:
|
||||
@test $(OUTPUT_APK) -nt version.properties || \
|
||||
(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
|
||||
@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)" \
|
||||
"$(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); \
|
||||
@@ -113,11 +128,11 @@ publish: _check-apk _check-token tag
|
||||
echo ""; \
|
||||
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
|
||||
@printf "$(GREEN)🚢 Déploiement terminé.$(RESET)\n"
|
||||
|
||||
## version-bump: Increment versionCode and update versionName patch
|
||||
version-bump:
|
||||
@printf "$(YELLOW)🔖 Incrémentation de la version...$(RESET)\n"
|
||||
@NEW_CODE=$$(( $(VERSION_CODE) + 1 )); \
|
||||
PATCH=$$(echo $(VERSION_NAME) | cut -d. -f3); \
|
||||
MAJOR=$$(echo $(VERSION_NAME) | cut -d. -f1); \
|
||||
@@ -128,9 +143,8 @@ version-bump:
|
||||
sed -i "s/versionCode=.*/versionCode=$${NEW_CODE}/" version.properties; \
|
||||
echo "[VERSION-BUMP] Bumped to $${NEW_VERSION} (build $${NEW_CODE})"
|
||||
|
||||
## changelog: Generate a changelog entry from git log since last tag
|
||||
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 ""); \
|
||||
if [ -z "$$LAST_TAG" ]; then \
|
||||
git log --oneline --pretty=format:"- %s" >> CHANGELOG.md; \
|
||||
@@ -141,9 +155,8 @@ changelog:
|
||||
fi; \
|
||||
echo "[CHANGELOG] Done."
|
||||
|
||||
## clean: Clean build artifacts
|
||||
clean:
|
||||
@echo "[CLEAN] Cleaning build artifacts..."
|
||||
@printf "$(YELLOW)🧽 Nettoyage des artefacts de build...$(RESET)\n"
|
||||
$(GRADLEW) clean
|
||||
@rm -f DataControl-*.apk
|
||||
@echo "[CLEAN] Done."
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**DataControl** est une application Android qui surveille en arrière-plan la consommation de données mobiles et coupe automatiquement l'accès à Internet en cas de dépassement du forfait — sans jamais toucher au Wi-Fi.
|
||||
|
||||
> JT-Tools by Johnny — v0.1.0
|
||||
> JT-Tools by Johnny — version courante et historique : voir [CHANGELOG.md](CHANGELOG.md) (aussi lisible dans l'onglet *À propos* de l'application).
|
||||
|
||||
---
|
||||
|
||||
@@ -16,7 +16,23 @@
|
||||
- **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".
|
||||
- **Notification permanente** — Affiche la consommation (Mo / forfait / %), l'état des données avec un code couleur (🟢 sous le forfait, 🟠 proche du forfait, 🔴 hors-forfait/coupure, 🔵 Wi-Fi actif), et le coût estimé. Rafraîchie toutes les 20 secondes et à chaque changement de réseau.
|
||||
- **Démarrage au boot** — Le service redémarre automatiquement après un redémarrage de l'appareil si le contrôle était actif.
|
||||
- **Interface Material 3** — Thème clair/sombre avec couleurs dynamiques (Android 12+), 3 écrans : Tableau de bord, Réglages, Liste blanche.
|
||||
- **Alerte pare-feu inactif** — Si le contrôle est actif sur données mobiles mais que le tunnel VPN n'est pas monté, la notification passe en rouge (« PARE-FEU INACTIF ») : les données ne sont alors pas filtrées.
|
||||
- **Journal de diagnostic** — Onglet *Journal* : état du VPN en direct, décisions du pare-feu, et, avec le service d'accessibilité, chaque application utilisée avec son verdict réseau/lancement. Conservé 15 jours.
|
||||
- **Mise à jour intégrée** — Recherche et installation de la dernière Release Gitea depuis l'onglet *À propos*.
|
||||
- **Mot de passe de verrouillage** — Protège les Réglages et la désactivation du contrôle.
|
||||
- **Interface Material 3** — Thème clair/sombre avec couleurs dynamiques (Android 12+), 5 onglets : Tableau de bord, Réglages, Liste blanche, Journal, À propos.
|
||||
|
||||
---
|
||||
|
||||
## Contrôle parental (avec FamilyControl)
|
||||
|
||||
DataControl peut être associé à **FamilyControl** (application parent en TypeScript/Expo, dépôt séparé) via Firebase (Auth + Firestore). La configuration du projet Firebase se **colle dans l'application** (*Réglages → Contrôle parental → Configuration Firebase*), sans recompiler ; sans elle, ces fonctions sont simplement désactivées (un `firebase.properties` à la compilation reste possible comme valeur par défaut).
|
||||
|
||||
- **Appairage** par code à usage court, saisi dans *Réglages → Contrôle parental*.
|
||||
- **Remontées** : batterie, position, état du pare-feu, consommation, trafic quotidien mobile/Wi-Fi, temps d'écran, événements (VPN révoqué, protection retirée…).
|
||||
- **Consignes du parent** : contrôle activé/désactivé, applications autorisées (mobile et Wi-Fi), filtrage Wi-Fi.
|
||||
- **Règles d'usage** (service d'accessibilité) : plages horaires (avec coupure optionnelle des données mobiles hors plage), temps d'écran quotidien global ou par application, ajout de temps, applications interdites. Le lanceur, le téléphone, le clavier et DataControl ne sont jamais bloqués.
|
||||
- **Anti-désinstallation** par administrateur d'appareil (retirable depuis les Réglages Android : le parent est alors alerté ; le mot de passe protège le retrait depuis l'application).
|
||||
|
||||
---
|
||||
|
||||
@@ -44,6 +60,9 @@ Le pare-feu est démarré/arrêté automatiquement selon le transport réseau ac
|
||||
| Ignorer l'optimisation de la batterie | Éviter que le système ne tue le service de surveillance |
|
||||
| `QUERY_ALL_PACKAGES` | Lister toutes les applications installées dans l'écran Liste blanche |
|
||||
| `RECEIVE_BOOT_COMPLETED` | Relancer la surveillance après redémarrage si le contrôle était actif |
|
||||
| Position (dont arrière-plan) | Facultatif — transmise au parent si l'appareil est associé |
|
||||
| Administrateur d'appareil | Facultatif — anti-désinstallation |
|
||||
| Service d'accessibilité | Facultatif — détecte l'application au premier plan (sans lire les écrans) pour les règles d'usage et le journal |
|
||||
|
||||
---
|
||||
|
||||
@@ -60,7 +79,10 @@ Le pare-feu est démarré/arrêté automatiquement selon le transport réseau ac
|
||||
- **Kotlin + Jetpack Compose (Material 3)**, Hilt pour l'injection de dépendances.
|
||||
- `data/` — `SettingsRepository` (DataStore Preferences), `UsageRepository` (TrafficStats), `InstalledAppsRepository` (PackageManager).
|
||||
- `service/` — `FirewallVpnService` (VpnService), `ConnectivityObserver` (détection Wi-Fi/cellulaire), `DataMonitorService` (orchestration + notification), `MonitorStateHolder` (pont service ↔ UI), `BootReceiver`.
|
||||
- `ui/` — écrans Compose (`dashboard`, `settings`, `whitelist`), navigation par `NavigationBar`, thème dynamique.
|
||||
- `remote/` — `FirebaseGateway`, `RemoteSync` (appairage, consignes, remontées), `DeviceReporter` (batterie, position, trafic, état).
|
||||
- `guard/` — `GuardEngine` (règles d'usage, temps d'écran), `AppGuardService` (accessibilité), `BlockedActivity`.
|
||||
- `admin/` — administrateur d'appareil (anti-désinstallation). `log/` — journal de diagnostic.
|
||||
- `ui/` — écrans Compose (`dashboard`, `settings`, `whitelist`, `journal`, `about`, `family`), navigation par `NavigationBar`, thème dynamique.
|
||||
|
||||
---
|
||||
|
||||
@@ -70,6 +92,7 @@ Le pare-feu est démarré/arrêté automatiquement selon le transport réseau ac
|
||||
make build # APK debug
|
||||
make release # APK release signé, copié en DataControl-<version>.apk
|
||||
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.
|
||||
|
||||
@@ -9,6 +9,13 @@ plugins {
|
||||
alias(libs.plugins.kotlin.kapt)
|
||||
}
|
||||
|
||||
// Configuration Firebase (liaison distante avec FamilyControl) : fichier local non versionné.
|
||||
// Sans ce fichier l'app se compile normalement, mais l'appairage/le contrôle à distance sont désactivés.
|
||||
val firebaseProps = Properties().apply {
|
||||
val f = rootProject.file("firebase.properties")
|
||||
if (f.exists()) f.inputStream().use { load(it) }
|
||||
}
|
||||
|
||||
val versionProps = Properties().apply {
|
||||
load(rootProject.file("version.properties").inputStream())
|
||||
}
|
||||
@@ -29,6 +36,9 @@ android {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
|
||||
buildConfigField("String", "FIREBASE_API_KEY", "\"${firebaseProps.getProperty("apiKey", "")}\"")
|
||||
buildConfigField("String", "FIREBASE_APP_ID", "\"${firebaseProps.getProperty("appId", "")}\"")
|
||||
buildConfigField("String", "FIREBASE_PROJECT_ID", "\"${firebaseProps.getProperty("projectId", "")}\"")
|
||||
buildConfigField("String", "BUILD_DATE", "\"${SimpleDateFormat("yyyy-MM-dd").format(Date())}\"")
|
||||
}
|
||||
|
||||
@@ -68,6 +78,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 {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
@@ -87,6 +109,10 @@ dependencies {
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
implementation(platform(libs.firebase.bom))
|
||||
implementation(libs.firebase.auth)
|
||||
implementation(libs.firebase.firestore)
|
||||
implementation(libs.kotlinx.coroutines.play.services)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<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_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" -->
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
@@ -49,6 +54,51 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<!-- FileProvider pour installer l'APK de mise à jour téléchargé -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- Limitation du lancement des applications (détection de l'application au premier plan). -->
|
||||
<service
|
||||
android:name=".guard.AppGuardService"
|
||||
android:exported="true"
|
||||
android:label="@string/guard_service_label"
|
||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accessibilityservice"
|
||||
android:resource="@xml/accessibility_guard" />
|
||||
</service>
|
||||
|
||||
<activity
|
||||
android:name=".guard.BlockedActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.DataControl" />
|
||||
|
||||
<!-- Administrateur d'appareil : bloque la désinstallation tant qu'il est actif. -->
|
||||
<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" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".service.BootReceiver"
|
||||
android:exported="true"
|
||||
|
||||
@@ -4,15 +4,31 @@ import android.app.Application
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import com.jttools.datacontrol.data.SettingsRepository
|
||||
import com.jttools.datacontrol.service.DataMonitorRestartWorker
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidApp
|
||||
class DataControlApp : Application() {
|
||||
|
||||
@Inject lateinit var settingsRepository: SettingsRepository
|
||||
@Inject lateinit var firebaseGateway: com.jttools.datacontrol.remote.FirebaseGateway
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
com.jttools.datacontrol.log.AppLog.init(this)
|
||||
CoroutineScope(Dispatchers.IO).launch { settingsRepository.resetPasswordOnce() }
|
||||
// La config Firebase se saisit dans l'appli : on l'applique au démarrage et à chaque modification.
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
settingsRepository.settingsFlow.map { it.firebase }.distinctUntilChanged().collect { firebaseGateway.apply(it) }
|
||||
}
|
||||
// Filet de sécurité : relance périodiquement DataMonitorService s'il a été tué en arrière-plan.
|
||||
// enqueueUniquePeriodicWork + KEEP : sans effet si déjà planifié, donc sûr à appeler à chaque démarrage.
|
||||
val request = PeriodicWorkRequestBuilder<DataMonitorRestartWorker>(15, TimeUnit.MINUTES).build()
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
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."
|
||||
)
|
||||
|
||||
/** À 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,27 @@
|
||||
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
|
||||
|
||||
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.
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
remoteSync.pushEvent("admin_disabled", "Protection anti-désinstallation désactivée")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,23 +7,34 @@ import java.time.LocalDate
|
||||
*/
|
||||
data class AppSettings(
|
||||
val controlEnabled: Boolean = false,
|
||||
val planLimitMo: Long = 5000L,
|
||||
val planLimitMo: Long = 1000L,
|
||||
val planPriceEuro: Double = 19.99,
|
||||
val overagePricePerMoEuro: Double = 0.01,
|
||||
val planStartDateEpochDay: Long = LocalDate.now().toEpochDay(),
|
||||
val autoResetEnabled: Boolean = true,
|
||||
val cutoffIntervalMinutes: Int = 15,
|
||||
val baselineMobileBytesAtReset: Long = 0L,
|
||||
val whitelistedPackages: Set<String> = emptySet(),
|
||||
val whitelistedPackagesWifi: Set<String> = emptySet(),
|
||||
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 passwordSalt: String? = null,
|
||||
val cutoffCycleAnchorMillis: Long? = null,
|
||||
val cutoffInPulse: Boolean = false,
|
||||
val cutoffPulseStartMillis: Long = 0L
|
||||
val cutoffPulseStartMillis: Long = 0L,
|
||||
/** Appairage avec FamilyControl (null tant que l'appareil n'est pas associé). */
|
||||
val familyId: String? = null,
|
||||
val deviceId: String? = null,
|
||||
/** Horodatage (ms) de la dernière configuration distante appliquée. */
|
||||
val remoteConfigVersion: Long = 0L,
|
||||
/** Règles d'usage (plages horaires, limites de temps, applications interdites), au format JSON. */
|
||||
val rulesJson: String? = null,
|
||||
/** Configuration Firebase saisie dans l'application (null : voir firebase.properties de compilation). */
|
||||
val firebase: com.jttools.datacontrol.remote.FirebaseConfig? = null
|
||||
) {
|
||||
val isPaired: Boolean get() = !familyId.isNullOrEmpty() && !deviceId.isNullOrEmpty()
|
||||
|
||||
val 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. */
|
||||
@@ -38,6 +49,13 @@ data class AppSettings(
|
||||
}
|
||||
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. */
|
||||
@@ -52,6 +70,9 @@ data class UsageState(
|
||||
get() = if (planLimitMo <= 0) 0f else (consumedMo.toFloat() / planLimitMo.toFloat()).coerceAtLeast(0f)
|
||||
}
|
||||
|
||||
/** Trafic cumulé d'une journée, pour l'historique envoyé à FamilyControl. */
|
||||
data class TrafficDay(val date: String, val mobileBytes: Long, val wifiBytes: Long)
|
||||
|
||||
enum class DataState {
|
||||
OK, // sous le seuil d'alerte, liste blanche active, pas de coupure en cours
|
||||
WARNING, // proche ou au-delà du forfait (>= 80%), liste blanche active, pas de coupure en cours
|
||||
|
||||
@@ -14,7 +14,7 @@ class InstalledAppsRepository @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
@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 ownPackage = context.packageName
|
||||
pm.getInstalledApplications(PackageManager.GET_META_DATA)
|
||||
@@ -24,7 +24,7 @@ class InstalledAppsRepository @Inject constructor(
|
||||
AppInfo(
|
||||
packageName = appInfo.packageName,
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -38,9 +38,24 @@ class SettingsRepository @Inject constructor(
|
||||
val SHOW_SYSTEM_APPS = booleanPreferencesKey("show_system_apps")
|
||||
val PASSWORD_HASH = stringPreferencesKey("password_hash")
|
||||
val PASSWORD_SALT = stringPreferencesKey("password_salt")
|
||||
val PASSWORD_RESET_V1_DONE = booleanPreferencesKey("password_reset_v1_done")
|
||||
val CUTOFF_ANCHOR_MILLIS = longPreferencesKey("cutoff_anchor_millis")
|
||||
val CUTOFF_IN_PULSE = booleanPreferencesKey("cutoff_in_pulse")
|
||||
val CUTOFF_PULSE_START_MILLIS = longPreferencesKey("cutoff_pulse_start_millis")
|
||||
val FAMILY_ID = stringPreferencesKey("family_id")
|
||||
val DEVICE_ID = stringPreferencesKey("device_id")
|
||||
val REMOTE_CONFIG_VERSION = longPreferencesKey("remote_config_version")
|
||||
val RULES_JSON = stringPreferencesKey("rules_json")
|
||||
val FIREBASE_API_KEY = stringPreferencesKey("firebase_api_key")
|
||||
val FIREBASE_APP_ID = stringPreferencesKey("firebase_app_id")
|
||||
val FIREBASE_PROJECT_ID = stringPreferencesKey("firebase_project_id")
|
||||
val USAGE_DAY = stringPreferencesKey("usage_day")
|
||||
val USAGE_JSON = stringPreferencesKey("usage_json")
|
||||
val TRAFFIC_DAY = stringPreferencesKey("traffic_day")
|
||||
val TRAFFIC_MOBILE_ACC = longPreferencesKey("traffic_mobile_acc")
|
||||
val TRAFFIC_WIFI_ACC = longPreferencesKey("traffic_wifi_acc")
|
||||
val TRAFFIC_LAST_MOBILE = longPreferencesKey("traffic_last_mobile")
|
||||
val TRAFFIC_LAST_OTHER = longPreferencesKey("traffic_last_other")
|
||||
}
|
||||
|
||||
val settingsFlow: Flow<AppSettings> = context.dataStore.data.map { prefs ->
|
||||
@@ -62,7 +77,16 @@ class SettingsRepository @Inject constructor(
|
||||
passwordSalt = prefs[Keys.PASSWORD_SALT],
|
||||
cutoffCycleAnchorMillis = prefs[Keys.CUTOFF_ANCHOR_MILLIS],
|
||||
cutoffInPulse = prefs[Keys.CUTOFF_IN_PULSE] ?: defaults.cutoffInPulse,
|
||||
cutoffPulseStartMillis = prefs[Keys.CUTOFF_PULSE_START_MILLIS] ?: defaults.cutoffPulseStartMillis
|
||||
cutoffPulseStartMillis = prefs[Keys.CUTOFF_PULSE_START_MILLIS] ?: defaults.cutoffPulseStartMillis,
|
||||
familyId = prefs[Keys.FAMILY_ID],
|
||||
deviceId = prefs[Keys.DEVICE_ID],
|
||||
remoteConfigVersion = prefs[Keys.REMOTE_CONFIG_VERSION] ?: defaults.remoteConfigVersion,
|
||||
rulesJson = prefs[Keys.RULES_JSON],
|
||||
firebase = run {
|
||||
val k = prefs[Keys.FIREBASE_API_KEY]; val a = prefs[Keys.FIREBASE_APP_ID]; val p = prefs[Keys.FIREBASE_PROJECT_ID]
|
||||
if (k.isNullOrBlank() || a.isNullOrBlank() || p.isNullOrBlank()) null
|
||||
else com.jttools.datacontrol.remote.FirebaseConfig(k, a, p)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -152,6 +176,20 @@ class SettingsRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Réinitialisation unique (v0.1.2) du mot de passe de verrouillage : le supprime une seule fois,
|
||||
* puis mémorise qu'elle a eu lieu pour ne pas effacer un mot de passe défini ensuite.
|
||||
*/
|
||||
suspend fun resetPasswordOnce() {
|
||||
context.dataStore.edit {
|
||||
if (it[Keys.PASSWORD_RESET_V1_DONE] != true) {
|
||||
it.remove(Keys.PASSWORD_HASH)
|
||||
it.remove(Keys.PASSWORD_SALT)
|
||||
it[Keys.PASSWORD_RESET_V1_DONE] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Vrai si aucun mot de passe n'est défini, ou si [password] correspond au mot de passe stocké. */
|
||||
suspend fun verifyPassword(password: String): Boolean {
|
||||
val prefs = context.dataStore.data.first()
|
||||
@@ -172,4 +210,101 @@ class SettingsRepository @Inject constructor(
|
||||
prefs[Keys.CUTOFF_PULSE_START_MILLIS] = pulseStartMillis
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setPairing(familyId: String?, deviceId: String?) {
|
||||
context.dataStore.edit {
|
||||
if (familyId == null || deviceId == null) {
|
||||
it.remove(Keys.FAMILY_ID)
|
||||
it.remove(Keys.DEVICE_ID)
|
||||
it.remove(Keys.REMOTE_CONFIG_VERSION)
|
||||
it.remove(Keys.RULES_JSON)
|
||||
} else {
|
||||
it[Keys.FAMILY_ID] = familyId
|
||||
it[Keys.DEVICE_ID] = deviceId
|
||||
it[Keys.REMOTE_CONFIG_VERSION] = 0L
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique une configuration envoyée par FamilyControl. Chaque champ null est ignoré.
|
||||
* [version] (horodatage de l'écriture côté parent) évite de ré-appliquer une ancienne
|
||||
* configuration au redémarrage et d'écraser un réglage local plus récent.
|
||||
*/
|
||||
suspend fun applyRemoteConfig(
|
||||
version: Long,
|
||||
controlEnabled: Boolean?,
|
||||
whitelistedPackages: Set<String>?,
|
||||
whitelistedPackagesWifi: Set<String>?,
|
||||
wifiFilterEnabled: Boolean?,
|
||||
planLimitMo: Long?,
|
||||
cutoffIntervalMinutes: Int?,
|
||||
rulesJson: String?
|
||||
) {
|
||||
context.dataStore.edit { prefs ->
|
||||
if ((prefs[Keys.REMOTE_CONFIG_VERSION] ?: 0L) >= version) return@edit
|
||||
rulesJson?.let { prefs[Keys.RULES_JSON] = it }
|
||||
prefs[Keys.REMOTE_CONFIG_VERSION] = version
|
||||
controlEnabled?.let { prefs[Keys.CONTROL_ENABLED] = it }
|
||||
whitelistedPackages?.let { prefs[Keys.WHITELISTED_PACKAGES] = it }
|
||||
whitelistedPackagesWifi?.let { prefs[Keys.WHITELISTED_PACKAGES_WIFI] = it }
|
||||
wifiFilterEnabled?.let { prefs[Keys.WIFI_FILTER_ENABLED] = it }
|
||||
planLimitMo?.takeIf { it > 0 }?.let { prefs[Keys.PLAN_LIMIT_MO] = it }
|
||||
cutoffIntervalMinutes?.takeIf { it > 0 }?.let { prefs[Keys.CUTOFF_INTERVAL_MINUTES] = it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cumule le trafic de la journée à partir des compteurs système (qui repartent de zéro au
|
||||
* redémarrage : un compteur inférieur au précédent est traité comme un redémarrage).
|
||||
* [otherBytes] = trafic non mobile (Wi-Fi, etc.) = total - mobile.
|
||||
*/
|
||||
suspend fun accumulateTraffic(today: String, mobileBytes: Long, otherBytes: Long): TrafficDay {
|
||||
var result = TrafficDay(today, 0L, 0L)
|
||||
context.dataStore.edit { prefs ->
|
||||
val sameDay = prefs[Keys.TRAFFIC_DAY] == today
|
||||
var mobileAcc = if (sameDay) prefs[Keys.TRAFFIC_MOBILE_ACC] ?: 0L else 0L
|
||||
var wifiAcc = if (sameDay) prefs[Keys.TRAFFIC_WIFI_ACC] ?: 0L else 0L
|
||||
val lastMobile = prefs[Keys.TRAFFIC_LAST_MOBILE]
|
||||
val lastOther = prefs[Keys.TRAFFIC_LAST_OTHER]
|
||||
if (lastMobile != null && lastOther != null) {
|
||||
mobileAcc += if (mobileBytes >= lastMobile) mobileBytes - lastMobile else mobileBytes
|
||||
wifiAcc += if (otherBytes >= lastOther) otherBytes - lastOther else otherBytes
|
||||
}
|
||||
prefs[Keys.TRAFFIC_DAY] = today
|
||||
prefs[Keys.TRAFFIC_MOBILE_ACC] = mobileAcc
|
||||
prefs[Keys.TRAFFIC_WIFI_ACC] = wifiAcc
|
||||
prefs[Keys.TRAFFIC_LAST_MOBILE] = mobileBytes
|
||||
prefs[Keys.TRAFFIC_LAST_OTHER] = otherBytes
|
||||
result = TrafficDay(today, mobileAcc, wifiAcc)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun loadUsage(): Pair<String, String>? {
|
||||
val prefs = context.dataStore.data.first()
|
||||
val day = prefs[Keys.USAGE_DAY] ?: return null
|
||||
val json = prefs[Keys.USAGE_JSON] ?: return null
|
||||
return day to json
|
||||
}
|
||||
|
||||
suspend fun saveUsage(day: String, json: String) {
|
||||
context.dataStore.edit {
|
||||
it[Keys.USAGE_DAY] = day
|
||||
it[Keys.USAGE_JSON] = json
|
||||
}
|
||||
}
|
||||
|
||||
/** Enregistre (ou efface, si null) la configuration Firebase saisie dans l'application. */
|
||||
suspend fun setFirebaseConfig(config: com.jttools.datacontrol.remote.FirebaseConfig?) {
|
||||
context.dataStore.edit {
|
||||
if (config == null) {
|
||||
it.remove(Keys.FIREBASE_API_KEY); it.remove(Keys.FIREBASE_APP_ID); it.remove(Keys.FIREBASE_PROJECT_ID)
|
||||
} else {
|
||||
it[Keys.FIREBASE_API_KEY] = config.apiKey
|
||||
it[Keys.FIREBASE_APP_ID] = config.appId
|
||||
it[Keys.FIREBASE_PROJECT_ID] = config.projectId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.jttools.datacontrol.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.core.content.FileProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* Mise à jour via les Releases Gitea du dépôt (dernière release publiée + son asset .apk).
|
||||
* Le dépôt est public : aucun jeton n'est nécessaire.
|
||||
*/
|
||||
class UpdateManager(private val context: Context, private val currentVersion: String) {
|
||||
|
||||
data class UpdateInfo(val versionName: String, val apkUrl: String, val changelog: String)
|
||||
|
||||
sealed interface CheckResult {
|
||||
data object UpToDate : CheckResult
|
||||
data class Available(val info: UpdateInfo) : CheckResult
|
||||
data class Error(val message: String) : CheckResult
|
||||
}
|
||||
|
||||
suspend fun check(): CheckResult = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val conn = open(RELEASES_URL)
|
||||
try {
|
||||
if (conn.responseCode != HttpURLConnection.HTTP_OK) {
|
||||
return@withContext CheckResult.Error("HTTP ${conn.responseCode}")
|
||||
}
|
||||
val json = JSONObject(conn.inputStream.bufferedReader().use { it.readText() })
|
||||
val version = json.optString("tag_name").removePrefix("v")
|
||||
if (version.isBlank()) return@withContext CheckResult.Error("Release sans tag")
|
||||
|
||||
val assets = json.optJSONArray("assets")
|
||||
var apkUrl = ""
|
||||
if (assets != null) {
|
||||
for (i in 0 until assets.length()) {
|
||||
val asset = assets.getJSONObject(i)
|
||||
if (asset.optString("name").endsWith(".apk")) {
|
||||
apkUrl = asset.optString("browser_download_url")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
when {
|
||||
!isNewer(version, currentVersion) -> CheckResult.UpToDate
|
||||
apkUrl.isBlank() -> CheckResult.Error("Aucun APK dans la release v$version")
|
||||
else -> CheckResult.Available(UpdateInfo(version, apkUrl, json.optString("body")))
|
||||
}
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
CheckResult.Error(e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
/** Télécharge l'APK dans le cache ; [onProgress] reçoit 0-100. Retourne le fichier ou lève une exception. */
|
||||
suspend fun download(info: UpdateInfo, onProgress: (Int) -> Unit): File = withContext(Dispatchers.IO) {
|
||||
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
|
||||
dir.listFiles()?.forEach { it.delete() }
|
||||
val file = File(dir, "DataControl-${info.versionName}.apk")
|
||||
val conn = open(info.apkUrl)
|
||||
try {
|
||||
if (conn.responseCode != HttpURLConnection.HTTP_OK) error("HTTP ${conn.responseCode}")
|
||||
val total = conn.contentLengthLong
|
||||
var done = 0L
|
||||
conn.inputStream.use { input ->
|
||||
file.outputStream().use { output ->
|
||||
val buf = ByteArray(32 * 1024)
|
||||
var n: Int
|
||||
while (input.read(buf).also { n = it } != -1) {
|
||||
output.write(buf, 0, n)
|
||||
done += n
|
||||
if (total > 0) onProgress((done * 100 / total).toInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
file
|
||||
}
|
||||
|
||||
/** Vrai si l'installation a été lancée ; faux si l'utilisateur doit d'abord autoriser l'installation d'APK. */
|
||||
fun install(file: File): Boolean {
|
||||
if (!context.packageManager.canRequestPackageInstalls()) {
|
||||
context.startActivity(
|
||||
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
return false
|
||||
}
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW)
|
||||
.setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun open(url: String): HttpURLConnection =
|
||||
(URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 30_000
|
||||
setRequestProperty("User-Agent", "DataControl/$currentVersion")
|
||||
setRequestProperty("Accept", "application/json, application/octet-stream")
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val RELEASES_URL = "https://git.tuxtech.fr/api/v1/repos/tuxgyver/DataControl/releases/latest"
|
||||
|
||||
fun isNewer(remote: String, current: String): Boolean {
|
||||
val r = remote.split(".").map { it.toIntOrNull() ?: 0 }
|
||||
val c = current.split(".").map { it.toIntOrNull() ?: 0 }
|
||||
for (i in 0 until maxOf(r.size, c.size)) {
|
||||
val rv = r.getOrElse(i) { 0 }
|
||||
val cv = c.getOrElse(i) { 0 }
|
||||
if (rv != cv) return rv > cv
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.jttools.datacontrol.guard
|
||||
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.telecom.TelecomManager
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import com.jttools.datacontrol.log.AppLog
|
||||
import com.jttools.datacontrol.service.MonitorStateHolder
|
||||
import com.jttools.datacontrol.service.NetworkTransport
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Détecte l'application au premier plan (service d'accessibilité, sans lire le contenu des écrans)
|
||||
* pour appliquer les règles de [GuardEngine] : interdiction, plages horaires, limites de temps.
|
||||
* Un blocage renvoie à l'accueil et affiche [BlockedActivity].
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class AppGuardService : AccessibilityService() {
|
||||
|
||||
@Inject lateinit var engine: GuardEngine
|
||||
@Inject lateinit var stateHolder: MonitorStateHolder
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var exempt: Set<String> = emptySet()
|
||||
private var exemptComputedAt = 0L
|
||||
private var currentPkg: String? = null
|
||||
private var lastBlockAt = 0L
|
||||
private var lastLoggedPkg: String? = null
|
||||
|
||||
private val screenReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
engine.onScreen(intent?.action != Intent.ACTION_SCREEN_OFF)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceConnected() {
|
||||
super.onServiceConnected()
|
||||
engine.start()
|
||||
engine.onScreen(getSystemService(PowerManager::class.java).isInteractive)
|
||||
registerReceiver(screenReceiver, IntentFilter().apply {
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
})
|
||||
scope.launch {
|
||||
// Réévalue l'application courante : la limite peut être atteinte pendant l'utilisation.
|
||||
while (isActive) {
|
||||
delay(CHECK_INTERVAL_MS)
|
||||
engine.tick()
|
||||
currentPkg?.let { enforce(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
|
||||
if (event?.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) return
|
||||
val pkg = event.packageName?.toString() ?: return
|
||||
if (pkg in exemptPackages()) {
|
||||
currentPkg = null
|
||||
lastLoggedPkg = null // revenir ensuite dans la même application compte comme une nouvelle utilisation
|
||||
engine.onForeground(null)
|
||||
return
|
||||
}
|
||||
currentPkg = pkg
|
||||
engine.onForeground(pkg)
|
||||
val block = engine.check(pkg)
|
||||
if (pkg != lastLoggedPkg) {
|
||||
lastLoggedPkg = pkg
|
||||
logUsage(pkg, block)
|
||||
}
|
||||
if (block != null) enforce(pkg, block)
|
||||
}
|
||||
|
||||
private fun enforce(pkg: String) {
|
||||
engine.check(pkg)?.let { enforce(pkg, it) }
|
||||
}
|
||||
|
||||
private fun enforce(pkg: String, block: Block) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastBlockAt < 2_000) return
|
||||
lastBlockAt = now
|
||||
AppLog.w("Appli", "Lancement BLOQUÉ : ${labelOf(pkg)} ($pkg) — ${block.message}")
|
||||
performGlobalAction(GLOBAL_ACTION_HOME)
|
||||
startActivity(BlockedActivity.intent(this, labelOf(pkg), block.message))
|
||||
}
|
||||
|
||||
/** Une ligne de journal par utilisation : réseau et lancement, autorisés ou bloqués. */
|
||||
private fun logUsage(pkg: String, block: Block?) {
|
||||
val (net, netBlocked) = networkVerdict(pkg)
|
||||
val launch = if (block == null) "lancement autorisé" else "lancement BLOQUÉ (${block.message.trimEnd('.')})"
|
||||
val line = "${labelOf(pkg)} ($pkg) — réseau $net · $launch"
|
||||
if (netBlocked || block != null) AppLog.w("Appli", line) else AppLog.i("Appli", line)
|
||||
}
|
||||
|
||||
/** Verdict réseau d'une application d'après l'état réel du pare-feu (mode appliqué + liste blanche). */
|
||||
private fun networkVerdict(pkg: String): Pair<String, Boolean> {
|
||||
val snap = stateHolder.snapshot.value
|
||||
val net = when (snap.transport) {
|
||||
NetworkTransport.CELLULAR -> "4G/5G"
|
||||
NetworkTransport.WIFI -> "Wi-Fi"
|
||||
NetworkTransport.OTHER -> "autre réseau"
|
||||
NetworkTransport.NONE -> "aucun réseau"
|
||||
}
|
||||
if (pkg == packageName) return "AUTORISÉ (DataControl, toujours exempté) [$net]" to false
|
||||
if (!snap.serviceRunning) return "libre (contrôle désactivé) [$net]" to false
|
||||
return when (snap.appliedMode) {
|
||||
"BLOCK_ALL" -> "BLOQUÉ (coupure totale en cours) [$net]" to true
|
||||
"FILTER" ->
|
||||
if (pkg in snap.effectiveWhitelist) "AUTORISÉ (liste blanche) [$net]" to false
|
||||
else "BLOQUÉ (hors liste blanche) [$net]" to true
|
||||
else -> "libre (aucun filtrage sur ce réseau) [$net]" to false
|
||||
}
|
||||
}
|
||||
|
||||
private fun labelOf(pkg: String): String = runCatching {
|
||||
packageManager.getApplicationLabel(packageManager.getApplicationInfo(pkg, 0)).toString()
|
||||
}.getOrDefault(pkg)
|
||||
|
||||
/** Applications jamais bloquées ni comptées : sans elles l'appareil deviendrait inutilisable. */
|
||||
private fun exemptPackages(): Set<String> {
|
||||
val now = System.currentTimeMillis()
|
||||
if (exempt.isNotEmpty() && now - exemptComputedAt < EXEMPT_TTL_MS) return exempt
|
||||
val set = mutableSetOf(packageName, "android", "com.android.systemui", "com.android.phone", "com.android.emergency")
|
||||
runCatching {
|
||||
val home = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
|
||||
packageManager.queryIntentActivities(home, PackageManager.MATCH_DEFAULT_ONLY)
|
||||
.forEach { set += it.activityInfo.packageName }
|
||||
}
|
||||
runCatching { getSystemService(TelecomManager::class.java).defaultDialerPackage?.let { set += it } }
|
||||
runCatching {
|
||||
getSystemService(InputMethodManager::class.java).enabledInputMethodList.forEach { set += it.packageName }
|
||||
}
|
||||
exempt = set
|
||||
exemptComputedAt = now
|
||||
return set
|
||||
}
|
||||
|
||||
override fun onInterrupt() = Unit
|
||||
|
||||
override fun onDestroy() {
|
||||
runCatching { unregisterReceiver(screenReceiver) }
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHECK_INTERVAL_MS = 10_000L
|
||||
private const val EXEMPT_TTL_MS = 5 * 60_000L
|
||||
|
||||
fun isEnabled(context: Context): Boolean {
|
||||
val enabled = Settings.Secure.getString(
|
||||
context.contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES
|
||||
) ?: return false
|
||||
val me = ComponentName(context, AppGuardService::class.java)
|
||||
return enabled.split(':').any {
|
||||
it.equals(me.flattenToString(), true) || it.equals(me.flattenToShortString(), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.jttools.datacontrol.guard
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
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()
|
||||
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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onBackPressed() = goHome()
|
||||
|
||||
private fun goHome() {
|
||||
startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
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,172 @@
|
||||
package com.jttools.datacontrol.guard
|
||||
|
||||
import android.os.SystemClock
|
||||
import com.jttools.datacontrol.data.SettingsRepository
|
||||
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.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
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.
|
||||
*/
|
||||
@Singleton
|
||||
class GuardEngine @Inject constructor(private val settings: SettingsRepository) {
|
||||
|
||||
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 day = LocalDate.now().toString()
|
||||
private var totalMs = 0L
|
||||
private val appMs = HashMap<String, Long>()
|
||||
private var currentPkg: String? = null
|
||||
private var lastMark = SystemClock.elapsedRealtime()
|
||||
private var screenOn = true
|
||||
private var dirty = false
|
||||
|
||||
fun start() {
|
||||
synchronized(lock) { if (started) return; started = true }
|
||||
scope.launch {
|
||||
settings.loadUsage()?.let { (savedDay, json) -> restore(savedDay, json) }
|
||||
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 != LocalDate.now().toString()) 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() }
|
||||
|
||||
private fun flush() {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val today = LocalDate.now().toString()
|
||||
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 && !inWindow(r, LocalDateTime.now())
|
||||
}
|
||||
|
||||
fun cutsMobileNow(): Boolean = synchronized(lock) {
|
||||
val r = rules
|
||||
r.scheduleEnabled && r.scheduleCutsMobile && !inWindow(r, LocalDateTime.now())
|
||||
}
|
||||
|
||||
fun check(pkg: String): Block? = synchronized(lock) {
|
||||
flush()
|
||||
val r = rules
|
||||
if (r.isEmpty) return null
|
||||
val now = LocalDateTime.now()
|
||||
if (pkg in r.blocked) return Block("Cette application est bloquée par vos parents.")
|
||||
if (r.scheduleEnabled && !inWindow(r, now)) {
|
||||
return Block("L'appareil est verrouillé en dehors des plages horaires. ${nextOpening(r, now)}")
|
||||
}
|
||||
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 fun inWindow(r: Rules, now: LocalDateTime): Boolean {
|
||||
val dow = now.dayOfWeek.value
|
||||
val prev = if (dow == 1) 7 else dow - 1
|
||||
val minute = now.hour * 60 + now.minute
|
||||
return r.windows.any { w ->
|
||||
if (w.startMin <= w.endMin) dow in w.days && minute >= w.startMin && minute < w.endMin
|
||||
else (dow in w.days && minute >= w.startMin) || (prev in w.days && minute < w.endMin)
|
||||
}
|
||||
}
|
||||
|
||||
private fun nextOpening(r: Rules, now: LocalDateTime): String {
|
||||
val minute = now.hour * 60 + now.minute
|
||||
for (offset in 0..7) {
|
||||
val dow = ((now.dayOfWeek.value - 1 + offset) % 7) + 1
|
||||
val start = r.windows.filter { dow in it.days && (offset > 0 || it.startMin > minute) }
|
||||
.minOfOrNull { it.startMin } ?: continue
|
||||
val label = "%02d:%02d".format(start / 60, start % 60)
|
||||
return if (offset == 0) "Reprise à $label." else "Reprise à $label (dans $offset j)."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MIN_MS = 60_000L
|
||||
const val MAX_DELTA_MS = 120_000L
|
||||
const val PERSIST_INTERVAL_MS = 30_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.jttools.datacontrol.guard
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/** 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()
|
||||
|
||||
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,114 @@
|
||||
package com.jttools.datacontrol.log
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
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>()
|
||||
private val _entries = MutableStateFlow<List<String>>(emptyList())
|
||||
val entries: StateFlow<List<String>> = _entries.asStateFlow()
|
||||
|
||||
@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()
|
||||
val entry = Entry(now, "${fmt.format(Date(now))} $level $tag: $message")
|
||||
synchronized(lines) {
|
||||
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() {
|
||||
_entries.value = lines.map { it.text }
|
||||
}
|
||||
|
||||
/** 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,196 @@
|
||||
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.net.TrafficStats
|
||||
import android.os.BatteryManager
|
||||
import android.os.Looper
|
||||
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.FirewallVpnService
|
||||
import com.jttools.datacontrol.service.MonitorStateHolder
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDate
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Envoie à FamilyControl (via [RemoteSync]) l'état de l'appareil : batterie, position,
|
||||
* état du pare-feu, historique de trafic. 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 var started = false
|
||||
private var lastBatterySent = -1 to false
|
||||
private var lastBatterySentAt = 0L
|
||||
private var lastLocationSentAt = 0L
|
||||
private var lastStateSignature = ""
|
||||
private var lastStateSentAt = 0L
|
||||
private var locationRegistered = false
|
||||
private var lastGuardEnabled: Boolean? = null
|
||||
private var lastUsageSentTotal = -1L
|
||||
|
||||
fun start(scope: CoroutineScope) {
|
||||
if (started) return
|
||||
started = true
|
||||
remote.start(scope)
|
||||
|
||||
val batteryReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, intent: Intent?) {
|
||||
if (intent != null) scope.launch { pushBattery(intent) }
|
||||
}
|
||||
}
|
||||
// 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
|
||||
)
|
||||
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
val s = settingsRepository.settingsFlow.first()
|
||||
if (s.isPaired) {
|
||||
registerLocationUpdates(scope)
|
||||
pushState()
|
||||
pushTraffic()
|
||||
pushUsage()
|
||||
}
|
||||
delay(REPORT_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pushBattery(intent: Intent) {
|
||||
if (!settingsRepository.settingsFlow.first().isPaired) return
|
||||
val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
|
||||
val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, 100)
|
||||
if (level < 0 || scale <= 0) return
|
||||
val percent = level * 100 / scale
|
||||
val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
|
||||
val charging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL
|
||||
val now = System.currentTimeMillis()
|
||||
val changed = (percent to charging) != lastBatterySent
|
||||
if (!changed || now - lastBatterySentAt < BATTERY_MIN_INTERVAL_MS && percent > LOW_BATTERY) return
|
||||
lastBatterySent = percent to charging
|
||||
lastBatterySentAt = now
|
||||
remote.pushDevice(mapOf("battery" to mapOf("level" to percent, "charging" to charging, "at" to now)))
|
||||
}
|
||||
|
||||
private suspend fun pushState() {
|
||||
val s = settingsRepository.settingsFlow.first()
|
||||
val snap = stateHolder.snapshot.value
|
||||
val guardEnabled = AppGuardService.isEnabled(context)
|
||||
if (lastGuardEnabled == true && !guardEnabled) {
|
||||
remote.pushEvent("guard_disabled", "Service d'accessibilité désactivé : les limites d'applications ne sont plus appliquées")
|
||||
}
|
||||
lastGuardEnabled = guardEnabled
|
||||
val state = mapOf(
|
||||
"controlEnabled" to s.controlEnabled,
|
||||
"transport" to snap.transport.name,
|
||||
"firewallFault" to snap.firewallFault,
|
||||
"vpnPrepared" to FirewallVpnService.isPrepared(context),
|
||||
"tunnelActive" to FirewallVpnService.tunnelActive,
|
||||
"adminActive" to AdminController.isActive(context),
|
||||
"guardEnabled" to guardEnabled,
|
||||
"rulesActive" to !guardEngine.rules.isEmpty,
|
||||
"consumedMo" to snap.consumedMo,
|
||||
"planLimitMo" to s.planLimitMo,
|
||||
"cutoffIntervalMinutes" to s.cutoffIntervalMinutes,
|
||||
"wifiFilterEnabled" to s.wifiFilterEnabled,
|
||||
"whitelistedPackages" to s.whitelistedPackages.sorted(),
|
||||
"whitelistedPackagesWifi" to s.whitelistedPackagesWifi.sorted(),
|
||||
"appliedConfigAt" to s.remoteConfigVersion,
|
||||
"appVersion" to BuildConfig.VERSION_NAME
|
||||
)
|
||||
val signature = state.toString()
|
||||
val now = System.currentTimeMillis()
|
||||
// Évite les écritures inutiles ; un battement toutes les 5 min indique que l'appareil est vivant.
|
||||
if (signature == lastStateSignature && now - lastStateSentAt < HEARTBEAT_MS) return
|
||||
lastStateSignature = signature
|
||||
lastStateSentAt = now
|
||||
remote.pushDevice(mapOf("state" to (state + ("at" to now))))
|
||||
}
|
||||
|
||||
private suspend fun pushTraffic() {
|
||||
val mobile = TrafficStats.getMobileRxBytes() + TrafficStats.getMobileTxBytes()
|
||||
val total = TrafficStats.getTotalRxBytes() + TrafficStats.getTotalTxBytes()
|
||||
if (mobile < 0 || total < 0) return
|
||||
val day = settingsRepository.accumulateTraffic(LocalDate.now().toString(), mobile, (total - mobile).coerceAtLeast(0L))
|
||||
remote.pushTraffic(day)
|
||||
}
|
||||
|
||||
private suspend fun pushUsage() {
|
||||
val u = guardEngine.usage()
|
||||
// Ne renvoie que si le temps d'écran a évolué (ou changement de jour).
|
||||
if (u.totalMs == lastUsageSentTotal) return
|
||||
lastUsageSentTotal = u.totalMs
|
||||
remote.pushUsage(u.date, u.totalMs, u.appMs)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun registerLocationUpdates(scope: CoroutineScope) {
|
||||
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 -> scope.launch { pushLocation(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 suspend fun pushLocation(loc: Location) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastLocationSentAt < LOCATION_MIN_SEND_MS) return
|
||||
lastLocationSentAt = now
|
||||
remote.pushDevice(
|
||||
mapOf(
|
||||
"location" to mapOf(
|
||||
"lat" to loc.latitude, "lng" to loc.longitude,
|
||||
"accuracy" to loc.accuracy.toDouble(), "at" to now
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val REPORT_INTERVAL_MS = 60_000L
|
||||
const val HEARTBEAT_MS = 5 * 60_000L
|
||||
const val BATTERY_MIN_INTERVAL_MS = 2 * 60_000L
|
||||
const val LOW_BATTERY = 20
|
||||
const val LOCATION_INTERVAL_MS = 5 * 60_000L
|
||||
const val LOCATION_MIN_DISTANCE_M = 50f
|
||||
const val LOCATION_MIN_SEND_MS = 60_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.jttools.datacontrol.remote
|
||||
|
||||
import android.util.Base64
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Identifiants d'un projet Firebase (ceux du bloc `firebaseConfig` de la console). */
|
||||
data class FirebaseConfig(val apiKey: String, val appId: String, val projectId: String) {
|
||||
companion object {
|
||||
/**
|
||||
* Lit ce que l'utilisateur colle : le bloc `firebaseConfig` de la console (JavaScript), un JSON,
|
||||
* ou une chaîne « fc1:… » partagée par FamilyControl. Renvoie null si apiKey, appId ou projectId manque.
|
||||
*/
|
||||
fun parse(input: String): FirebaseConfig? {
|
||||
val text = input.trim()
|
||||
if (text.startsWith("fc1:")) {
|
||||
return runCatching {
|
||||
val json = JSONObject(String(Base64.decode(text.removePrefix("fc1:"), Base64.DEFAULT), Charsets.UTF_8))
|
||||
build(json.optString("apiKey"), json.optString("appId"), json.optString("projectId"))
|
||||
}.getOrNull()
|
||||
}
|
||||
fun pick(name: String) =
|
||||
Regex("""["']?$name["']?\s*[:=]\s*["']([^"']+)["']""").find(text)?.groupValues?.get(1)
|
||||
return build(pick("apiKey"), pick("appId"), pick("projectId"))
|
||||
}
|
||||
|
||||
private fun build(apiKey: String?, appId: String?, projectId: String?): FirebaseConfig? =
|
||||
if (apiKey.isNullOrBlank() || appId.isNullOrBlank() || projectId.isNullOrBlank()) null
|
||||
else FirebaseConfig(apiKey.trim(), appId.trim(), projectId.trim())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.jttools.datacontrol.remote
|
||||
|
||||
import android.content.Context
|
||||
import com.google.firebase.FirebaseApp
|
||||
import com.google.firebase.FirebaseOptions
|
||||
import com.google.firebase.auth.FirebaseAuth
|
||||
import com.google.firebase.firestore.FirebaseFirestore
|
||||
import com.jttools.datacontrol.BuildConfig
|
||||
import com.jttools.datacontrol.log.AppLog
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** État du lien Firebase : [generation] change à chaque (ré)initialisation, pour relancer les écoutes. */
|
||||
data class GatewayState(val generation: Int = 0, val enabled: Boolean = false, val projectId: String? = null)
|
||||
|
||||
/**
|
||||
* Accès à Firebase (Auth anonyme + Firestore). La configuration se saisit dans l'application
|
||||
* (Réglages → Contrôle parental) et peut changer à tout moment ; à défaut, on retombe sur
|
||||
* `firebase.properties` (compilation). Sans configuration, [enabled] est faux et tout reste inerte.
|
||||
*/
|
||||
@Singleton
|
||||
class FirebaseGateway @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
private val _state = MutableStateFlow(GatewayState())
|
||||
val state: StateFlow<GatewayState> = _state.asStateFlow()
|
||||
val enabled: Boolean get() = _state.value.enabled
|
||||
|
||||
@Volatile private var app: FirebaseApp? = null
|
||||
private var currentKey: String? = null
|
||||
|
||||
private fun buildConfigFallback(): FirebaseConfig? =
|
||||
if (BuildConfig.FIREBASE_API_KEY.isNotBlank() && BuildConfig.FIREBASE_APP_ID.isNotBlank() &&
|
||||
BuildConfig.FIREBASE_PROJECT_ID.isNotBlank()
|
||||
) FirebaseConfig(BuildConfig.FIREBASE_API_KEY, BuildConfig.FIREBASE_APP_ID, BuildConfig.FIREBASE_PROJECT_ID)
|
||||
else null
|
||||
|
||||
/** Applique la configuration saisie [saved] (ou, à défaut, celle de compilation). Sans effet si elle n'a pas changé. */
|
||||
@Synchronized
|
||||
fun apply(saved: FirebaseConfig?) {
|
||||
val cfg = saved ?: buildConfigFallback()
|
||||
val key = cfg?.let { "${it.apiKey}|${it.appId}|${it.projectId}" }
|
||||
if (key == currentKey && (key == null || app != null)) return
|
||||
|
||||
app?.let { runCatching { it.delete() } }
|
||||
app = null
|
||||
currentKey = key
|
||||
if (cfg != null) {
|
||||
app = runCatching {
|
||||
FirebaseApp.initializeApp(
|
||||
context,
|
||||
FirebaseOptions.Builder()
|
||||
.setApiKey(cfg.apiKey)
|
||||
.setApplicationId(cfg.appId)
|
||||
.setProjectId(cfg.projectId)
|
||||
.build()
|
||||
)
|
||||
}.onFailure { AppLog.e("Firebase", "Initialisation impossible", it) }.getOrNull()
|
||||
}
|
||||
val ok = app != null
|
||||
AppLog.i("Firebase", if (ok) "Configuré (projet ${cfg?.projectId})" else "Non configuré")
|
||||
_state.value = GatewayState(_state.value.generation + 1, ok, cfg?.projectId?.takeIf { ok })
|
||||
}
|
||||
|
||||
val db: FirebaseFirestore? get() = app?.let { FirebaseFirestore.getInstance(it) }
|
||||
private val auth: FirebaseAuth? get() = app?.let { FirebaseAuth.getInstance(it) }
|
||||
|
||||
/** Identifiant de l'appareil côté Firebase (connexion anonyme, persistante). Null si indisponible. */
|
||||
suspend fun ensureSignedIn(): String? {
|
||||
val a = auth ?: return null
|
||||
a.currentUser?.let { return it.uid }
|
||||
return runCatching { a.signInAnonymously().await().user?.uid }
|
||||
.onFailure { AppLog.w("Firebase", "Connexion anonyme impossible : ${it.message}") }
|
||||
.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package com.jttools.datacontrol.remote
|
||||
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.google.firebase.firestore.DocumentReference
|
||||
import com.google.firebase.firestore.SetOptions
|
||||
import com.jttools.datacontrol.data.AppSettings
|
||||
import com.jttools.datacontrol.data.InstalledAppsRepository
|
||||
import com.jttools.datacontrol.data.SettingsRepository
|
||||
import com.jttools.datacontrol.data.TrafficDay
|
||||
import com.jttools.datacontrol.log.AppLog
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Liaison avec FamilyControl via Firestore.
|
||||
*
|
||||
* Modèle (voir FamilyControl/firebase/firestore.rules) :
|
||||
* - pairings/{code} : créé par le parent, lu une fois par l'enfant
|
||||
* - families/{fid}/devices/{did} : état, batterie, position (écrit par l'appareil)
|
||||
* - families/{fid}/devices/{did}/config/remote : consignes du parent (lues par l'appareil)
|
||||
* - families/{fid}/devices/{did}/traffic/{yyyy-MM-dd}: historique de trafic
|
||||
* - families/{fid}/devices/{did}/events/{auto} : journal d'évènements
|
||||
*
|
||||
* L'appareil ne s'auto-bloque jamais : l'app DataControl est toujours exclue du tunnel VPN
|
||||
* (cf. FirewallVpnService), donc ces échanges passent même quand le réseau est coupé.
|
||||
*/
|
||||
@Singleton
|
||||
class RemoteSync @Inject constructor(
|
||||
private val gateway: FirebaseGateway,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val installedApps: InstalledAppsRepository
|
||||
) {
|
||||
val available: Boolean get() = gateway.enabled
|
||||
val gatewayState = gateway.state
|
||||
|
||||
private fun deviceDoc(s: AppSettings): DocumentReference? {
|
||||
val db = gateway.db ?: return null
|
||||
val fid = s.familyId ?: return null
|
||||
val did = s.deviceId ?: return null
|
||||
return db.collection("families").document(fid).collection("devices").document(did)
|
||||
}
|
||||
|
||||
/** Associe cet appareil à la famille correspondant au [code] affiché dans FamilyControl. */
|
||||
suspend fun pair(code: String, deviceName: String): Result<Unit> = runCatching {
|
||||
check(gateway.enabled) { "Firebase non configuré dans cette version" }
|
||||
val db = gateway.db!!
|
||||
val uid = gateway.ensureSignedIn() ?: error("Connexion Firebase impossible (réseau ?)")
|
||||
val clean = code.trim().uppercase()
|
||||
val pairing = db.collection("pairings").document(clean).get().await()
|
||||
check(pairing.exists()) { "Code inconnu ou expiré" }
|
||||
val familyId = pairing.getString("familyId") ?: error("Code invalide")
|
||||
check((pairing.getLong("expiresAt") ?: 0L) > System.currentTimeMillis()) { "Code expiré" }
|
||||
val deviceId = UUID.randomUUID().toString()
|
||||
db.collection("families").document(familyId).collection("devices").document(deviceId).set(
|
||||
mapOf(
|
||||
"deviceUid" to uid,
|
||||
"name" to deviceName.ifBlank { Build.MODEL },
|
||||
"model" to "${Build.MANUFACTURER} ${Build.MODEL}",
|
||||
"pairingCode" to clean,
|
||||
"pairedAt" to System.currentTimeMillis()
|
||||
)
|
||||
).await()
|
||||
settingsRepository.setPairing(familyId, deviceId)
|
||||
}
|
||||
|
||||
suspend fun unpair() {
|
||||
val s = settingsRepository.settingsFlow.first()
|
||||
pushEvent("unpaired", "Appareil dissocié depuis DataControl")
|
||||
deviceDoc(s)?.let { runCatching { it.update("unpairedAt", System.currentTimeMillis()) } }
|
||||
settingsRepository.setPairing(null, null)
|
||||
}
|
||||
|
||||
/** Écoute les consignes du parent et les applique ; relancé à chaque (ré)initialisation de Firebase. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun start(scope: CoroutineScope) {
|
||||
scope.launch {
|
||||
gateway.state.collectLatest { st ->
|
||||
if (!st.enabled) return@collectLatest
|
||||
coroutineScope {
|
||||
launch {
|
||||
settingsRepository.settingsFlow
|
||||
.map { it.familyId to it.deviceId }
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { (fid, did) ->
|
||||
if (fid == null || did == null) flowOf(null) else configFlow(fid, did)
|
||||
}
|
||||
.collect { cfg -> if (cfg != null) apply(cfg) }
|
||||
}
|
||||
launch {
|
||||
// Si le parent supprime l'appareil de la famille, on se dissocie localement (plus de règles,
|
||||
// plus d'envois qui échoueraient en silence).
|
||||
settingsRepository.settingsFlow
|
||||
.map { it.familyId to it.deviceId }
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { (fid, did) -> if (fid == null || did == null) flowOf(null) else existenceFlow(fid, did) }
|
||||
.collect { exists ->
|
||||
if (exists == false) {
|
||||
AppLog.w("FamilyControl", "Appareil supprimé par le parent : dissociation locale")
|
||||
settingsRepository.setPairing(null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
launch {
|
||||
// Liste des applications installées, pour que le parent puisse choisir lesquelles autoriser.
|
||||
settingsRepository.settingsFlow.map { it.isPaired }.distinctUntilChanged().collect { paired ->
|
||||
if (paired) pushApps()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Vrai/faux selon que le document de l'appareil existe côté serveur (ignore le cache local). */
|
||||
private fun existenceFlow(fid: String, did: String) = callbackFlow<Boolean?> {
|
||||
val db = gateway.db
|
||||
if (db == null || gateway.ensureSignedIn() == null) { trySend(null); close(); return@callbackFlow }
|
||||
val reg = db.collection("families").document(fid).collection("devices").document(did)
|
||||
.addSnapshotListener { snap, err ->
|
||||
if (err != null || snap == null || snap.metadata.isFromCache) return@addSnapshotListener
|
||||
trySend(snap.exists())
|
||||
}
|
||||
awaitClose { reg.remove() }
|
||||
}
|
||||
|
||||
private class Config(val map: Map<String, Any?>)
|
||||
|
||||
private fun configFlow(fid: String, did: String) = callbackFlow<Config?> {
|
||||
val db = gateway.db
|
||||
if (db == null || gateway.ensureSignedIn() == null) { trySend(null); close(); return@callbackFlow }
|
||||
val reg = db.collection("families").document(fid).collection("devices").document(did)
|
||||
.collection("config").document("remote")
|
||||
.addSnapshotListener { snap, err ->
|
||||
if (err != null) { Log.w(TAG, "config listener", err); return@addSnapshotListener }
|
||||
if (snap != null && snap.exists()) trySend(Config(snap.data.orEmpty()))
|
||||
}
|
||||
awaitClose { reg.remove() }
|
||||
}
|
||||
|
||||
private suspend fun apply(cfg: Config) {
|
||||
val m = cfg.map
|
||||
val version = (m["updatedAt"] as? Number)?.toLong() ?: return
|
||||
fun strings(key: String) = (m[key] as? List<*>)?.filterIsInstance<String>()?.toSet()
|
||||
settingsRepository.applyRemoteConfig(
|
||||
version = version,
|
||||
controlEnabled = m["controlEnabled"] as? Boolean,
|
||||
whitelistedPackages = strings("whitelistedPackages"),
|
||||
whitelistedPackagesWifi = strings("whitelistedPackagesWifi"),
|
||||
wifiFilterEnabled = m["wifiFilterEnabled"] as? Boolean,
|
||||
planLimitMo = (m["planLimitMo"] as? Number)?.toLong(),
|
||||
cutoffIntervalMinutes = (m["cutoffIntervalMinutes"] as? Number)?.toInt(),
|
||||
rulesJson = (m["rules"] as? Map<*, *>)?.let { org.json.JSONObject(it).toString() }
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun pushApps() {
|
||||
val s = settingsRepository.settingsFlow.first()
|
||||
val ref = deviceDoc(s) ?: return
|
||||
val apps = installedApps.listInstalledApps(withIcons = false).map {
|
||||
mapOf("pkg" to it.packageName, "label" to it.label, "system" to it.isSystemApp)
|
||||
}
|
||||
write(ref.collection("meta").document("apps"), mapOf("apps" to apps, "at" to System.currentTimeMillis()))
|
||||
}
|
||||
|
||||
/** Fusionne [data] dans le document de l'appareil (état, batterie, position…). */
|
||||
suspend fun pushDevice(data: Map<String, Any?>) {
|
||||
val s = settingsRepository.settingsFlow.first()
|
||||
val ref = deviceDoc(s) ?: return
|
||||
write(ref, data + ("lastSeen" to System.currentTimeMillis()))
|
||||
}
|
||||
|
||||
suspend fun pushTraffic(day: TrafficDay) {
|
||||
val s = settingsRepository.settingsFlow.first()
|
||||
val ref = deviceDoc(s) ?: return
|
||||
write(
|
||||
ref.collection("traffic").document(day.date),
|
||||
mapOf("date" to day.date, "mobileBytes" to day.mobileBytes, "wifiBytes" to day.wifiBytes,
|
||||
"at" to System.currentTimeMillis())
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun pushUsage(date: String, totalMs: Long, appMs: Map<String, Long>) {
|
||||
val s = settingsRepository.settingsFlow.first()
|
||||
val ref = deviceDoc(s) ?: return
|
||||
write(
|
||||
ref.collection("usage").document(date),
|
||||
mapOf("date" to date, "totalMs" to totalMs, "apps" to appMs, "at" to System.currentTimeMillis())
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun pushEvent(type: String, detail: String) {
|
||||
val s = settingsRepository.settingsFlow.first()
|
||||
val ref = deviceDoc(s) ?: return
|
||||
runCatching {
|
||||
ref.collection("events").add(
|
||||
mapOf("type" to type, "detail" to detail, "at" to System.currentTimeMillis())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun write(ref: DocumentReference, data: Map<String, Any?>) {
|
||||
if (gateway.ensureSignedIn() == null) return
|
||||
// Les écritures sont mises en file par le SDK hors-ligne : pas d'attente bloquante du réseau.
|
||||
ref.set(data, SetOptions.merge())
|
||||
}
|
||||
|
||||
companion object { private const val TAG = "RemoteSync" }
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import com.jttools.datacontrol.log.AppLog
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -34,13 +35,36 @@ class ConnectivityObserver @Inject constructor(
|
||||
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() {
|
||||
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) {
|
||||
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)
|
||||
.build()
|
||||
|
||||
trySend(transportOf(cm.getNetworkCapabilities(cm.activeNetwork)))
|
||||
cm.registerNetworkCallback(request, callback)
|
||||
|
||||
awaitClose { cm.unregisterNetworkCallback(callback) }
|
||||
|
||||
@@ -20,6 +20,10 @@ import com.jttools.datacontrol.data.AppSettings
|
||||
import com.jttools.datacontrol.data.DataState
|
||||
import com.jttools.datacontrol.data.SettingsRepository
|
||||
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 dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -45,6 +49,9 @@ class DataMonitorService : LifecycleService() {
|
||||
@Inject lateinit var usageRepository: UsageRepository
|
||||
@Inject lateinit var connectivityObserver: ConnectivityObserver
|
||||
@Inject lateinit var stateHolder: MonitorStateHolder
|
||||
@Inject lateinit var deviceReporter: DeviceReporter
|
||||
@Inject lateinit var remoteSync: RemoteSync
|
||||
@Inject lateinit var guardEngine: GuardEngine
|
||||
|
||||
private var latestSettings: AppSettings = AppSettings()
|
||||
private var latestTransport: NetworkTransport = NetworkTransport.NONE
|
||||
@@ -52,16 +59,22 @@ class DataMonitorService : LifecycleService() {
|
||||
|
||||
private var lastAppliedMode: VpnMode? = null
|
||||
private var lastAppliedWhitelist: Set<String> = emptySet()
|
||||
private var lastApplyMillis = 0L
|
||||
|
||||
private var cutoffCycleAnchorMillis: Long? = null
|
||||
private var inPulse = false
|
||||
private var pulseStartMillis: Long = 0L
|
||||
private var settingsLoaded = false
|
||||
private var lastControlEnabled: Boolean? = null
|
||||
private var lastDiagnostic = ""
|
||||
|
||||
private val vpnRevokedReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
vpnRevoked = true
|
||||
lifecycleScope.launch { settingsRepository.setControlEnabled(false) }
|
||||
lifecycleScope.launch {
|
||||
settingsRepository.setControlEnabled(false)
|
||||
remoteSync.pushEvent("vpn_revoked", "Autorisation VPN retirée : le filtrage est désactivé")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +94,7 @@ class DataMonitorService : LifecycleService() {
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED
|
||||
)
|
||||
startForeground(NOTIF_ID, buildNotification(stateHolder.snapshot.value))
|
||||
AppLog.i("Service", "DataMonitorService démarré")
|
||||
|
||||
lifecycleScope.launch {
|
||||
settingsRepository.settingsFlow.collect {
|
||||
@@ -94,10 +108,21 @@ class DataMonitorService : LifecycleService() {
|
||||
settingsLoaded = true
|
||||
}
|
||||
latestSettings = it
|
||||
if (lastControlEnabled != null && lastControlEnabled != it.controlEnabled && it.isPaired) {
|
||||
launch {
|
||||
remoteSync.pushEvent(
|
||||
"control_changed",
|
||||
if (it.controlEnabled) "Contrôle activé" else "Contrôle désactivé"
|
||||
)
|
||||
}
|
||||
}
|
||||
lastControlEnabled = it.controlEnabled
|
||||
if (!it.controlEnabled) vpnRevoked = false
|
||||
tick()
|
||||
}
|
||||
}
|
||||
guardEngine.start()
|
||||
deviceReporter.start(lifecycleScope)
|
||||
lifecycleScope.launch {
|
||||
connectivityObserver.observe().collect {
|
||||
latestTransport = it
|
||||
@@ -137,7 +162,9 @@ class DataMonitorService : LifecycleService() {
|
||||
estimatedTotalPriceEuro = estimatedTotal,
|
||||
dataState = if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK,
|
||||
nextAutoResetDate = settings.nextAutoResetDate(),
|
||||
serviceRunning = false
|
||||
serviceRunning = false,
|
||||
firewallFault = false,
|
||||
transport = latestTransport
|
||||
)
|
||||
}
|
||||
updateNotification(stateHolder.snapshot.value)
|
||||
@@ -148,6 +175,7 @@ class DataMonitorService : LifecycleService() {
|
||||
|
||||
val dataState: DataState
|
||||
var nextCutoffAt: Long? = null
|
||||
var firewallFault = false
|
||||
|
||||
when {
|
||||
latestTransport == NetworkTransport.WIFI -> {
|
||||
@@ -160,6 +188,14 @@ class DataMonitorService : LifecycleService() {
|
||||
dataState = DataState.WIFI
|
||||
}
|
||||
latestTransport == NetworkTransport.CELLULAR && !vpnRevoked && FirewallVpnService.isPrepared(this) -> {
|
||||
if (guardEngine.cutsMobileNow()) {
|
||||
// Hors plage horaire autorisée : couper les données mobiles (règle du parent).
|
||||
applyVpnMode(VpnMode.BLOCK_ALL, emptySet())
|
||||
resetCutoffCycle()
|
||||
firewallFault = !FirewallVpnService.tunnelActive &&
|
||||
System.currentTimeMillis() - lastApplyMillis > TUNNEL_RETRY_DELAY_MS
|
||||
dataState = DataState.CUT
|
||||
} else {
|
||||
// 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()
|
||||
@@ -188,16 +224,24 @@ class DataMonitorService : LifecycleService() {
|
||||
applyVpnMode(VpnMode.FILTER, settings.whitelistedPackages)
|
||||
}
|
||||
nextCutoffAt = if (inPulse) pulseStartMillis + PULSE_DURATION_MS else anchor + intervalMs
|
||||
firewallFault = !FirewallVpnService.tunnelActive &&
|
||||
System.currentTimeMillis() - lastApplyMillis > TUNNEL_RETRY_DELAY_MS
|
||||
dataState = if (inPulse) DataState.CUT else if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK
|
||||
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// Pas de réseau, transport inconnu, ou VPN non préparé/révoqué : rien à filtrer.
|
||||
applyVpnMode(VpnMode.STOPPED, emptySet())
|
||||
resetCutoffCycle()
|
||||
// Sur données mobiles sans autorisation VPN valide, rien n'est filtré : à signaler.
|
||||
firewallFault = latestTransport == NetworkTransport.CELLULAR
|
||||
dataState = if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK
|
||||
}
|
||||
}
|
||||
|
||||
logDiagnostic(settings)
|
||||
|
||||
stateHolder.update {
|
||||
it.copy(
|
||||
consumedMo = consumedMo,
|
||||
@@ -207,12 +251,35 @@ class DataMonitorService : LifecycleService() {
|
||||
dataState = dataState,
|
||||
nextAutoResetDate = settings.nextAutoResetDate(),
|
||||
nextCutoffAtMillis = nextCutoffAt,
|
||||
serviceRunning = true
|
||||
serviceRunning = true,
|
||||
firewallFault = firewallFault,
|
||||
transport = latestTransport
|
||||
)
|
||||
}
|
||||
updateNotification(stateHolder.snapshot.value)
|
||||
}
|
||||
|
||||
/** Écrit dans le journal POURQUOI le pare-feu est (ou n'est pas) actif, à chaque changement de situation. */
|
||||
private fun logDiagnostic(settings: AppSettings) {
|
||||
val prepared = FirewallVpnService.isPrepared(this)
|
||||
val expected = when {
|
||||
!settings.controlEnabled -> "contrôle désactivé"
|
||||
latestTransport == NetworkTransport.WIFI ->
|
||||
if (settings.wifiFilterEnabled && prepared) "Wi-Fi filtré" else "Wi-Fi : pas de filtrage (normal, le VPN ne se monte que sur données mobiles)"
|
||||
latestTransport == NetworkTransport.CELLULAR ->
|
||||
if (!prepared) "données mobiles mais autorisation VPN absente" else if (vpnRevoked) "données mobiles mais VPN révoqué" else "données mobiles : pare-feu attendu"
|
||||
else -> "aucun réseau actif (${latestTransport})"
|
||||
}
|
||||
val guard = com.jttools.datacontrol.guard.AppGuardService.isEnabled(this)
|
||||
val diag = "réseau=$latestTransport contrôle=${settings.controlEnabled} vpnAutorisé=$prepared " +
|
||||
"tunnelMonté=${FirewallVpnService.tunnelActive} accessibilité=$guard révoqué=$vpnRevoked " +
|
||||
"mode=${lastAppliedMode ?: "—"} → $expected"
|
||||
if (diag != lastDiagnostic) {
|
||||
lastDiagnostic = diag
|
||||
AppLog.i("Pare-feu", diag)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetCutoffCycle() {
|
||||
if (cutoffCycleAnchorMillis == null && !inPulse) return
|
||||
cutoffCycleAnchorMillis = null
|
||||
@@ -236,9 +303,19 @@ class DataMonitorService : LifecycleService() {
|
||||
}
|
||||
|
||||
private fun applyVpnMode(mode: VpnMode, whitelist: Set<String>) {
|
||||
if (mode == lastAppliedMode && (mode != VpnMode.FILTER || whitelist == lastAppliedWhitelist)) return
|
||||
// Si le tunnel a disparu (service VPN tué/relancé, établissement échoué), on le remonte au
|
||||
// tick suivant au lieu de croire qu'il est toujours actif.
|
||||
val tunnelLost = mode != VpnMode.STOPPED && !FirewallVpnService.tunnelActive &&
|
||||
System.currentTimeMillis() - lastApplyMillis > TUNNEL_RETRY_DELAY_MS
|
||||
if (!tunnelLost && mode == lastAppliedMode && (mode != VpnMode.FILTER || whitelist == lastAppliedWhitelist)) return
|
||||
lastAppliedMode = mode
|
||||
lastAppliedWhitelist = whitelist
|
||||
lastApplyMillis = System.currentTimeMillis()
|
||||
AppLog.i("Pare-feu", "→ mode $mode (${whitelist.size} app(s) autorisée(s))")
|
||||
stateHolder.update { it.copy(appliedMode = mode.name, effectiveWhitelist = whitelist) }
|
||||
// Une exception ici (démarrage de service de premier plan refusé, par ex.) ne doit ni faire
|
||||
// planter l'application ni rester invisible : elle est tracée dans le journal.
|
||||
try {
|
||||
when (mode) {
|
||||
VpnMode.STOPPED -> ContextCompat.startForegroundService(this, FirewallVpnService.stopIntent(this))
|
||||
VpnMode.FILTER -> ContextCompat.startForegroundService(
|
||||
@@ -248,6 +325,9 @@ class DataMonitorService : LifecycleService() {
|
||||
this, FirewallVpnService.startBlockAllIntent(this)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AppLog.e("Pare-feu", "Impossible de démarrer FirewallVpnService pour le mode $mode", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(snapshot: MonitorSnapshot): Notification {
|
||||
@@ -262,6 +342,7 @@ class DataMonitorService : LifecycleService() {
|
||||
}
|
||||
|
||||
val (emoji, stateLabel, color) = when {
|
||||
snapshot.firewallFault -> Triple("⚠️", getString(R.string.dashboard_state_warning), Color.parseColor("#C62828"))
|
||||
!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.WARNING -> Triple("🟠", getString(R.string.dashboard_state_warning), Color.parseColor("#EF6C00"))
|
||||
@@ -274,8 +355,12 @@ class DataMonitorService : LifecycleService() {
|
||||
} else "—"
|
||||
val priceText = String.format("%.2f", snapshot.estimatedTotalPriceEuro)
|
||||
|
||||
val contentText = "$emoji $stateLabel · ${snapshot.consumedMo}/${snapshot.planLimitMo} Mo ($percentText)"
|
||||
val bigText = "$contentText\n${getString(R.string.dashboard_cost_total)} : $priceText €"
|
||||
val warn = if (snapshot.firewallFault) "⚠️ PARE-FEU INACTIF — données non filtrées · " else ""
|
||||
val usageText = "$warn$emoji $stateLabel · ${snapshot.consumedMo}/${snapshot.planLimitMo} Mo ($percentText)"
|
||||
val 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 bigText = "$usageText\n$costText"
|
||||
|
||||
val openApp = PendingIntent.getActivity(
|
||||
this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE
|
||||
@@ -310,6 +395,7 @@ class DataMonitorService : LifecycleService() {
|
||||
private const val NOTIF_ID = 1001
|
||||
private const val TICK_INTERVAL_MS = 20_000L
|
||||
private const val PULSE_DURATION_MS = 30_000L
|
||||
private const val TUNNEL_RETRY_DELAY_MS = 5_000L
|
||||
private const val WARNING_THRESHOLD = 0.8f
|
||||
|
||||
fun start(context: Context) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.jttools.datacontrol.MainActivity
|
||||
import com.jttools.datacontrol.R
|
||||
import com.jttools.datacontrol.log.AppLog
|
||||
import java.io.FileInputStream
|
||||
|
||||
/**
|
||||
@@ -37,9 +38,15 @@ class FirewallVpnService : VpnService() {
|
||||
// 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.
|
||||
try {
|
||||
startForeground(NOTIF_ID, buildNotification())
|
||||
} catch (e: Exception) {
|
||||
AppLog.e(TAG, "startForeground() refusé", e)
|
||||
}
|
||||
AppLog.i(TAG, "onStartCommand action=${intent?.action?.substringAfterLast('.') ?: "null (relance système)"}")
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> {
|
||||
AppLog.i(TAG, "Arrêt du tunnel demandé")
|
||||
stopTunnel()
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
@@ -54,11 +61,15 @@ class FirewallVpnService : VpnService() {
|
||||
|
||||
private fun establishTunnel(allowedPackages: Set<String>) {
|
||||
stopTunnel(keepForeground = true)
|
||||
AppLog.i(TAG, "Établissement du tunnel : ${allowedPackages.size} application(s) exclue(s) du filtrage")
|
||||
|
||||
val builder = Builder()
|
||||
.setSession("DataControl")
|
||||
.addAddress(TUNNEL_ADDRESS, 32)
|
||||
.addRoute("0.0.0.0", 0)
|
||||
// Sans route IPv6, le trafic IPv6 des applications bloquées contournerait le pare-feu.
|
||||
.addAddress(TUNNEL_ADDRESS_V6, 128)
|
||||
.addRoute("::", 0)
|
||||
.setBlocking(true)
|
||||
|
||||
// Notre propre app doit toujours pouvoir communiquer normalement.
|
||||
@@ -67,11 +78,17 @@ class FirewallVpnService : VpnService() {
|
||||
|
||||
tunInterface = runCatching { builder.establish() }.getOrElse {
|
||||
Log.e(TAG, "Échec établissement du tunnel VPN", it)
|
||||
AppLog.e(TAG, "establish() a échoué", it)
|
||||
null
|
||||
}
|
||||
|
||||
val fd = tunInterface ?: return
|
||||
val fd = tunInterface ?: run {
|
||||
AppLog.e(TAG, "Tunnel NON monté : establish() a renvoyé null (autorisation VPN retirée ou autre VPN prioritaire)")
|
||||
return
|
||||
}
|
||||
AppLog.i(TAG, "Tunnel VPN monté (l'icône clé doit apparaître)")
|
||||
running = true
|
||||
tunnelActive = true
|
||||
readerThread = Thread({ blackhole(fd) }, "datacontrol-vpn-blackhole").apply {
|
||||
isDaemon = true
|
||||
start()
|
||||
@@ -102,6 +119,7 @@ class FirewallVpnService : VpnService() {
|
||||
|
||||
private fun stopTunnel(keepForeground: Boolean = false) {
|
||||
running = false
|
||||
tunnelActive = false
|
||||
readerThread?.interrupt()
|
||||
readerThread = null
|
||||
tunInterface?.let { runCatching { it.close() } }
|
||||
@@ -112,6 +130,7 @@ class FirewallVpnService : VpnService() {
|
||||
}
|
||||
|
||||
override fun onRevoke() {
|
||||
AppLog.w(TAG, "onRevoke : le système ou un autre VPN a repris la main")
|
||||
stopTunnel()
|
||||
sendBroadcast(Intent(ACTION_VPN_REVOKED).setPackage(packageName))
|
||||
super.onRevoke()
|
||||
@@ -150,6 +169,11 @@ class FirewallVpnService : VpnService() {
|
||||
private const val CHANNEL_ID = "datacontrol_vpn"
|
||||
private const val NOTIF_ID = 2001
|
||||
private const val TUNNEL_ADDRESS = "10.10.10.1"
|
||||
private const val TUNNEL_ADDRESS_V6 = "fd00:10:10::1"
|
||||
|
||||
/** Vrai tant que le tunnel est réellement établi (faux si le service a été tué/relancé sans intent). */
|
||||
@Volatile var tunnelActive = false
|
||||
private set
|
||||
|
||||
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"
|
||||
|
||||
@@ -15,7 +15,13 @@ data class MonitorSnapshot(
|
||||
val dataState: DataState = DataState.OK,
|
||||
val nextAutoResetDate: LocalDate = LocalDate.now(),
|
||||
val nextCutoffAtMillis: Long? = null,
|
||||
val serviceRunning: Boolean = false
|
||||
val serviceRunning: Boolean = false,
|
||||
/** Contrôle activé sur données mobiles mais tunnel VPN non monté : les données ne sont PAS filtrées. */
|
||||
val firewallFault: Boolean = false,
|
||||
val transport: NetworkTransport = NetworkTransport.NONE,
|
||||
/** Mode réellement appliqué au pare-feu : FILTER, BLOCK_ALL ou STOPPED. */
|
||||
val appliedMode: String = "STOPPED",
|
||||
val effectiveWhitelist: Set<String> = emptySet()
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,6 +34,60 @@ class SystemActions(private val activity: ComponentActivity) {
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted -> onNotificationResult?.invoke(granted) }
|
||||
|
||||
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 = FirewallVpnService.isPrepared(activity)
|
||||
|
||||
fun requestVpnPermission(onResult: (Boolean) -> Unit) {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.jttools.datacontrol.ui.about
|
||||
|
||||
/**
|
||||
* Contenu des sous-onglets de « À propos ». Chaque affirmation a été relevée dans le code, le README
|
||||
* ou le CHANGELOG du dépôt (voir le compte rendu de la refonte) : ne rien ajouter ici qui ne s'y vérifie pas.
|
||||
* Le journal, lui, n'est pas dans ce fichier : il est lu depuis CHANGELOG.md (cf. ChangelogParser).
|
||||
*/
|
||||
data class AboutEntry(val title: String, val body: String)
|
||||
data class AboutSection(val heading: String?, val entries: List<AboutEntry>)
|
||||
|
||||
object AboutContent {
|
||||
|
||||
val features = listOf(
|
||||
AboutSection("Données mobiles", listOf(
|
||||
AboutEntry("Suivi de la consommation", "Mesure les Mo consommés depuis le début du cycle, sans permission spéciale (compteur système TrafficStats)."),
|
||||
AboutEntry("Forfait et coût estimé", "Volume, prix, prix par Mo hors-forfait et date de début se règlent ; le total estimé se met à jour en direct, notification comprise."),
|
||||
AboutEntry("Réinitialisation", "Automatique chaque mois à la date anniversaire, ou manuelle depuis le tableau de bord. La consommation peut aussi être recalée à la main sur le décompte de l'opérateur."),
|
||||
AboutEntry("Liste blanche", "Sur données mobiles, seules les applications cochées ont accès à Internet."),
|
||||
AboutEntry("Filtrage Wi-Fi optionnel", "Le Wi-Fi n'est jamais filtré par défaut ; une liste blanche Wi-Fi distincte peut être activée."),
|
||||
AboutEntry("Coupure cyclique", "Toutes les X minutes (réglable), une coupure totale de 30 secondes s'applique aux données mobiles, liste blanche comprise."),
|
||||
AboutEntry("Notification permanente", "Consommation, état en couleur et total estimé. Une alerte « PARE-FEU INACTIF » s'affiche si les données mobiles ne sont plus filtrées."),
|
||||
AboutEntry("Reprise automatique", "Le suivi redémarre après un redémarrage de l'appareil et est relancé périodiquement si le système l'a arrêté.")
|
||||
)),
|
||||
AboutSection("Protection", listOf(
|
||||
AboutEntry("Mot de passe de verrouillage", "Protège l'accès aux Réglages et la désactivation du contrôle."),
|
||||
AboutEntry("Anti-désinstallation", "DataControl peut devenir administrateur de l'appareil : Android refuse alors de le désinstaller tant que la protection est active."),
|
||||
AboutEntry("Journal de diagnostic", "L'onglet Journal montre l'état du VPN, les décisions du pare-feu et, si le service d'accessibilité est activé, chaque application utilisée avec son verdict réseau et lancement. Conservé 15 jours, avec copie et partage."),
|
||||
AboutEntry("Mise à jour intégrée", "Recherche et installation de la dernière version publiée, depuis l'onglet Journal de cette page.")
|
||||
)),
|
||||
AboutSection("Contrôle parental (avec FamilyControl)", listOf(
|
||||
AboutEntry("Appairage par code", "L'appareil s'associe à l'application parent FamilyControl avec un code d'appairage."),
|
||||
AboutEntry("Suivi à distance", "Batterie, position (arrière-plan compris si autorisé), état du pare-feu, trafic quotidien mobile/Wi-Fi et journal d'événements."),
|
||||
AboutEntry("Accès réseau par application", "Le parent autorise ou bloque Internet application par application, sur données mobiles comme sur Wi-Fi."),
|
||||
AboutEntry("Plages horaires, temps d'écran, applications interdites", "Appliqués par un service d'accessibilité : verrouillage hors plage, limite quotidienne globale ou par application, ajout de temps, lancement refusé."),
|
||||
AboutEntry("Condition", "Ces fonctions demandent une configuration Firebase saisie dans Réglages → Contrôle parental, puis l'association à un compte parent.")
|
||||
))
|
||||
)
|
||||
|
||||
val howTo = listOf(
|
||||
"Ouvrez « Réglages » et renseignez le forfait : volume (Mo), prix, prix par Mo hors-forfait, intervalle de coupure. Touchez « Enregistrer ».",
|
||||
"Ouvrez « Liste blanche » et cochez les applications qui doivent garder Internet en données mobiles.",
|
||||
"Dans « Réglages » → « Autorisations », accordez « Pare-feu local (VPN) », « Notifications » et « Optimisation de la batterie ».",
|
||||
"Sur le « Tableau de bord », activez « Activer le contrôle » et acceptez la demande VPN d'Android.",
|
||||
"Coupez le Wi-Fi : l'icône clé du VPN apparaît en données mobiles et la notification affiche l'état.",
|
||||
"Recommandé : définissez un mot de passe dans « Réglages » → « Sécurité »."
|
||||
)
|
||||
|
||||
val everyday = listOf(
|
||||
AboutEntry("Consulter la consommation", "Le Tableau de bord affiche les Mo consommés, le restant, le dépassement et le total estimé."),
|
||||
AboutEntry("Repartir à zéro", "« Réinitialiser le forfait maintenant » sur le Tableau de bord, ou « Consommation actuelle » dans les Réglages pour recaler sur l'opérateur."),
|
||||
AboutEntry("Changer une application autorisée", "Onglet « Liste blanche » : onglets « Données mobiles » et « Wi-Fi », recherche et sélection multiple.")
|
||||
)
|
||||
|
||||
val troubleshooting = listOf(
|
||||
AboutEntry("Je ne vois pas l'icône VPN", "Le pare-feu ne se monte que sur données mobiles, contrôle activé. Sur Wi-Fi, il n'y en a jamais (sauf filtrage Wi-Fi activé). Consultez l'onglet Journal : il donne la raison exacte."),
|
||||
AboutEntry("« PARE-FEU INACTIF » dans la notification", "Les données mobiles ne sont pas filtrées : autorisation VPN retirée ou tunnel non monté. Réactivez le contrôle et lisez le Journal."),
|
||||
AboutEntry("Le contrôle s'est désactivé tout seul", "Un seul VPN peut être actif sous Android : si un autre VPN prend la main, DataControl est révoqué et se désactive."),
|
||||
AboutEntry("Le service d'accessibilité est grisé (Android 13+)", "Ouvrez Infos sur l'application → ⋮ → « Autoriser les paramètres restreints », puis réessayez.")
|
||||
)
|
||||
|
||||
val prerequisites = listOf(
|
||||
AboutSection("Système", listOf(
|
||||
AboutEntry("Android 8.0 ou plus", "Version minimale déclarée par l'application (API 26) ; cible : Android 14 (API 34)."),
|
||||
AboutEntry("Ni root, ni Shizuku", "Le filtrage repose sur le VPN local d'Android."),
|
||||
AboutEntry("Un seul VPN à la fois", "Le pare-feu est incompatible avec un autre VPN actif en même temps.")
|
||||
)),
|
||||
AboutSection("Autorisations", listOf(
|
||||
AboutEntry("VPN (obligatoire)", "Consentement système demandé à l'activation du contrôle."),
|
||||
AboutEntry("Notifications (Android 13+)", "Pour l'état permanent."),
|
||||
AboutEntry("Optimisation de la batterie (recommandé)", "Évite que le système n'arrête la surveillance."),
|
||||
AboutEntry("Liste des applications", "Permission QUERY_ALL_PACKAGES, pour afficher toutes les applications dans la Liste blanche."),
|
||||
AboutEntry("Contrôle parental (facultatif)", "Position (avec « Toujours autoriser » pour l'arrière-plan), administrateur de l'appareil, service d'accessibilité.")
|
||||
)),
|
||||
AboutSection("Réseau", listOf(
|
||||
AboutEntry("Sortant, HTTPS (TCP 443)", "Recherche de mise à jour (git.tuxtech.fr) et, si l'appareil est associé, échanges avec Firebase. Aucun port entrant, aucune redirection de port.")
|
||||
))
|
||||
)
|
||||
|
||||
val security = listOf(
|
||||
AboutSection("Pare-feu", listOf(
|
||||
AboutEntry("Tunnel local sans transfert", "Le VPN ne route ni ne transmet aucun paquet : le trafic des applications non autorisées entre dans le tunnel et y est abandonné."),
|
||||
AboutEntry("IPv4 et IPv6", "Les deux familles d'adresses sont capturées, pour qu'une application bloquée ne contourne pas le pare-feu en IPv6."),
|
||||
AboutEntry("Reprise si le tunnel disparaît", "Si le service VPN est arrêté par le système, le tunnel est remonté automatiquement ; sinon la notification le signale."),
|
||||
AboutEntry("DataControl jamais bloqué", "L'application est toujours exclue du tunnel : la liaison avec le parent ne peut pas être coupée par le pare-feu.")
|
||||
)),
|
||||
AboutSection("Accès à l'application", listOf(
|
||||
AboutEntry("Mot de passe", "Haché en SHA-256 avec un sel aléatoire de 16 octets (SecureRandom), jamais stocké en clair. Limite : un hachage simple, sans étirement de type bcrypt ou Argon2."),
|
||||
AboutEntry("Anti-désinstallation", "Administrateur d'appareil. Limite : la protection peut être retirée depuis les Réglages Android ; si l'appareil est associé, le parent en est alerté. La demande de mot de passe ne s'applique qu'au retrait fait depuis DataControl.")
|
||||
)),
|
||||
AboutSection("Liaison FamilyControl", listOf(
|
||||
AboutEntry("Appairage", "Code aléatoire à durée limitée (10 minutes) créé côté parent. Connexion anonyme Firebase côté appareil, compte e-mail côté parent."),
|
||||
AboutEntry("Règles Firestore", "Fournies avec FamilyControl (firebase/firestore.rules) : l'appareil n'écrit que dans son propre espace et ne peut pas modifier les consignes du parent."),
|
||||
AboutEntry("Service d'accessibilité", "Détecte uniquement l'application au premier plan : la lecture du contenu des écrans est désactivée (canRetrieveWindowContent = false)."),
|
||||
AboutEntry("Applications exemptées", "Lanceur, téléphone, clavier et DataControl ne sont jamais bloqués, pour que l'appareil reste utilisable.")
|
||||
)),
|
||||
AboutSection("Limites connues", listOf(
|
||||
AboutEntry("Signature de l'APK", "Le build release est actuellement signé avec la clé de debug d'Android. Android n'accepte une mise à jour que si elle porte la même signature que l'application installée."),
|
||||
AboutEntry("Téléchargement des mises à jour", "L'APK est récupéré en HTTPS depuis la release Gitea ; aucune empreinte supplémentaire n'est vérifiée par l'application.")
|
||||
))
|
||||
)
|
||||
|
||||
val guarantees = listOf(
|
||||
AboutSection("Vos données", listOf(
|
||||
AboutEntry("Aucune télémétrie", "Le code ne contient ni analytique ni publicité. Les seuls accès réseau sont la recherche de mise à jour et, si l'appareil est associé, Firebase."),
|
||||
AboutEntry("Sans appairage, rien ne sort", "Non associé, l'appareil n'envoie rien ; la recherche de mise à jour ne part que lorsque vous touchez le bouton."),
|
||||
AboutEntry("Le mot de passe reste sur l'appareil", "Il n'est jamais transmis, pas même haché."),
|
||||
AboutEntry("Appareil associé : ce qui est envoyé", "Nom de l'appareil, batterie, position, état du pare-feu et du contrôle, listes d'applications autorisées, consommation du forfait, liste des applications installées, trafic quotidien mobile/Wi-Fi, temps d'écran par application, événements.")
|
||||
)),
|
||||
AboutSection("Ce qui n'est jamais lu", listOf(
|
||||
AboutEntry("Contenu des écrans et messages", "Le service d'accessibilité ne lit pas le contenu des fenêtres."),
|
||||
AboutEntry("Contenu du trafic", "Le VPN ne route rien et n'inspecte aucun paquet.")
|
||||
)),
|
||||
AboutSection("Privilèges et licence", listOf(
|
||||
AboutEntry("Rien d'activé à votre insu", "VPN, position, administrateur d'appareil et accessibilité passent chacun par une autorisation explicite d'Android."),
|
||||
AboutEntry("Licence", "Projet personnel — JT-Tools by Johnny. Le dépôt ne contient pas de fichier de licence : aucun droit de réutilisation n'est accordé par ce texte.")
|
||||
))
|
||||
)
|
||||
|
||||
val choices = listOf(
|
||||
AboutEntry("VpnService plutôt qu'une coupure des données", "Android interdit aux applications normales de couper les données mobiles depuis Android 5. Le VPN local fonctionne sans root ni Shizuku, sur le principe de NetGuard."),
|
||||
AboutEntry("Exclusion d'applications plutôt que relais de paquets", "Les applications autorisées sont exclues du tunnel (addDisallowedApplication) : leur trafic passe normalement, sans passer par l'application."),
|
||||
AboutEntry("TrafficStats plutôt que NetworkStatsManager", "Le compteur système cumulé ne demande aucune permission spéciale. Contrepartie : il repart de zéro au redémarrage, l'application recale alors sa référence."),
|
||||
AboutEntry("Service de premier plan + relance périodique", "Un service permanent et une tâche WorkManager toutes les 15 minutes, car certains systèmes arrêtent agressivement les applications en arrière-plan."),
|
||||
AboutEntry("Kotlin, Jetpack Compose (Material 3), Hilt, DataStore", "Interface déclarative et thème dynamique (Android 12+) ; réglages dans DataStore Preferences."),
|
||||
AboutEntry("HttpURLConnection plutôt qu'un client HTTP tiers", "Seule la mise à jour fait un appel HTTP : pas de dépendance réseau supplémentaire pour cela."),
|
||||
AboutEntry("Firebase sans plugin google-services", "La configuration se saisit dans l'application (Réglages → Contrôle parental) et peut changer sans recompiler ; sans elle, le contrôle à distance est simplement désactivé. Un fichier firebase.properties à la compilation reste possible comme valeur par défaut."),
|
||||
AboutEntry("Service d'accessibilité plutôt que statistiques d'usage", "Permet de renvoyer à l'accueil et d'afficher un écran de blocage dès qu'une application interdite passe au premier plan."),
|
||||
AboutEntry("Administrateur d'appareil plutôt que Device Owner", "Pas de réinitialisation d'usine ni de branchement à un PC ; en contrepartie, la protection est moins robuste."),
|
||||
AboutEntry("Journal embarqué", "Le journal de diagnostic est conservé 15 jours (20 000 lignes au plus) dans un fichier local ; le changelog de cette page est le CHANGELOG.md du dépôt, embarqué dans l'APK.")
|
||||
)
|
||||
|
||||
const val REPO_URL = "https://git.tuxtech.fr/tuxgyver/DataControl"
|
||||
const val AI_NOTICE = "Application développée avec l'assistance de l'IA sous la direction et supervision de Johnny."
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.jttools.datacontrol.ui.about
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -11,23 +14,38 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.ScrollableTabRow
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import com.jttools.datacontrol.R
|
||||
import com.jttools.datacontrol.data.UpdateManager
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDate
|
||||
|
||||
@Composable
|
||||
fun AboutScreen() {
|
||||
@@ -46,35 +64,142 @@ fun AboutScreen() {
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
val updateManager = remember(versionName) { UpdateManager(context.applicationContext, versionName) }
|
||||
val scope = rememberCoroutineScope()
|
||||
var updateStatus by remember { mutableStateOf<String?>(null) }
|
||||
var available by remember { mutableStateOf<UpdateManager.UpdateInfo?>(null) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var progress by remember { mutableIntStateOf(-1) }
|
||||
|
||||
val tabs = listOf(
|
||||
"Fonctionnalités", "Mode d'emploi", "Prérequis", "Sécurité",
|
||||
"Garanties", "Choix techniques", "Journal", "Auteur"
|
||||
)
|
||||
var tab by rememberSaveable { mutableIntStateOf(0) }
|
||||
val changelog = remember { ChangelogParser.load(context) }
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// En-tête toujours visible, quel que soit le sous-onglet.
|
||||
AboutHeader(iconBitmap, versionName)
|
||||
|
||||
ScrollableTabRow(selectedTabIndex = tab, edgePadding = 12.dp) {
|
||||
tabs.forEachIndexed { i, label ->
|
||||
Tab(selected = tab == i, onClick = { tab = i }, text = { Text(label) })
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.about_title), style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
when (tab) {
|
||||
0 -> AboutContent.features.forEach { SectionCard(it) }
|
||||
1 -> {
|
||||
CardBox("Démarrer") {
|
||||
AboutContent.howTo.forEachIndexed { i, step ->
|
||||
Text("${i + 1}. $step", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
CardBox("Au quotidien") { AboutContent.everyday.forEach { EntryRow(it) } }
|
||||
CardBox("Si ça ne marche pas") { AboutContent.troubleshooting.forEach { EntryRow(it) } }
|
||||
}
|
||||
2 -> AboutContent.prerequisites.forEach { SectionCard(it) }
|
||||
3 -> AboutContent.security.forEach { SectionCard(it) }
|
||||
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
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(6.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
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (iconBitmap != null) {
|
||||
Image(
|
||||
bitmap = iconBitmap,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(72.dp)
|
||||
Text(
|
||||
when {
|
||||
busy && info == null -> stringResource(R.string.update_checking)
|
||||
info != null -> stringResource(R.string.update_download_install)
|
||||
else -> stringResource(R.string.update_check)
|
||||
}
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (changelog.isEmpty()) {
|
||||
Text("Le journal des versions (CHANGELOG.md) est introuvable dans cette version de l'application.", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
changelog.forEach { v ->
|
||||
CardBox(v.title) {
|
||||
v.items.forEach { item ->
|
||||
Text(ChangelogParser.styled("• $item"), style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
7 -> AuthorTab()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
@@ -82,37 +207,82 @@ fun AboutScreen() {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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_description), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
HorizontalDivider()
|
||||
Text(stringResource(R.string.about_description), 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()
|
||||
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
|
||||
"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++ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,7 @@ fun DashboardScreen(systemActions: SystemActions) {
|
||||
if (checked) {
|
||||
systemActions.requestNotificationPermission { _ ->
|
||||
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) {
|
||||
viewModel.setControlEnabled(true)
|
||||
DataMonitorService.start(context)
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
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.ui.SystemActions
|
||||
import com.jttools.datacontrol.ui.theme.StateOk
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Carte « Contrôle parental » des réglages : appairage FamilyControl, 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 gw by vm.gatewayState.collectAsStateWithLifecycle()
|
||||
var firebaseText by remember { mutableStateOf("") }
|
||||
var firebaseError by remember { mutableStateOf<String?>(null) }
|
||||
var editFirebase by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
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" ou "unpair".
|
||||
var pendingAction by remember { mutableStateOf<String?>(null) }
|
||||
var pwd by remember { mutableStateOf("") }
|
||||
var pwdError by remember { mutableStateOf(false) }
|
||||
|
||||
// Au retour des écrans système (accessibilité, permissions, admin), on relit l'état réel.
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||
adminActive = systemActions.isAdminActive()
|
||||
locationGranted = systemActions.hasLocationPermission()
|
||||
backgroundGranted = systemActions.hasBackgroundLocationPermission()
|
||||
guardEnabled = systemActions.isGuardEnabled()
|
||||
}
|
||||
|
||||
fun runProtected(action: String, block: () -> Unit) {
|
||||
if (settings.hasPassword) { pendingAction = action; pwd = ""; pwdError = false } else block()
|
||||
}
|
||||
|
||||
fun execute(action: String) {
|
||||
when (action) {
|
||||
"admin" -> { systemActions.deactivateAdmin(); adminActive = systemActions.isAdminActive() }
|
||||
"unpair" -> vm.unpair()
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
// --- Configuration Firebase (saisie ici, sans recompiler) ---
|
||||
Text(stringResource(R.string.family_firebase_title), fontWeight = FontWeight.Medium)
|
||||
if (gw.enabled && !editFirebase) {
|
||||
Text(
|
||||
stringResource(R.string.family_firebase_ok, gw.projectId ?: ""),
|
||||
color = StateOk, style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
OutlinedButton(onClick = { editFirebase = true }) { Text(stringResource(R.string.family_firebase_edit)) }
|
||||
} else {
|
||||
Text(
|
||||
stringResource(R.string.family_firebase_help),
|
||||
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = firebaseText, onValueChange = { firebaseText = it; firebaseError = null },
|
||||
label = { Text(stringResource(R.string.family_firebase_field)) },
|
||||
minLines = 3, maxLines = 6, modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
firebaseError?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Button(
|
||||
enabled = firebaseText.isNotBlank(),
|
||||
onClick = {
|
||||
firebaseError = vm.saveFirebaseConfig(firebaseText)
|
||||
if (firebaseError == null) { firebaseText = ""; editFirebase = false }
|
||||
}
|
||||
) { Text(stringResource(R.string.family_firebase_save)) }
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// --- Appairage ---
|
||||
when {
|
||||
!gw.enabled -> Text(
|
||||
stringResource(R.string.family_unavailable),
|
||||
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
settings.isPaired -> {
|
||||
Text(stringResource(R.string.family_paired), color = StateOk, style = MaterialTheme.typography.bodyMedium)
|
||||
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.uppercase().take(8) },
|
||||
label = { Text(stringResource(R.string.family_code)) },
|
||||
singleLine = true, modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = deviceName, onValueChange = { deviceName = it },
|
||||
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)
|
||||
}
|
||||
Button(
|
||||
enabled = code.length >= 6 && !pairing.busy,
|
||||
onClick = { vm.pair(code, deviceName) }
|
||||
) { Text(stringResource(R.string.family_pair)) }
|
||||
}
|
||||
}
|
||||
|
||||
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 (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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pendingAction?.let { action ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingAction = null },
|
||||
title = { Text(stringResource(R.string.password_gate_title)) },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = pwd, onValueChange = { pwd = it; pwdError = false },
|
||||
label = { Text(stringResource(R.string.password_field_label)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
isError = pwdError, singleLine = true, modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
if (pwdError) Text(
|
||||
stringResource(R.string.password_incorrect),
|
||||
color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
scope.launch {
|
||||
if (vm.verifyPassword(pwd)) { pendingAction = null; execute(action) } else pwdError = true
|
||||
}
|
||||
}) { Text(stringResource(R.string.common_confirm)) }
|
||||
},
|
||||
dismissButton = {
|
||||
OutlinedButton(onClick = { pendingAction = null }) { Text(stringResource(R.string.common_cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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.FirebaseConfig
|
||||
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.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class PairingUiState(val busy: Boolean = false, val error: 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()
|
||||
)
|
||||
|
||||
val gatewayState = remoteSync.gatewayState.stateIn(
|
||||
viewModelScope, SharingStarted.WhileSubscribed(5_000), remoteSync.gatewayState.value
|
||||
)
|
||||
|
||||
/** Enregistre la configuration Firebase collée ; renvoie un message d'erreur, ou null si elle est acceptée. */
|
||||
fun saveFirebaseConfig(text: String): String? {
|
||||
val cfg = FirebaseConfig.parse(text)
|
||||
?: return "Configuration non reconnue : il faut au minimum apiKey, appId et projectId (collez le bloc « firebaseConfig » complet, ou la chaîne « fc1:… » de FamilyControl)."
|
||||
viewModelScope.launch { settingsRepository.setFirebaseConfig(cfg) }
|
||||
return null
|
||||
}
|
||||
|
||||
private val _pairing = MutableStateFlow(PairingUiState())
|
||||
val pairing: StateFlow<PairingUiState> = _pairing.asStateFlow()
|
||||
|
||||
fun pair(code: String, deviceName: String) {
|
||||
_pairing.value = PairingUiState(busy = true)
|
||||
viewModelScope.launch {
|
||||
val result = remoteSync.pair(code, deviceName)
|
||||
_pairing.value = PairingUiState(error = result.exceptionOrNull()?.message)
|
||||
}
|
||||
}
|
||||
|
||||
fun unpair() {
|
||||
viewModelScope.launch { remoteSync.unpair() }
|
||||
}
|
||||
|
||||
suspend fun verifyPassword(password: String): Boolean = settingsRepository.verifyPassword(password)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.jttools.datacontrol.ui.journal
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.jttools.datacontrol.log.AppLog
|
||||
import com.jttools.datacontrol.service.FirewallVpnService
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun JournalScreen() {
|
||||
val context = LocalContext.current
|
||||
val entries by AppLog.entries.collectAsStateWithLifecycle()
|
||||
|
||||
// État en direct du pare-feu, rafraîchi chaque seconde : première chose à regarder si le VPN n'apparaît pas.
|
||||
var live by remember { mutableStateOf(liveStatus(context)) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) { live = liveStatus(context); delay(1000) }
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Journal", style = MaterialTheme.typography.headlineMedium)
|
||||
Card(shape = RoundedCornerShape(16.dp)) {
|
||||
Text(live, fontFamily = FontFamily.Monospace, fontSize = 12.sp, modifier = Modifier.padding(12.dp))
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = {
|
||||
val cm = context.getSystemService(ClipboardManager::class.java)
|
||||
cm.setPrimaryClip(ClipData.newPlainText("Journal DataControl", live + "\n\n" + AppLog.asText()))
|
||||
}) { Text("Copier") }
|
||||
OutlinedButton(onClick = {
|
||||
val send = Intent(Intent.ACTION_SEND).setType("text/plain")
|
||||
.putExtra(Intent.EXTRA_TEXT, live + "\n\n" + AppLog.asText())
|
||||
context.startActivity(Intent.createChooser(send, "Partager le journal").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}) { Text("Partager") }
|
||||
OutlinedButton(onClick = { 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun liveStatus(context: Context): String = buildString {
|
||||
appendLine("VPN autorisé (prepare) : ${FirewallVpnService.isPrepared(context)}")
|
||||
appendLine("Tunnel VPN monté : ${FirewallVpnService.tunnelActive}")
|
||||
appendLine("Suivi des applis (accessibilité) : ${com.jttools.datacontrol.guard.AppGuardService.isEnabled(context)}")
|
||||
appendLine("Conservation du journal : ${AppLog.RETENTION_DAYS} jours")
|
||||
append("Attendu : le tunnel n'est monté QUE sur données mobiles, contrôle activé.")
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.jttools.datacontrol.ui.navigation
|
||||
|
||||
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.Info
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
@@ -25,6 +26,7 @@ import com.jttools.datacontrol.R
|
||||
import com.jttools.datacontrol.ui.SystemActions
|
||||
import com.jttools.datacontrol.ui.about.AboutScreen
|
||||
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.whitelist.WhitelistScreen
|
||||
|
||||
@@ -32,10 +34,11 @@ private sealed class Destination(val route: String, val labelRes: Int, val icon:
|
||||
data object Dashboard : Destination("dashboard", R.string.nav_dashboard, Icons.Filled.Dashboard)
|
||||
data object 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)
|
||||
}
|
||||
|
||||
private val destinations = listOf(Destination.Dashboard, Destination.Settings, Destination.Whitelist, Destination.About)
|
||||
private val destinations = listOf(Destination.Dashboard, Destination.Settings, Destination.Whitelist, Destination.Journal, Destination.About)
|
||||
|
||||
@Composable
|
||||
fun DataControlNavHost(systemActions: SystemActions) {
|
||||
@@ -72,6 +75,7 @@ fun DataControlNavHost(systemActions: SystemActions) {
|
||||
composable(Destination.Dashboard.route) { DashboardScreen(systemActions) }
|
||||
composable(Destination.Settings.route) { SettingsScreen(systemActions) }
|
||||
composable(Destination.Whitelist.route) { WhitelistScreen() }
|
||||
composable(Destination.Journal.route) { JournalScreen() }
|
||||
composable(Destination.About.route) { AboutScreen() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +222,8 @@ private fun SettingsContent(
|
||||
}
|
||||
}
|
||||
|
||||
com.jttools.datacontrol.ui.family.FamilySection(systemActions)
|
||||
|
||||
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_security), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<string name="nav_dashboard">Tableau de bord</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>
|
||||
|
||||
<!-- Dashboard -->
|
||||
@@ -85,10 +86,15 @@
|
||||
<!-- About -->
|
||||
<string name="about_title">À propos</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_check">Rechercher une mise à jour</string>
|
||||
<string name="update_checking">Recherche en cours…</string>
|
||||
<string name="update_up_to_date">Vous avez la dernière version.</string>
|
||||
<string name="update_available">Version %1$s disponible</string>
|
||||
<string name="update_download_install">Télécharger et installer</string>
|
||||
<string name="update_downloading">Téléchargement… %1$d %%</string>
|
||||
<string name="update_need_permission">Autorisez l\'installation d\'applications pour DataControl, puis relancez la mise à jour.</string>
|
||||
<string name="update_error">Échec : %1$s</string>
|
||||
<string name="about_author">Auteur</string>
|
||||
<string name="about_author_value">JT-Tools — Johnny</string>
|
||||
|
||||
@@ -114,4 +120,35 @@
|
||||
<string name="unit_euro">€</string>
|
||||
<string name="common_cancel">Annuler</string>
|
||||
<string name="common_confirm">Confirmer</string>
|
||||
|
||||
<string name="family_title">Contrôle parental (FamilyControl)</string>
|
||||
<string name="family_unavailable">Renseignez d\'abord la configuration Firebase ci-dessus pour pouvoir associer l\'appareil.</string>
|
||||
<string name="family_paired">Appareil associé à FamilyControl : le parent peut gérer les accès réseau, voir la batterie, la position et l\'historique de trafic.</string>
|
||||
<string name="family_unpair">Dissocier cet appareil</string>
|
||||
<string name="family_pair_help">Saisissez le code d\'appairage affiché dans FamilyControl pour associer cet appareil.</string>
|
||||
<string name="family_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 via FamilyControl, y compris en arrière-plan (choisir « Toujours autoriser »).</string>
|
||||
<string name="family_location_grant">Autoriser la position</string>
|
||||
<string name="family_location_background">Autoriser en arrière-plan</string>
|
||||
<string name="guard_service_label">DataControl — limitation des applications</string>
|
||||
<string name="guard_service_description">Détecte l\'application utilisée pour appliquer les plages horaires et les limites de temps définies par les parents. Le contenu des écrans n\'est pas lu.</string>
|
||||
<string name="family_guard_title">Limitation des applications</string>
|
||||
<string name="family_guard_desc">Plages horaires, temps d\'écran et blocage d\'applications décidés par le parent. Nécessite le service d\'accessibilité « DataControl ».</string>
|
||||
<string name="family_guard_hint">Android 13+ : si l\'activation est grisée, ouvrez Infos sur l\'application → ⋮ → « Autoriser les paramètres restreints », puis réessayez.</string>
|
||||
<string name="family_guard_enable">Activer le service</string>
|
||||
<string name="family_firebase_title">Configuration Firebase</string>
|
||||
<string name="family_firebase_ok">Configurée — projet %1$s</string>
|
||||
<string name="family_firebase_edit">Modifier la configuration</string>
|
||||
<string name="family_firebase_help">Collez ici la chaîne « fc1:… » partagée par FamilyControl (bouton « Partager pour DataControl »), ou le bloc « firebaseConfig » de la console Firebase.</string>
|
||||
<string name="family_firebase_field">Configuration Firebase</string>
|
||||
<string name="family_firebase_save">Enregistrer</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:accessibilityEventTypes="typeWindowStateChanged"
|
||||
android:accessibilityFeedbackType="feedbackGeneric"
|
||||
android:canRetrieveWindowContent="false"
|
||||
android:description="@string/guard_service_description"
|
||||
android:notificationTimeout="100" />
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<device-admin>
|
||||
<uses-policies />
|
||||
</device-admin>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="updates" path="updates/" />
|
||||
</paths>
|
||||
@@ -0,0 +1,5 @@
|
||||
# Copier vers firebase.properties (ignoré par git) et renseigner les valeurs de l'app Android
|
||||
# déclarée dans la console Firebase (Paramètres du projet > Vos applications).
|
||||
apiKey=
|
||||
appId=
|
||||
projectId=
|
||||
@@ -16,6 +16,8 @@ hiltNavigationCompose = "1.2.0"
|
||||
datastore = "1.1.1"
|
||||
coroutines = "1.8.1"
|
||||
workManager = "2.9.1"
|
||||
firebaseBom = "33.1.2"
|
||||
coroutinesPlayServices = "1.8.1"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -42,6 +44,10 @@ androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navig
|
||||
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" }
|
||||
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" }
|
||||
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
|
||||
firebase-auth = { group = "com.google.firebase", name = "firebase-auth" }
|
||||
firebase-firestore = { group = "com.google.firebase", name = "firebase-firestore" }
|
||||
kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutinesPlayServices" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
versionName=0.1.1
|
||||
versionCode=2
|
||||
versionName=0.1.8
|
||||
versionCode=9
|
||||
Reference in new issue
Block a user