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