2 Commits
29 changed files with 1426 additions and 193 deletions

No files matched your search

+12
View File
@@ -1,5 +1,17 @@
# Changelog
## 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.
+37 -24
View File
@@ -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."
+12
View File
@@ -78,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))
+21
View File
@@ -65,6 +65,27 @@
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"
@@ -20,6 +20,7 @@ class DataControlApp : Application() {
override fun onCreate() {
super.onCreate()
com.jttools.datacontrol.log.AppLog.init(this)
CoroutineScope(Dispatchers.IO).launch { settingsRepository.resetPasswordOnce() }
// 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.
@@ -27,7 +27,9 @@ data class AppSettings(
val familyId: String? = null,
val deviceId: String? = null,
/** Horodatage (ms) de la dernière configuration distante appliquée. */
val remoteConfigVersion: Long = 0L
val remoteConfigVersion: Long = 0L,
/** Règles d'usage (plages horaires, limites de temps, applications interdites), au format JSON. */
val rulesJson: String? = null
) {
val isPaired: Boolean get() = !familyId.isNullOrEmpty() && !deviceId.isNullOrEmpty()
@@ -45,6 +45,9 @@ class SettingsRepository @Inject constructor(
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 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")
@@ -74,7 +77,8 @@ class SettingsRepository @Inject constructor(
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
remoteConfigVersion = prefs[Keys.REMOTE_CONFIG_VERSION] ?: defaults.remoteConfigVersion,
rulesJson = prefs[Keys.RULES_JSON]
)
}
@@ -205,6 +209,7 @@ class SettingsRepository @Inject constructor(
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
@@ -225,10 +230,12 @@ class SettingsRepository @Inject constructor(
whitelistedPackagesWifi: Set<String>?,
wifiFilterEnabled: Boolean?,
planLimitMo: Long?,
cutoffIntervalMinutes: Int?
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 }
@@ -265,4 +272,18 @@ class SettingsRepository @Inject constructor(
}
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
}
}
}
@@ -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 } }
}
@@ -16,6 +16,8 @@ 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
@@ -37,7 +39,8 @@ class DeviceReporter @Inject constructor(
@ApplicationContext private val context: Context,
private val remote: RemoteSync,
private val settingsRepository: SettingsRepository,
private val stateHolder: MonitorStateHolder
private val stateHolder: MonitorStateHolder,
private val guardEngine: GuardEngine
) {
private var started = false
private var lastBatterySent = -1 to false
@@ -46,6 +49,8 @@ class DeviceReporter @Inject constructor(
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 || !remote.available) return
@@ -69,6 +74,7 @@ class DeviceReporter @Inject constructor(
registerLocationUpdates(scope)
pushState()
pushTraffic()
pushUsage()
}
delay(REPORT_INTERVAL_MS)
}
@@ -94,6 +100,11 @@ class DeviceReporter @Inject constructor(
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,
@@ -101,6 +112,8 @@ class DeviceReporter @Inject constructor(
"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,
@@ -127,6 +140,14 @@ class DeviceReporter @Inject constructor(
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
@@ -127,7 +127,8 @@ class RemoteSync @Inject constructor(
whitelistedPackagesWifi = strings("whitelistedPackagesWifi"),
wifiFilterEnabled = m["wifiFilterEnabled"] as? Boolean,
planLimitMo = (m["planLimitMo"] as? Number)?.toLong(),
cutoffIntervalMinutes = (m["cutoffIntervalMinutes"] as? Number)?.toInt()
cutoffIntervalMinutes = (m["cutoffIntervalMinutes"] as? Number)?.toInt(),
rulesJson = (m["rules"] as? Map<*, *>)?.let { org.json.JSONObject(it).toString() }
)
}
@@ -157,6 +158,15 @@ class RemoteSync @Inject constructor(
)
}
suspend fun pushUsage(date: String, totalMs: Long, appMs: Map<String, Long>) {
val s = settingsRepository.settingsFlow.first()
val ref = deviceDoc(s) ?: return
write(
ref.collection("usage").document(date),
mapOf("date" to date, "totalMs" to totalMs, "apps" to appMs, "at" to System.currentTimeMillis())
)
}
suspend fun pushEvent(type: String, detail: String) {
val s = settingsRepository.settingsFlow.first()
val ref = deviceDoc(s) ?: return
@@ -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
@@ -42,7 +43,7 @@ class ConnectivityObserver @Inject constructor(
fun publish() {
val values = synchronized(physical) { physical.values.toList() }
// Android privilégie le Wi-Fi quand les deux sont connectés.
trySend(
val result = (
when {
NetworkTransport.WIFI in values -> NetworkTransport.WIFI
NetworkTransport.CELLULAR in values -> NetworkTransport.CELLULAR
@@ -50,12 +51,14 @@ class ConnectivityObserver @Inject constructor(
else -> NetworkTransport.NONE
}
)
trySend(result)
}
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return // notre propre tunnel : ignoré
synchronized(physical) { physical[network] = transportOf(caps) }
AppLog.i("Réseau", "réseau physique ${transportOf(caps)} (caps mis à jour)")
publish()
}
@@ -20,6 +20,8 @@ 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
@@ -49,6 +51,7 @@ class DataMonitorService : LifecycleService() {
@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
@@ -63,6 +66,7 @@ class DataMonitorService : LifecycleService() {
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?) {
@@ -90,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 {
@@ -116,6 +121,7 @@ class DataMonitorService : LifecycleService() {
tick()
}
}
guardEngine.start()
deviceReporter.start(lifecycleScope)
lifecycleScope.launch {
connectivityObserver.observe().collect {
@@ -182,37 +188,47 @@ class DataMonitorService : LifecycleService() {
dataState = DataState.WIFI
}
latestTransport == NetworkTransport.CELLULAR && !vpnRevoked && FirewallVpnService.isPrepared(this) -> {
// La coupure cyclique tourne en continu sur données mobiles, indépendamment du
// dépassement du forfait : elle ne doit pas attendre le hors-forfait pour se déclencher.
val now = System.currentTimeMillis()
val intervalMs = settings.cutoffIntervalMinutes * 60_000L
if (cutoffCycleAnchorMillis == null) {
cutoffCycleAnchorMillis = now
persistCutoffState()
}
val anchor = cutoffCycleAnchorMillis!!
if (inPulse) {
if (now - pulseStartMillis >= PULSE_DURATION_MS) {
inPulse = false
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()
val intervalMs = settings.cutoffIntervalMinutes * 60_000L
if (cutoffCycleAnchorMillis == null) {
cutoffCycleAnchorMillis = now
persistCutoffState()
applyVpnMode(VpnMode.FILTER, settings.whitelistedPackages)
} else {
applyVpnMode(VpnMode.BLOCK_ALL, emptySet())
}
} else if (now - anchor >= intervalMs) {
inPulse = true
pulseStartMillis = now
persistCutoffState()
applyVpnMode(VpnMode.BLOCK_ALL, emptySet())
} else {
applyVpnMode(VpnMode.FILTER, settings.whitelistedPackages)
val anchor = cutoffCycleAnchorMillis!!
if (inPulse) {
if (now - pulseStartMillis >= PULSE_DURATION_MS) {
inPulse = false
cutoffCycleAnchorMillis = now
persistCutoffState()
applyVpnMode(VpnMode.FILTER, settings.whitelistedPackages)
} else {
applyVpnMode(VpnMode.BLOCK_ALL, emptySet())
}
} else if (now - anchor >= intervalMs) {
inPulse = true
pulseStartMillis = now
persistCutoffState()
applyVpnMode(VpnMode.BLOCK_ALL, emptySet())
} else {
applyVpnMode(VpnMode.FILTER, settings.whitelistedPackages)
}
nextCutoffAt = if (inPulse) pulseStartMillis + PULSE_DURATION_MS else anchor + intervalMs
firewallFault = !FirewallVpnService.tunnelActive &&
System.currentTimeMillis() - lastApplyMillis > TUNNEL_RETRY_DELAY_MS
dataState = if (inPulse) DataState.CUT else if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK
}
nextCutoffAt = if (inPulse) pulseStartMillis + PULSE_DURATION_MS else anchor + intervalMs
firewallFault = !FirewallVpnService.tunnelActive &&
System.currentTimeMillis() - lastApplyMillis > TUNNEL_RETRY_DELAY_MS
dataState = if (inPulse) DataState.CUT else if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK
}
else -> {
// Pas de réseau, transport inconnu, ou VPN non préparé/révoqué : rien à filtrer.
@@ -224,6 +240,8 @@ class DataMonitorService : LifecycleService() {
}
}
logDiagnostic(settings)
stateHolder.update {
it.copy(
consumedMo = consumedMo,
@@ -241,6 +259,27 @@ class DataMonitorService : LifecycleService() {
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
@@ -272,14 +311,22 @@ class DataMonitorService : LifecycleService() {
lastAppliedMode = mode
lastAppliedWhitelist = whitelist
lastApplyMillis = System.currentTimeMillis()
when (mode) {
VpnMode.STOPPED -> ContextCompat.startForegroundService(this, FirewallVpnService.stopIntent(this))
VpnMode.FILTER -> ContextCompat.startForegroundService(
this, FirewallVpnService.startFilterIntent(this, whitelist)
)
VpnMode.BLOCK_ALL -> ContextCompat.startForegroundService(
this, FirewallVpnService.startBlockAllIntent(this)
)
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(
this, FirewallVpnService.startFilterIntent(this, whitelist)
)
VpnMode.BLOCK_ALL -> ContextCompat.startForegroundService(
this, FirewallVpnService.startBlockAllIntent(this)
)
}
} catch (e: Exception) {
AppLog.e("Pare-feu", "Impossible de démarrer FirewallVpnService pour le mode $mode", e)
}
}
@@ -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.
startForeground(NOTIF_ID, buildNotification())
try {
startForeground(NOTIF_ID, buildNotification())
} catch (e: Exception) {
AppLog.e(TAG, "startForeground() refusé", e)
}
AppLog.i(TAG, "onStartCommand action=${intent?.action?.substringAfterLast('.') ?: "null (relance système)"}")
when (intent?.action) {
ACTION_STOP -> {
AppLog.i(TAG, "Arrêt du tunnel demandé")
stopTunnel()
stopSelf()
return START_NOT_STICKY
@@ -54,6 +61,7 @@ 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")
@@ -70,10 +78,15 @@ 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 {
@@ -117,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()
@@ -18,7 +18,10 @@ data class MonitorSnapshot(
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
val transport: NetworkTransport = NetworkTransport.NONE,
/** Mode réellement appliqué au pare-feu : FILTER, BLOCK_ALL ou STOPPED. */
val appliedMode: String = "STOPPED",
val effectiveWhitelist: Set<String> = emptySet()
)
/**
@@ -73,6 +73,12 @@ class SystemActions(private val activity: ComponentActivity) {
backgroundLocationLauncher.launch(android.Manifest.permission.ACCESS_BACKGROUND_LOCATION)
}
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) {
@@ -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 n'existent que si l'application a été compilée avec une configuration Firebase et associée à 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", "Firestore et l'authentification sont initialisés depuis un fichier local non versionné (firebase.properties). Sans lui, l'application se compile normalement et le contrôle à distance est simplement désactivé."),
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
@@ -16,11 +19,15 @@ 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
@@ -29,6 +36,7 @@ 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
@@ -37,6 +45,7 @@ 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() {
@@ -62,132 +71,218 @@ fun AboutScreen() {
var busy by remember { mutableStateOf(false) }
var progress by remember { mutableIntStateOf(-1) }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(stringResource(R.string.about_title), style = MaterialTheme.typography.headlineMedium)
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) }
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
Column(
Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
if (iconBitmap != null) {
Image(
bitmap = iconBitmap,
contentDescription = null,
modifier = Modifier.size(72.dp)
)
}
Text(
stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
stringResource(R.string.about_version, versionName),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
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) })
}
}
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(stringResource(R.string.update_section), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
HorizontalDivider()
val info = available
val statusText = when {
progress >= 0 -> stringResource(R.string.update_downloading, progress)
updateStatus != null -> updateStatus!!
info != null -> stringResource(R.string.update_available, info.versionName)
else -> null
}
if (statusText != null) Text(statusText, style = MaterialTheme.typography.bodyMedium)
if (info != null && progress < 0 && info.changelog.isNotBlank()) {
Text(info.changelog, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (progress >= 0) {
LinearProgressIndicator(progress = { progress / 100f }, modifier = Modifier.fillMaxWidth())
}
val needPermission = stringResource(R.string.update_need_permission)
val upToDate = stringResource(R.string.update_up_to_date)
val errorFmt = stringResource(R.string.update_error, "%s")
Button(
enabled = !busy,
onClick = {
busy = true
scope.launch {
if (info == null) {
updateStatus = null
when (val r = updateManager.check()) {
is UpdateManager.CheckResult.UpToDate -> updateStatus = upToDate
is UpdateManager.CheckResult.Available -> available = r.info
is UpdateManager.CheckResult.Error -> updateStatus = errorFmt.replace("%s", r.message)
}
} else {
updateStatus = null
progress = 0
try {
val file = updateManager.download(info) { progress = it }
if (!updateManager.install(file)) updateStatus = needPermission
} catch (e: Exception) {
updateStatus = errorFmt.replace("%s", e.message ?: e.javaClass.simpleName)
}
progress = -1
}
busy = false
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
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)
}
}
) {
Text(
when {
busy && info == null -> stringResource(R.string.update_checking)
info != null -> stringResource(R.string.update_download_install)
else -> stringResource(R.string.update_check)
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.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(stringResource(R.string.update_section), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
HorizontalDivider()
val info = available
val statusText = when {
progress >= 0 -> stringResource(R.string.update_downloading, progress)
updateStatus != null -> updateStatus!!
info != null -> stringResource(R.string.update_available, info.versionName)
else -> null
}
if (statusText != null) Text(statusText, style = MaterialTheme.typography.bodyMedium)
if (info != null && progress < 0 && info.changelog.isNotBlank()) {
Text(info.changelog, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (progress >= 0) {
LinearProgressIndicator(progress = { progress / 100f }, modifier = Modifier.fillMaxWidth())
}
val needPermission = stringResource(R.string.update_need_permission)
val upToDate = stringResource(R.string.update_up_to_date)
val errorFmt = stringResource(R.string.update_error, "%s")
Button(
enabled = !busy,
onClick = {
busy = true
scope.launch {
if (info == null) {
updateStatus = null
when (val r = updateManager.check()) {
is UpdateManager.CheckResult.UpToDate -> updateStatus = upToDate
is UpdateManager.CheckResult.Available -> available = r.info
is UpdateManager.CheckResult.Error -> updateStatus = errorFmt.replace("%s", r.message)
}
} else {
updateStatus = null
progress = 0
try {
val file = updateManager.download(info) { progress = it }
if (!updateManager.install(file)) updateStatus = needPermission
} catch (e: Exception) {
updateStatus = errorFmt.replace("%s", e.message ?: e.javaClass.simpleName)
}
progress = -1
}
busy = false
}
}
)
) {
Text(
when {
busy && info == null -> stringResource(R.string.update_checking)
info != null -> stringResource(R.string.update_download_install)
else -> stringResource(R.string.update_check)
}
)
}
}
}
}
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
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
)
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,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Text(
"Pare-feu de données mobiles pour Android : seules les applications autorisées accèdent à Internet en 4G/5G, le Wi-Fi reste libre. Pilotable à distance par un parent avec FamilyControl.",
style = MaterialTheme.typography.bodySmall
)
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("Android 8.0+", "Sans root", "VPN local", "Kotlin · Compose", "Projet personnel").forEach { Pill(it) }
}
}
}
}
@Composable
private fun Pill(text: String) {
Surface(shape = RoundedCornerShape(50), color = MaterialTheme.colorScheme.secondaryContainer) {
Text(
text,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 3.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
@Composable
private fun CardBox(title: String?, content: @Composable () -> Unit) {
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
if (title != null) {
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
HorizontalDivider()
}
content()
}
}
}
@Composable
private fun SectionCard(section: AboutSection) =
CardBox(section.heading) { section.entries.forEach { EntryRow(it) } }
@Composable
private fun EntryRow(entry: AboutEntry) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(entry.title, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
Text(entry.body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun AuthorTab() {
val uriHandler = LocalUriHandler.current
CardBox(null) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(stringResource(R.string.about_author), style = MaterialTheme.typography.bodyMedium)
Text(stringResource(R.string.about_author_value), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("Dépôt", style = MaterialTheme.typography.bodyMedium)
Text(
"git.tuxtech.fr/tuxgyver/DataControl",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable { runCatching { uriHandler.openUri(AboutContent.REPO_URL) } }
)
}
Text("© ${LocalDate.now().year} JT-Tools — Johnny", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Card(
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
) {
Text(
AboutContent.AI_NOTICE,
modifier = Modifier.padding(18.dp),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
@@ -0,0 +1,54 @@
package com.jttools.datacontrol.ui.about
import android.content.Context
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
data class ChangelogVersion(val title: String, val items: List<String>)
/** Lit CHANGELOG.md (embarqué dans les assets par le build) : versions de la plus récente à la plus ancienne. */
object ChangelogParser {
fun load(context: Context): List<ChangelogVersion> = runCatching {
parse(context.assets.open("CHANGELOG.md").bufferedReader().use { it.readText() })
}.getOrDefault(emptyList())
fun parse(text: String): List<ChangelogVersion> {
val out = mutableListOf<ChangelogVersion>()
var title: String? = null
var items = mutableListOf<String>()
fun flush() { title?.let { out += ChangelogVersion(it, items) }; items = mutableListOf() }
for (raw in text.lines()) {
val line = raw.trim()
when {
line.startsWith("## ") -> { flush(); title = line.removePrefix("## ").trim() }
line.startsWith("- ") && title != null -> items += line.removePrefix("- ").trim()
line.isNotEmpty() && title != null && !line.startsWith("#") && items.isNotEmpty() ->
items[items.lastIndex] = items.last() + " " + line // ligne de continuation
}
}
flush()
return out
}
/** Rend **gras** et `code` du Markdown ; le reste est du texte brut. */
fun styled(text: String): AnnotatedString = buildAnnotatedString {
var i = 0
while (i < text.length) {
when {
text.startsWith("**", i) -> {
val end = text.indexOf("**", i + 2)
if (end > 0) { withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(text.substring(i + 2, end)) }; i = end + 2 } else { append(text[i]); i++ }
}
text[i] == '`' -> {
val end = text.indexOf('`', i + 1)
if (end > 0) { withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { append(text.substring(i + 1, end)) }; i = end + 1 } else { append(text[i]); i++ }
}
else -> { append(text[i]); i++ }
}
}
}
}
@@ -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)
@@ -16,7 +16,6 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
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
@@ -29,6 +28,8 @@ 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
@@ -46,6 +47,7 @@ fun FamilySection(systemActions: SystemActions) {
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) }
@@ -54,10 +56,12 @@ fun FamilySection(systemActions: SystemActions) {
var pwd by remember { mutableStateOf("") }
var pwdError by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
// Au retour des écrans système (accessibilité, permissions, admin), on relit l'état réel.
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
adminActive = systemActions.isAdminActive()
locationGranted = systemActions.hasLocationPermission()
backgroundGranted = systemActions.hasBackgroundLocationPermission()
guardEnabled = systemActions.isGuardEnabled()
}
fun runProtected(action: String, block: () -> Unit) {
@@ -140,6 +144,26 @@ fun FamilySection(systemActions: SystemActions) {
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(
@@ -0,0 +1,121 @@
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.rememberCoroutineScope
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 com.jttools.datacontrol.ui.SystemActions
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun JournalScreen(systemActions: SystemActions) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
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") }
}
Button(onClick = {
scope.launch {
if (!FirewallVpnService.isPrepared(context)) {
AppLog.i("Test VPN", "autorisation VPN absente : demande à l'utilisateur")
systemActions.requestVpnPermission { granted ->
AppLog.i("Test VPN", "autorisation VPN ${if (granted) "accordée" else "REFUSÉE"} — relancez le test")
}
return@launch
}
runVpnTest(context)
}
}, modifier = Modifier.fillMaxWidth()) { Text("Tester le VPN (10 s, coupe les autres applications)") }
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é.")
}
/** Monte le tunnel 10 s quel que soit le réseau : prouve (ou infirme) que le VPN peut se monter sur cet appareil. */
private suspend fun runVpnTest(context: Context) {
val app = context.applicationContext
AppLog.i("Test VPN", "démarrage : demande de montage du tunnel (mode blocage total)")
try {
androidx.core.content.ContextCompat.startForegroundService(app, FirewallVpnService.startBlockAllIntent(app))
} catch (e: Exception) {
AppLog.e("Test VPN", "le service VPN n'a pas pu démarrer", e)
return
}
delay(3_000)
if (FirewallVpnService.tunnelActive) AppLog.i("Test VPN", "SUCCÈS : tunnel monté — l'icône clé doit être visible")
else AppLog.e("Test VPN", "ÉCHEC : tunnel non monté après 3 s (voir les lignes FirewallVpnService ci-dessus)")
delay(7_000)
runCatching {
androidx.core.content.ContextCompat.startForegroundService(app, FirewallVpnService.stopIntent(app))
}
AppLog.i("Test VPN", "fin du test : tunnel arrêté")
}
@@ -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(systemActions) }
composable(Destination.About.route) { AboutScreen() }
}
}
+7 -4
View File
@@ -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,6 @@
<!-- 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>
@@ -142,4 +139,10 @@
<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>
</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" />
+2 -2
View File
@@ -1,2 +1,2 @@
versionName=0.1.3
versionCode=4
versionName=0.1.5
versionCode=6