diff --git a/CHANGELOG.md b/CHANGELOG.md
index 19e12d5..76b7944 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## 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.
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 01afdd5..829c2be 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -65,6 +65,27 @@
android:resource="@xml/file_paths" />
+
+
+
+
+
+
+
+
+
+
?,
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? {
+ 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
+ }
+ }
}
diff --git a/app/src/main/java/com/jttools/datacontrol/guard/AppGuardService.kt b/app/src/main/java/com/jttools/datacontrol/guard/AppGuardService.kt
new file mode 100644
index 0000000..6c3eb91
--- /dev/null
+++ b/app/src/main/java/com/jttools/datacontrol/guard/AppGuardService.kt
@@ -0,0 +1,131 @@
+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 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
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
+ private var exempt: Set = emptySet()
+ private var exemptComputedAt = 0L
+ private var currentPkg: String? = null
+ private var lastBlockAt = 0L
+
+ 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
+ engine.onForeground(null)
+ return
+ }
+ currentPkg = pkg
+ engine.onForeground(pkg)
+ enforce(pkg)
+ }
+
+ private fun enforce(pkg: String) {
+ val block = engine.check(pkg) ?: return
+ val now = System.currentTimeMillis()
+ if (now - lastBlockAt < 2_000) return
+ lastBlockAt = now
+ performGlobalAction(GLOBAL_ACTION_HOME)
+ val label = runCatching {
+ packageManager.getApplicationLabel(packageManager.getApplicationInfo(pkg, 0)).toString()
+ }.getOrDefault(pkg)
+ startActivity(BlockedActivity.intent(this, label, block.message))
+ }
+
+ /** Applications jamais bloquées ni comptées : sans elles l'appareil deviendrait inutilisable. */
+ private fun exemptPackages(): Set {
+ 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)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/jttools/datacontrol/guard/BlockedActivity.kt b/app/src/main/java/com/jttools/datacontrol/guard/BlockedActivity.kt
new file mode 100644
index 0000000..e50d357
--- /dev/null
+++ b/app/src/main/java/com/jttools/datacontrol/guard/BlockedActivity.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/com/jttools/datacontrol/guard/GuardEngine.kt b/app/src/main/java/com/jttools/datacontrol/guard/GuardEngine.kt
new file mode 100644
index 0000000..e8ea8f7
--- /dev/null
+++ b/app/src/main/java/com/jttools/datacontrol/guard/GuardEngine.kt
@@ -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)
+
+/**
+ * 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()
+ 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
+ }
+}
diff --git a/app/src/main/java/com/jttools/datacontrol/guard/Rules.kt b/app/src/main/java/com/jttools/datacontrol/guard/Rules.kt
new file mode 100644
index 0000000..6fc77ce
--- /dev/null
+++ b/app/src/main/java/com/jttools/datacontrol/guard/Rules.kt
@@ -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, 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 = emptyList(),
+ val dailyLimitMin: Int = 0,
+ val appLimits: List = emptyList(),
+ val blocked: Set = emptySet(),
+ val bonusDate: String? = null,
+ val bonusGlobalMin: Int = 0,
+ val bonusApps: List = 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 =
+ if (this == null) emptyList() else (0 until length()).mapNotNull { optJSONObject(it) }
+
+ private fun JSONArray?.ints(): List =
+ if (this == null) emptyList() else (0 until length()).map { optInt(it) }
+
+ private fun JSONArray?.strings(): List =
+ if (this == null) emptyList() else (0 until length()).map { optString(it) }
+ }
+}
diff --git a/app/src/main/java/com/jttools/datacontrol/log/AppLog.kt b/app/src/main/java/com/jttools/datacontrol/log/AppLog.kt
new file mode 100644
index 0000000..7f9992c
--- /dev/null
+++ b/app/src/main/java/com/jttools/datacontrol/log/AppLog.kt
@@ -0,0 +1,67 @@
+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 : garde les [MAX_LINES] dernières lignes en mémoire et dans un fichier
+ * (survit au redémarrage de l'app), consultable dans l'onglet « Journal ».
+ */
+object AppLog {
+ private const val MAX_LINES = 400
+ private const val FILE_NAME = "journal.txt"
+
+ private val io = Executors.newSingleThreadExecutor()
+ private val fmt = SimpleDateFormat("MM-dd HH:mm:ss", Locale.FRANCE)
+ private val lines = ArrayDeque()
+ private val _entries = MutableStateFlow>(emptyList())
+ val entries: StateFlow> = _entries.asStateFlow()
+
+ @Volatile private var file: File? = null
+
+ fun init(context: Context) {
+ if (file != null) return
+ val f = File(context.filesDir, FILE_NAME)
+ file = f
+ synchronized(lines) {
+ runCatching { if (f.exists()) lines.addAll(f.readLines().takeLast(MAX_LINES)) }
+ _entries.value = lines.toList()
+ }
+ i("App", "Journal ouvert")
+ }
+
+ 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 line = "${fmt.format(Date())} $level $tag: $message"
+ synchronized(lines) {
+ lines.addLast(line)
+ while (lines.size > MAX_LINES) lines.removeFirst()
+ _entries.value = lines.toList()
+ val snapshot = lines.toList()
+ io.execute { runCatching { file?.writeText(snapshot.joinToString("\n")) } }
+ }
+ }
+
+ fun clear() {
+ synchronized(lines) {
+ lines.clear()
+ _entries.value = emptyList()
+ io.execute { runCatching { file?.writeText("") } }
+ }
+ }
+
+ fun asText(): String = synchronized(lines) { lines.joinToString("\n") }
+}
diff --git a/app/src/main/java/com/jttools/datacontrol/remote/DeviceReporter.kt b/app/src/main/java/com/jttools/datacontrol/remote/DeviceReporter.kt
index 6a3c1b8..df2649a 100644
--- a/app/src/main/java/com/jttools/datacontrol/remote/DeviceReporter.kt
+++ b/app/src/main/java/com/jttools/datacontrol/remote/DeviceReporter.kt
@@ -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
diff --git a/app/src/main/java/com/jttools/datacontrol/remote/RemoteSync.kt b/app/src/main/java/com/jttools/datacontrol/remote/RemoteSync.kt
index ed7db4e..3efd9b8 100644
--- a/app/src/main/java/com/jttools/datacontrol/remote/RemoteSync.kt
+++ b/app/src/main/java/com/jttools/datacontrol/remote/RemoteSync.kt
@@ -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) {
+ 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
diff --git a/app/src/main/java/com/jttools/datacontrol/service/ConnectivityObserver.kt b/app/src/main/java/com/jttools/datacontrol/service/ConnectivityObserver.kt
index d712da8..b2cfb3f 100644
--- a/app/src/main/java/com/jttools/datacontrol/service/ConnectivityObserver.kt
+++ b/app/src/main/java/com/jttools/datacontrol/service/ConnectivityObserver.kt
@@ -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()
}
diff --git a/app/src/main/java/com/jttools/datacontrol/service/DataMonitorService.kt b/app/src/main/java/com/jttools/datacontrol/service/DataMonitorService.kt
index 650ef06..c7806fa 100644
--- a/app/src/main/java/com/jttools/datacontrol/service/DataMonitorService.kt
+++ b/app/src/main/java/com/jttools/datacontrol/service/DataMonitorService.kt
@@ -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,24 @@ 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 diag = "réseau=$latestTransport contrôle=${settings.controlEnabled} vpnAutorisé=$prepared 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,6 +308,7 @@ class DataMonitorService : LifecycleService() {
lastAppliedMode = mode
lastAppliedWhitelist = whitelist
lastApplyMillis = System.currentTimeMillis()
+ AppLog.i("Pare-feu", "→ mode $mode (${whitelist.size} app(s) autorisée(s))")
when (mode) {
VpnMode.STOPPED -> ContextCompat.startForegroundService(this, FirewallVpnService.stopIntent(this))
VpnMode.FILTER -> ContextCompat.startForegroundService(
diff --git a/app/src/main/java/com/jttools/datacontrol/service/FirewallVpnService.kt b/app/src/main/java/com/jttools/datacontrol/service/FirewallVpnService.kt
index 147cf01..91bec02 100644
--- a/app/src/main/java/com/jttools/datacontrol/service/FirewallVpnService.kt
+++ b/app/src/main/java/com/jttools/datacontrol/service/FirewallVpnService.kt
@@ -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
/**
@@ -38,8 +39,10 @@ class FirewallVpnService : VpnService() {
// 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())
+ 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 +57,7 @@ class FirewallVpnService : VpnService() {
private fun establishTunnel(allowedPackages: Set) {
stopTunnel(keepForeground = true)
+ AppLog.i(TAG, "Établissement du tunnel : ${allowedPackages.size} application(s) exclue(s) du filtrage")
val builder = Builder()
.setSession("DataControl")
@@ -70,10 +74,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 +126,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()
diff --git a/app/src/main/java/com/jttools/datacontrol/ui/SystemActions.kt b/app/src/main/java/com/jttools/datacontrol/ui/SystemActions.kt
index 95ef19f..2ce1ef0 100644
--- a/app/src/main/java/com/jttools/datacontrol/ui/SystemActions.kt
+++ b/app/src/main/java/com/jttools/datacontrol/ui/SystemActions.kt
@@ -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) {
diff --git a/app/src/main/java/com/jttools/datacontrol/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/jttools/datacontrol/ui/dashboard/DashboardScreen.kt
index d3f97ab..aaa2c81 100644
--- a/app/src/main/java/com/jttools/datacontrol/ui/dashboard/DashboardScreen.kt
+++ b/app/src/main/java/com/jttools/datacontrol/ui/dashboard/DashboardScreen.kt
@@ -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)
diff --git a/app/src/main/java/com/jttools/datacontrol/ui/family/FamilySection.kt b/app/src/main/java/com/jttools/datacontrol/ui/family/FamilySection.kt
index b7444da..df4f572 100644
--- a/app/src/main/java/com/jttools/datacontrol/ui/family/FamilySection.kt
+++ b/app/src/main/java/com/jttools/datacontrol/ui/family/FamilySection.kt
@@ -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(
diff --git a/app/src/main/java/com/jttools/datacontrol/ui/journal/JournalScreen.kt b/app/src/main/java/com/jttools/datacontrol/ui/journal/JournalScreen.kt
new file mode 100644
index 0000000..4afa631
--- /dev/null
+++ b/app/src/main/java/com/jttools/datacontrol/ui/journal/JournalScreen.kt
@@ -0,0 +1,83 @@
+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}")
+ append("Attendu : le tunnel n'est monté QUE sur données mobiles, contrôle activé.")
+}
diff --git a/app/src/main/java/com/jttools/datacontrol/ui/navigation/DataControlNavHost.kt b/app/src/main/java/com/jttools/datacontrol/ui/navigation/DataControlNavHost.kt
index 5911a60..e6a004f 100644
--- a/app/src/main/java/com/jttools/datacontrol/ui/navigation/DataControlNavHost.kt
+++ b/app/src/main/java/com/jttools/datacontrol/ui/navigation/DataControlNavHost.kt
@@ -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() }
}
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 47e3f0f..bafa5ee 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -6,6 +6,7 @@
Tableau de bord
Réglages
Liste blanche
+ Journal
À propos
@@ -142,4 +143,10 @@
Partagée avec le parent via FamilyControl, y compris en arrière-plan (choisir « Toujours autoriser »).
Autoriser la position
Autoriser en arrière-plan
+ DataControl — limitation des applications
+ 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.
+ Limitation des applications
+ Plages horaires, temps d\'écran et blocage d\'applications décidés par le parent. Nécessite le service d\'accessibilité « DataControl ».
+ Android 13+ : si l\'activation est grisée, ouvrez Infos sur l\'application → ⋮ → « Autoriser les paramètres restreints », puis réessayez.
+ Activer le service
diff --git a/app/src/main/res/xml/accessibility_guard.xml b/app/src/main/res/xml/accessibility_guard.xml
new file mode 100644
index 0000000..ff3e9fa
--- /dev/null
+++ b/app/src/main/res/xml/accessibility_guard.xml
@@ -0,0 +1,7 @@
+
+
diff --git a/version.properties b/version.properties
index 679bf8f..d9161cb 100644
--- a/version.properties
+++ b/version.properties
@@ -1,2 +1,2 @@
-versionName=0.1.3
-versionCode=4
+versionName=0.1.4
+versionCode=5