release: v0.1.4 — onglet Journal (diagnostic VPN), limitation des applications (plages horaires, temps d'écran)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
tuxgyverandClaude Sonnet 5 committed 2026-09-20 18:03:00 +02:00
1 parent 82d47fcd72
commit 6086e8aeff
23 files changed
+816 -40

No files matched your search

@@ -20,6 +20,7 @@ class DataControlApp : Application() {
override fun onCreate() {
super.onCreate()
com.jttools.datacontrol.log.AppLog.init(this)
CoroutineScope(Dispatchers.IO).launch { settingsRepository.resetPasswordOnce() }
// Filet de sécurité : relance périodiquement DataMonitorService s'il a été tué en arrière-plan.
// enqueueUniquePeriodicWork + KEEP : sans effet si déjà planifié, donc sûr à appeler à chaque démarrage.
@@ -27,7 +27,9 @@ data class AppSettings(
val familyId: String? = null,
val deviceId: String? = null,
/** Horodatage (ms) de la dernière configuration distante appliquée. */
val remoteConfigVersion: Long = 0L
val remoteConfigVersion: Long = 0L,
/** Règles d'usage (plages horaires, limites de temps, applications interdites), au format JSON. */
val rulesJson: String? = null
) {
val isPaired: Boolean get() = !familyId.isNullOrEmpty() && !deviceId.isNullOrEmpty()
@@ -45,6 +45,9 @@ class SettingsRepository @Inject constructor(
val FAMILY_ID = stringPreferencesKey("family_id")
val DEVICE_ID = stringPreferencesKey("device_id")
val REMOTE_CONFIG_VERSION = longPreferencesKey("remote_config_version")
val RULES_JSON = stringPreferencesKey("rules_json")
val USAGE_DAY = stringPreferencesKey("usage_day")
val USAGE_JSON = stringPreferencesKey("usage_json")
val TRAFFIC_DAY = stringPreferencesKey("traffic_day")
val TRAFFIC_MOBILE_ACC = longPreferencesKey("traffic_mobile_acc")
val TRAFFIC_WIFI_ACC = longPreferencesKey("traffic_wifi_acc")
@@ -74,7 +77,8 @@ class SettingsRepository @Inject constructor(
cutoffPulseStartMillis = prefs[Keys.CUTOFF_PULSE_START_MILLIS] ?: defaults.cutoffPulseStartMillis,
familyId = prefs[Keys.FAMILY_ID],
deviceId = prefs[Keys.DEVICE_ID],
remoteConfigVersion = prefs[Keys.REMOTE_CONFIG_VERSION] ?: defaults.remoteConfigVersion
remoteConfigVersion = prefs[Keys.REMOTE_CONFIG_VERSION] ?: defaults.remoteConfigVersion,
rulesJson = prefs[Keys.RULES_JSON]
)
}
@@ -205,6 +209,7 @@ class SettingsRepository @Inject constructor(
it.remove(Keys.FAMILY_ID)
it.remove(Keys.DEVICE_ID)
it.remove(Keys.REMOTE_CONFIG_VERSION)
it.remove(Keys.RULES_JSON)
} else {
it[Keys.FAMILY_ID] = familyId
it[Keys.DEVICE_ID] = deviceId
@@ -225,10 +230,12 @@ class SettingsRepository @Inject constructor(
whitelistedPackagesWifi: Set<String>?,
wifiFilterEnabled: Boolean?,
planLimitMo: Long?,
cutoffIntervalMinutes: Int?
cutoffIntervalMinutes: Int?,
rulesJson: String?
) {
context.dataStore.edit { prefs ->
if ((prefs[Keys.REMOTE_CONFIG_VERSION] ?: 0L) >= version) return@edit
rulesJson?.let { prefs[Keys.RULES_JSON] = it }
prefs[Keys.REMOTE_CONFIG_VERSION] = version
controlEnabled?.let { prefs[Keys.CONTROL_ENABLED] = it }
whitelistedPackages?.let { prefs[Keys.WHITELISTED_PACKAGES] = it }
@@ -265,4 +272,18 @@ class SettingsRepository @Inject constructor(
}
return result
}
suspend fun loadUsage(): Pair<String, String>? {
val prefs = context.dataStore.data.first()
val day = prefs[Keys.USAGE_DAY] ?: return null
val json = prefs[Keys.USAGE_JSON] ?: return null
return day to json
}
suspend fun saveUsage(day: String, json: String) {
context.dataStore.edit {
it[Keys.USAGE_DAY] = day
it[Keys.USAGE_JSON] = json
}
}
}
@@ -0,0 +1,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<String> = 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<String> {
val now = System.currentTimeMillis()
if (exempt.isNotEmpty() && now - exemptComputedAt < EXEMPT_TTL_MS) return exempt
val set = mutableSetOf(packageName, "android", "com.android.systemui", "com.android.phone", "com.android.emergency")
runCatching {
val home = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
packageManager.queryIntentActivities(home, PackageManager.MATCH_DEFAULT_ONLY)
.forEach { set += it.activityInfo.packageName }
}
runCatching { getSystemService(TelecomManager::class.java).defaultDialerPackage?.let { set += it } }
runCatching {
getSystemService(InputMethodManager::class.java).enabledInputMethodList.forEach { set += it.packageName }
}
exempt = set
exemptComputedAt = now
return set
}
override fun onInterrupt() = Unit
override fun onDestroy() {
runCatching { unregisterReceiver(screenReceiver) }
scope.cancel()
super.onDestroy()
}
companion object {
private const val CHECK_INTERVAL_MS = 10_000L
private const val EXEMPT_TTL_MS = 5 * 60_000L
fun isEnabled(context: Context): Boolean {
val enabled = Settings.Secure.getString(
context.contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES
) ?: return false
val me = ComponentName(context, AppGuardService::class.java)
return enabled.split(':').any {
it.equals(me.flattenToString(), true) || it.equals(me.flattenToShortString(), true)
}
}
}
}
@@ -0,0 +1,76 @@
package com.jttools.datacontrol.guard
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.jttools.datacontrol.ui.theme.DataControlTheme
/** Écran affiché par-dessus une application bloquée : explique pourquoi et renvoie à l'accueil. */
class BlockedActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val label = intent.getStringExtra(EXTRA_LABEL).orEmpty()
val message = intent.getStringExtra(EXTRA_MESSAGE).orEmpty()
setContent {
DataControlTheme {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.errorContainer) {
Column(
Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(Icons.Filled.Lock, contentDescription = null, modifier = Modifier.size(72.dp))
Spacer(Modifier.height(20.dp))
Text(label, style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center)
Spacer(Modifier.height(10.dp))
Text(message, style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center)
Spacer(Modifier.height(28.dp))
Button(onClick = { goHome() }) { Text("Retour à l'accueil") }
}
}
}
}
}
@Deprecated("Deprecated in Java")
override fun onBackPressed() = goHome()
private fun goHome() {
startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
finish()
}
companion object {
private const val EXTRA_LABEL = "label"
private const val EXTRA_MESSAGE = "message"
fun intent(context: Context, label: String, message: String): Intent =
Intent(context, BlockedActivity::class.java)
.putExtra(EXTRA_LABEL, label)
.putExtra(EXTRA_MESSAGE, message)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
}
@@ -0,0 +1,172 @@
package com.jttools.datacontrol.guard
import android.os.SystemClock
import com.jttools.datacontrol.data.SettingsRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.json.JSONObject
import java.time.LocalDate
import java.time.LocalDateTime
import javax.inject.Inject
import javax.inject.Singleton
data class Block(val message: String)
data class UsageSnapshot(val date: String, val totalMs: Long, val appMs: Map<String, Long>)
/**
* Cœur de la limitation d'usage : cumule le temps d'écran (total et par application) à partir des
* changements d'application au premier plan, et décide si une application doit être bloquée
* (interdite, hors plage horaire, temps quotidien ou temps d'application épuisé).
*
* L'appelant ([AppGuardService]) ne signale JAMAIS les applications exemptées (lanceur, téléphone,
* clavier, DataControl) : elles ne comptent pas et ne sont jamais bloquées.
*/
@Singleton
class GuardEngine @Inject constructor(private val settings: SettingsRepository) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val lock = Any()
@Volatile var rules: Rules = Rules.NONE
private set
private var started = false
private var day = LocalDate.now().toString()
private var totalMs = 0L
private val appMs = HashMap<String, Long>()
private var currentPkg: String? = null
private var lastMark = SystemClock.elapsedRealtime()
private var screenOn = true
private var dirty = false
fun start() {
synchronized(lock) { if (started) return; started = true }
scope.launch {
settings.loadUsage()?.let { (savedDay, json) -> restore(savedDay, json) }
settings.settingsFlow.map { it.rulesJson }.distinctUntilChanged().collect { rules = Rules.parse(it) }
}
scope.launch {
while (isActive) {
delay(PERSIST_INTERVAL_MS)
tick()
persist()
}
}
}
private fun restore(savedDay: String, json: String) = synchronized(lock) {
if (savedDay != LocalDate.now().toString()) return@synchronized
runCatching {
val o = JSONObject(json)
day = savedDay
totalMs = o.optLong("total")
appMs.clear()
o.optJSONObject("apps")?.let { a -> a.keys().forEach { k -> appMs[k] = a.optLong(k) } }
}
}
private suspend fun persist() {
val snap = synchronized(lock) { if (!dirty) return; dirty = false; usageLocked() }
val json = JSONObject().put("total", snap.totalMs).put("apps", JSONObject(snap.appMs as Map<*, *>)).toString()
settings.saveUsage(snap.date, json)
}
fun usage(): UsageSnapshot = synchronized(lock) { flush(); usageLocked() }
private fun usageLocked() = UsageSnapshot(day, totalMs, HashMap(appMs))
/** [pkg] = application désormais au premier plan, ou null si exemptée / aucune. */
fun onForeground(pkg: String?) = synchronized(lock) { flush(); currentPkg = pkg }
fun onScreen(on: Boolean) = synchronized(lock) { flush(); screenOn = on }
fun tick() = synchronized(lock) { flush() }
private fun flush() {
val now = SystemClock.elapsedRealtime()
val today = LocalDate.now().toString()
if (today != day) {
day = today; totalMs = 0; appMs.clear(); dirty = true
}
val pkg = currentPkg
if (pkg != null && screenOn) {
// Plafonné : si le service a été gelé longtemps, on ne comptabilise pas tout l'intervalle.
val delta = (now - lastMark).coerceIn(0, MAX_DELTA_MS)
if (delta > 0) {
totalMs += delta
appMs[pkg] = (appMs[pkg] ?: 0L) + delta
dirty = true
}
}
lastMark = now
}
/** Vrai si l'appareil est hors de toutes les plages horaires autorisées. */
fun scheduleLocked(): Boolean = synchronized(lock) {
val r = rules
r.scheduleEnabled && !inWindow(r, LocalDateTime.now())
}
fun cutsMobileNow(): Boolean = synchronized(lock) {
val r = rules
r.scheduleEnabled && r.scheduleCutsMobile && !inWindow(r, LocalDateTime.now())
}
fun check(pkg: String): Block? = synchronized(lock) {
flush()
val r = rules
if (r.isEmpty) return null
val now = LocalDateTime.now()
if (pkg in r.blocked) return Block("Cette application est bloquée par vos parents.")
if (r.scheduleEnabled && !inWindow(r, now)) {
return Block("L'appareil est verrouillé en dehors des plages horaires. ${nextOpening(r, now)}")
}
val bonusToday = r.bonusDate == day
if (r.dailyLimitMin > 0) {
val allowedMs = (r.dailyLimitMin + if (bonusToday) r.bonusGlobalMin else 0) * MIN_MS
if (totalMs >= allowedMs) return Block("Le temps d'écran de la journée est écoulé.")
}
r.appLimits.firstOrNull { it.pkg == pkg }?.let { lim ->
val bonus = if (bonusToday) r.bonusApps.firstOrNull { it.pkg == pkg }?.minutes ?: 0 else 0
if ((appMs[pkg] ?: 0L) >= (lim.minutes + bonus) * MIN_MS) {
return Block("Le temps alloué à cette application est écoulé.")
}
}
null
}
private fun inWindow(r: Rules, now: LocalDateTime): Boolean {
val dow = now.dayOfWeek.value
val prev = if (dow == 1) 7 else dow - 1
val minute = now.hour * 60 + now.minute
return r.windows.any { w ->
if (w.startMin <= w.endMin) dow in w.days && minute >= w.startMin && minute < w.endMin
else (dow in w.days && minute >= w.startMin) || (prev in w.days && minute < w.endMin)
}
}
private fun nextOpening(r: Rules, now: LocalDateTime): String {
val minute = now.hour * 60 + now.minute
for (offset in 0..7) {
val dow = ((now.dayOfWeek.value - 1 + offset) % 7) + 1
val start = r.windows.filter { dow in it.days && (offset > 0 || it.startMin > minute) }
.minOfOrNull { it.startMin } ?: continue
val label = "%02d:%02d".format(start / 60, start % 60)
return if (offset == 0) "Reprise à $label." else "Reprise à $label (dans $offset j)."
}
return ""
}
private companion object {
const val MIN_MS = 60_000L
const val MAX_DELTA_MS = 120_000L
const val PERSIST_INTERVAL_MS = 30_000L
}
}
@@ -0,0 +1,67 @@
package com.jttools.datacontrol.guard
import org.json.JSONArray
import org.json.JSONObject
/** Plage pendant laquelle l'appareil est utilisable. Minutes depuis minuit ; jours ISO (1 = lundi … 7 = dimanche). */
data class TimeWindow(val days: Set<Int>, val startMin: Int, val endMin: Int)
data class AppLimit(val pkg: String, val minutes: Int)
/**
* Règles d'usage envoyées par FamilyControl (champ `rules` de la config distante).
* Les temps s'entendent par jour ; le bonus n'est valable que le jour [bonusDate].
*/
data class Rules(
val scheduleEnabled: Boolean = false,
val scheduleCutsMobile: Boolean = true,
val windows: List<TimeWindow> = emptyList(),
val dailyLimitMin: Int = 0,
val appLimits: List<AppLimit> = emptyList(),
val blocked: Set<String> = emptySet(),
val bonusDate: String? = null,
val bonusGlobalMin: Int = 0,
val bonusApps: List<AppLimit> = emptyList()
) {
val isEmpty: Boolean
get() = !scheduleEnabled && dailyLimitMin <= 0 && appLimits.isEmpty() && blocked.isEmpty()
companion object {
val NONE = Rules()
fun parse(json: String?): Rules {
if (json.isNullOrBlank()) return NONE
return runCatching {
val o = JSONObject(json)
val sch = o.optJSONObject("schedule")
val bonus = o.optJSONObject("bonus")
Rules(
scheduleEnabled = sch?.optBoolean("enabled", false) ?: false,
scheduleCutsMobile = sch?.optBoolean("cutsMobile", true) ?: true,
windows = sch?.optJSONArray("windows").objects().map { w ->
TimeWindow(
days = w.optJSONArray("days").ints().toSet(),
startMin = w.optInt("start", 0),
endMin = w.optInt("end", 0)
)
},
dailyLimitMin = o.optInt("dailyLimitMin", 0),
appLimits = o.optJSONArray("appLimits").objects().map { AppLimit(it.optString("pkg"), it.optInt("min")) },
blocked = o.optJSONArray("blocked").strings().toSet(),
bonusDate = bonus?.optString("date")?.takeIf { it.isNotEmpty() },
bonusGlobalMin = bonus?.optInt("globalMin", 0) ?: 0,
bonusApps = bonus?.optJSONArray("apps").objects().map { AppLimit(it.optString("pkg"), it.optInt("min")) }
)
}.getOrDefault(NONE)
}
private fun JSONArray?.objects(): List<JSONObject> =
if (this == null) emptyList() else (0 until length()).mapNotNull { optJSONObject(it) }
private fun JSONArray?.ints(): List<Int> =
if (this == null) emptyList() else (0 until length()).map { optInt(it) }
private fun JSONArray?.strings(): List<String> =
if (this == null) emptyList() else (0 until length()).map { optString(it) }
}
}
@@ -0,0 +1,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<String>()
private val _entries = MutableStateFlow<List<String>>(emptyList())
val entries: StateFlow<List<String>> = _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") }
}
@@ -16,6 +16,8 @@ import androidx.core.content.ContextCompat
import com.jttools.datacontrol.BuildConfig
import com.jttools.datacontrol.admin.AdminController
import com.jttools.datacontrol.data.SettingsRepository
import com.jttools.datacontrol.guard.AppGuardService
import com.jttools.datacontrol.guard.GuardEngine
import com.jttools.datacontrol.service.FirewallVpnService
import com.jttools.datacontrol.service.MonitorStateHolder
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -37,7 +39,8 @@ class DeviceReporter @Inject constructor(
@ApplicationContext private val context: Context,
private val remote: RemoteSync,
private val settingsRepository: SettingsRepository,
private val stateHolder: MonitorStateHolder
private val stateHolder: MonitorStateHolder,
private val guardEngine: GuardEngine
) {
private var started = false
private var lastBatterySent = -1 to false
@@ -46,6 +49,8 @@ class DeviceReporter @Inject constructor(
private var lastStateSignature = ""
private var lastStateSentAt = 0L
private var locationRegistered = false
private var lastGuardEnabled: Boolean? = null
private var lastUsageSentTotal = -1L
fun start(scope: CoroutineScope) {
if (started || !remote.available) return
@@ -69,6 +74,7 @@ class DeviceReporter @Inject constructor(
registerLocationUpdates(scope)
pushState()
pushTraffic()
pushUsage()
}
delay(REPORT_INTERVAL_MS)
}
@@ -94,6 +100,11 @@ class DeviceReporter @Inject constructor(
private suspend fun pushState() {
val s = settingsRepository.settingsFlow.first()
val snap = stateHolder.snapshot.value
val guardEnabled = AppGuardService.isEnabled(context)
if (lastGuardEnabled == true && !guardEnabled) {
remote.pushEvent("guard_disabled", "Service d'accessibilité désactivé : les limites d'applications ne sont plus appliquées")
}
lastGuardEnabled = guardEnabled
val state = mapOf(
"controlEnabled" to s.controlEnabled,
"transport" to snap.transport.name,
@@ -101,6 +112,8 @@ class DeviceReporter @Inject constructor(
"vpnPrepared" to FirewallVpnService.isPrepared(context),
"tunnelActive" to FirewallVpnService.tunnelActive,
"adminActive" to AdminController.isActive(context),
"guardEnabled" to guardEnabled,
"rulesActive" to !guardEngine.rules.isEmpty,
"consumedMo" to snap.consumedMo,
"planLimitMo" to s.planLimitMo,
"cutoffIntervalMinutes" to s.cutoffIntervalMinutes,
@@ -127,6 +140,14 @@ class DeviceReporter @Inject constructor(
remote.pushTraffic(day)
}
private suspend fun pushUsage() {
val u = guardEngine.usage()
// Ne renvoie que si le temps d'écran a évolué (ou changement de jour).
if (u.totalMs == lastUsageSentTotal) return
lastUsageSentTotal = u.totalMs
remote.pushUsage(u.date, u.totalMs, u.appMs)
}
@SuppressLint("MissingPermission")
private fun registerLocationUpdates(scope: CoroutineScope) {
if (locationRegistered) return
@@ -127,7 +127,8 @@ class RemoteSync @Inject constructor(
whitelistedPackagesWifi = strings("whitelistedPackagesWifi"),
wifiFilterEnabled = m["wifiFilterEnabled"] as? Boolean,
planLimitMo = (m["planLimitMo"] as? Number)?.toLong(),
cutoffIntervalMinutes = (m["cutoffIntervalMinutes"] as? Number)?.toInt()
cutoffIntervalMinutes = (m["cutoffIntervalMinutes"] as? Number)?.toInt(),
rulesJson = (m["rules"] as? Map<*, *>)?.let { org.json.JSONObject(it).toString() }
)
}
@@ -157,6 +158,15 @@ class RemoteSync @Inject constructor(
)
}
suspend fun pushUsage(date: String, totalMs: Long, appMs: Map<String, Long>) {
val s = settingsRepository.settingsFlow.first()
val ref = deviceDoc(s) ?: return
write(
ref.collection("usage").document(date),
mapOf("date" to date, "totalMs" to totalMs, "apps" to appMs, "at" to System.currentTimeMillis())
)
}
suspend fun pushEvent(type: String, detail: String) {
val s = settingsRepository.settingsFlow.first()
val ref = deviceDoc(s) ?: return
@@ -5,6 +5,7 @@ import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import com.jttools.datacontrol.log.AppLog
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
@@ -42,7 +43,7 @@ class ConnectivityObserver @Inject constructor(
fun publish() {
val values = synchronized(physical) { physical.values.toList() }
// Android privilégie le Wi-Fi quand les deux sont connectés.
trySend(
val result = (
when {
NetworkTransport.WIFI in values -> NetworkTransport.WIFI
NetworkTransport.CELLULAR in values -> NetworkTransport.CELLULAR
@@ -50,12 +51,14 @@ class ConnectivityObserver @Inject constructor(
else -> NetworkTransport.NONE
}
)
trySend(result)
}
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return // notre propre tunnel : ignoré
synchronized(physical) { physical[network] = transportOf(caps) }
AppLog.i("Réseau", "réseau physique ${transportOf(caps)} (caps mis à jour)")
publish()
}
@@ -20,6 +20,8 @@ import com.jttools.datacontrol.data.AppSettings
import com.jttools.datacontrol.data.DataState
import com.jttools.datacontrol.data.SettingsRepository
import com.jttools.datacontrol.data.UsageRepository
import com.jttools.datacontrol.guard.GuardEngine
import com.jttools.datacontrol.log.AppLog
import com.jttools.datacontrol.remote.DeviceReporter
import com.jttools.datacontrol.remote.RemoteSync
import dagger.hilt.android.AndroidEntryPoint
@@ -49,6 +51,7 @@ class DataMonitorService : LifecycleService() {
@Inject lateinit var stateHolder: MonitorStateHolder
@Inject lateinit var deviceReporter: DeviceReporter
@Inject lateinit var remoteSync: RemoteSync
@Inject lateinit var guardEngine: GuardEngine
private var latestSettings: AppSettings = AppSettings()
private var latestTransport: NetworkTransport = NetworkTransport.NONE
@@ -63,6 +66,7 @@ class DataMonitorService : LifecycleService() {
private var pulseStartMillis: Long = 0L
private var settingsLoaded = false
private var lastControlEnabled: Boolean? = null
private var lastDiagnostic = ""
private val vpnRevokedReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
@@ -90,6 +94,7 @@ class DataMonitorService : LifecycleService() {
ContextCompat.RECEIVER_NOT_EXPORTED
)
startForeground(NOTIF_ID, buildNotification(stateHolder.snapshot.value))
AppLog.i("Service", "DataMonitorService démarré")
lifecycleScope.launch {
settingsRepository.settingsFlow.collect {
@@ -116,6 +121,7 @@ class DataMonitorService : LifecycleService() {
tick()
}
}
guardEngine.start()
deviceReporter.start(lifecycleScope)
lifecycleScope.launch {
connectivityObserver.observe().collect {
@@ -182,37 +188,47 @@ class DataMonitorService : LifecycleService() {
dataState = DataState.WIFI
}
latestTransport == NetworkTransport.CELLULAR && !vpnRevoked && FirewallVpnService.isPrepared(this) -> {
// La coupure cyclique tourne en continu sur données mobiles, indépendamment du
// dépassement du forfait : elle ne doit pas attendre le hors-forfait pour se déclencher.
val now = System.currentTimeMillis()
val intervalMs = settings.cutoffIntervalMinutes * 60_000L
if (cutoffCycleAnchorMillis == null) {
cutoffCycleAnchorMillis = now
persistCutoffState()
}
val anchor = cutoffCycleAnchorMillis!!
if (inPulse) {
if (now - pulseStartMillis >= PULSE_DURATION_MS) {
inPulse = false
if (guardEngine.cutsMobileNow()) {
// Hors plage horaire autorisée : couper les données mobiles (règle du parent).
applyVpnMode(VpnMode.BLOCK_ALL, emptySet())
resetCutoffCycle()
firewallFault = !FirewallVpnService.tunnelActive &&
System.currentTimeMillis() - lastApplyMillis > TUNNEL_RETRY_DELAY_MS
dataState = DataState.CUT
} else {
// La coupure cyclique tourne en continu sur données mobiles, indépendamment du
// dépassement du forfait : elle ne doit pas attendre le hors-forfait pour se déclencher.
val now = System.currentTimeMillis()
val intervalMs = settings.cutoffIntervalMinutes * 60_000L
if (cutoffCycleAnchorMillis == null) {
cutoffCycleAnchorMillis = now
persistCutoffState()
applyVpnMode(VpnMode.FILTER, settings.whitelistedPackages)
} else {
applyVpnMode(VpnMode.BLOCK_ALL, emptySet())
}
} else if (now - anchor >= intervalMs) {
inPulse = true
pulseStartMillis = now
persistCutoffState()
applyVpnMode(VpnMode.BLOCK_ALL, emptySet())
} else {
applyVpnMode(VpnMode.FILTER, settings.whitelistedPackages)
val anchor = cutoffCycleAnchorMillis!!
if (inPulse) {
if (now - pulseStartMillis >= PULSE_DURATION_MS) {
inPulse = false
cutoffCycleAnchorMillis = now
persistCutoffState()
applyVpnMode(VpnMode.FILTER, settings.whitelistedPackages)
} else {
applyVpnMode(VpnMode.BLOCK_ALL, emptySet())
}
} else if (now - anchor >= intervalMs) {
inPulse = true
pulseStartMillis = now
persistCutoffState()
applyVpnMode(VpnMode.BLOCK_ALL, emptySet())
} else {
applyVpnMode(VpnMode.FILTER, settings.whitelistedPackages)
}
nextCutoffAt = if (inPulse) pulseStartMillis + PULSE_DURATION_MS else anchor + intervalMs
firewallFault = !FirewallVpnService.tunnelActive &&
System.currentTimeMillis() - lastApplyMillis > TUNNEL_RETRY_DELAY_MS
dataState = if (inPulse) DataState.CUT else if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK
}
nextCutoffAt = if (inPulse) pulseStartMillis + PULSE_DURATION_MS else anchor + intervalMs
firewallFault = !FirewallVpnService.tunnelActive &&
System.currentTimeMillis() - lastApplyMillis > TUNNEL_RETRY_DELAY_MS
dataState = if (inPulse) DataState.CUT else if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK
}
else -> {
// Pas de réseau, transport inconnu, ou VPN non préparé/révoqué : rien à filtrer.
@@ -224,6 +240,8 @@ class DataMonitorService : LifecycleService() {
}
}
logDiagnostic(settings)
stateHolder.update {
it.copy(
consumedMo = consumedMo,
@@ -241,6 +259,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(
@@ -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<String>) {
stopTunnel(keepForeground = true)
AppLog.i(TAG, "Établissement du tunnel : ${allowedPackages.size} application(s) exclue(s) du filtrage")
val builder = Builder()
.setSession("DataControl")
@@ -70,10 +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()
@@ -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) {
@@ -163,6 +163,7 @@ fun DashboardScreen(systemActions: SystemActions) {
if (checked) {
systemActions.requestNotificationPermission { _ ->
systemActions.requestVpnPermission { granted ->
com.jttools.datacontrol.log.AppLog.i("Tableau de bord", "Activation du contrôle : autorisation VPN ${if (granted) "accordée" else "REFUSÉE"}")
if (granted) {
viewModel.setControlEnabled(true)
DataMonitorService.start(context)
@@ -16,7 +16,6 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -29,6 +28,8 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.jttools.datacontrol.R
import com.jttools.datacontrol.ui.SystemActions
@@ -46,6 +47,7 @@ fun FamilySection(systemActions: SystemActions) {
var adminActive by remember { mutableStateOf(systemActions.isAdminActive()) }
var locationGranted by remember { mutableStateOf(systemActions.hasLocationPermission()) }
var backgroundGranted by remember { mutableStateOf(systemActions.hasBackgroundLocationPermission()) }
var guardEnabled by remember { mutableStateOf(systemActions.isGuardEnabled()) }
var code by remember { mutableStateOf("") }
var deviceName by remember { mutableStateOf(android.os.Build.MODEL) }
@@ -54,10 +56,12 @@ fun FamilySection(systemActions: SystemActions) {
var pwd by remember { mutableStateOf("") }
var pwdError by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
// Au retour des écrans système (accessibilité, permissions, admin), on relit l'état réel.
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
adminActive = systemActions.isAdminActive()
locationGranted = systemActions.hasLocationPermission()
backgroundGranted = systemActions.hasBackgroundLocationPermission()
guardEnabled = systemActions.isGuardEnabled()
}
fun runProtected(action: String, block: () -> Unit) {
@@ -140,6 +144,26 @@ fun FamilySection(systemActions: SystemActions) {
HorizontalDivider()
// --- Limitation des applications ---
Text(stringResource(R.string.family_guard_title), fontWeight = FontWeight.Medium)
Text(
stringResource(R.string.family_guard_desc),
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (guardEnabled) {
Text(stringResource(R.string.settings_perm_granted), color = StateOk)
} else {
Button(onClick = { systemActions.openAccessibilitySettings() }) {
Text(stringResource(R.string.family_guard_enable))
}
Text(
stringResource(R.string.family_guard_hint),
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
HorizontalDivider()
// --- Position ---
Text(stringResource(R.string.family_location_title), fontWeight = FontWeight.Medium)
Text(
@@ -0,0 +1,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é.")
}
@@ -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() }
}
}