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:
1 parent
82d47fcd72
commit
b168c5f8fd
23 files changed
+816
-40
No files matched your search
@@ -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.
|
||||
|
||||
@@ -65,6 +65,27 @@
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- Limitation du lancement des applications (détection de l'application au premier plan). -->
|
||||
<service
|
||||
android:name=".guard.AppGuardService"
|
||||
android:exported="true"
|
||||
android:label="@string/guard_service_label"
|
||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accessibilityservice"
|
||||
android:resource="@xml/accessibility_guard" />
|
||||
</service>
|
||||
|
||||
<activity
|
||||
android:name=".guard.BlockedActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.DataControl" />
|
||||
|
||||
<!-- Administrateur d'appareil : bloque la désinstallation tant qu'il est actif. -->
|
||||
<receiver
|
||||
android:name=".admin.AppDeviceAdminReceiver"
|
||||
|
||||
@@ -20,6 +20,7 @@ class DataControlApp : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
com.jttools.datacontrol.log.AppLog.init(this)
|
||||
CoroutineScope(Dispatchers.IO).launch { settingsRepository.resetPasswordOnce() }
|
||||
// Filet de sécurité : relance périodiquement DataMonitorService s'il a été tué en arrière-plan.
|
||||
// enqueueUniquePeriodicWork + KEEP : sans effet si déjà planifié, donc sûr à appeler à chaque démarrage.
|
||||
|
||||
@@ -27,7 +27,9 @@ data class AppSettings(
|
||||
val familyId: String? = null,
|
||||
val deviceId: String? = null,
|
||||
/** Horodatage (ms) de la dernière configuration distante appliquée. */
|
||||
val remoteConfigVersion: Long = 0L
|
||||
val remoteConfigVersion: Long = 0L,
|
||||
/** Règles d'usage (plages horaires, limites de temps, applications interdites), au format JSON. */
|
||||
val rulesJson: String? = null
|
||||
) {
|
||||
val isPaired: Boolean get() = !familyId.isNullOrEmpty() && !deviceId.isNullOrEmpty()
|
||||
|
||||
|
||||
@@ -45,6 +45,9 @@ class SettingsRepository @Inject constructor(
|
||||
val FAMILY_ID = stringPreferencesKey("family_id")
|
||||
val DEVICE_ID = stringPreferencesKey("device_id")
|
||||
val REMOTE_CONFIG_VERSION = longPreferencesKey("remote_config_version")
|
||||
val RULES_JSON = stringPreferencesKey("rules_json")
|
||||
val USAGE_DAY = stringPreferencesKey("usage_day")
|
||||
val USAGE_JSON = stringPreferencesKey("usage_json")
|
||||
val TRAFFIC_DAY = stringPreferencesKey("traffic_day")
|
||||
val TRAFFIC_MOBILE_ACC = longPreferencesKey("traffic_mobile_acc")
|
||||
val TRAFFIC_WIFI_ACC = longPreferencesKey("traffic_wifi_acc")
|
||||
@@ -74,7 +77,8 @@ class SettingsRepository @Inject constructor(
|
||||
cutoffPulseStartMillis = prefs[Keys.CUTOFF_PULSE_START_MILLIS] ?: defaults.cutoffPulseStartMillis,
|
||||
familyId = prefs[Keys.FAMILY_ID],
|
||||
deviceId = prefs[Keys.DEVICE_ID],
|
||||
remoteConfigVersion = prefs[Keys.REMOTE_CONFIG_VERSION] ?: defaults.remoteConfigVersion
|
||||
remoteConfigVersion = prefs[Keys.REMOTE_CONFIG_VERSION] ?: defaults.remoteConfigVersion,
|
||||
rulesJson = prefs[Keys.RULES_JSON]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -205,6 +209,7 @@ class SettingsRepository @Inject constructor(
|
||||
it.remove(Keys.FAMILY_ID)
|
||||
it.remove(Keys.DEVICE_ID)
|
||||
it.remove(Keys.REMOTE_CONFIG_VERSION)
|
||||
it.remove(Keys.RULES_JSON)
|
||||
} else {
|
||||
it[Keys.FAMILY_ID] = familyId
|
||||
it[Keys.DEVICE_ID] = deviceId
|
||||
@@ -225,10 +230,12 @@ class SettingsRepository @Inject constructor(
|
||||
whitelistedPackagesWifi: Set<String>?,
|
||||
wifiFilterEnabled: Boolean?,
|
||||
planLimitMo: Long?,
|
||||
cutoffIntervalMinutes: Int?
|
||||
cutoffIntervalMinutes: Int?,
|
||||
rulesJson: String?
|
||||
) {
|
||||
context.dataStore.edit { prefs ->
|
||||
if ((prefs[Keys.REMOTE_CONFIG_VERSION] ?: 0L) >= version) return@edit
|
||||
rulesJson?.let { prefs[Keys.RULES_JSON] = it }
|
||||
prefs[Keys.REMOTE_CONFIG_VERSION] = version
|
||||
controlEnabled?.let { prefs[Keys.CONTROL_ENABLED] = it }
|
||||
whitelistedPackages?.let { prefs[Keys.WHITELISTED_PACKAGES] = it }
|
||||
@@ -265,4 +272,18 @@ class SettingsRepository @Inject constructor(
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun loadUsage(): Pair<String, String>? {
|
||||
val prefs = context.dataStore.data.first()
|
||||
val day = prefs[Keys.USAGE_DAY] ?: return null
|
||||
val json = prefs[Keys.USAGE_JSON] ?: return null
|
||||
return day to json
|
||||
}
|
||||
|
||||
suspend fun saveUsage(day: String, json: String) {
|
||||
context.dataStore.edit {
|
||||
it[Keys.USAGE_DAY] = day
|
||||
it[Keys.USAGE_JSON] = json
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<string name="nav_dashboard">Tableau de bord</string>
|
||||
<string name="nav_settings">Réglages</string>
|
||||
<string name="nav_whitelist">Liste blanche</string>
|
||||
<string name="nav_journal">Journal</string>
|
||||
<string name="nav_about">À propos</string>
|
||||
|
||||
<!-- Dashboard -->
|
||||
@@ -142,4 +143,10 @@
|
||||
<string name="family_location_desc">Partagée avec le parent via FamilyControl, y compris en arrière-plan (choisir « Toujours autoriser »).</string>
|
||||
<string name="family_location_grant">Autoriser la position</string>
|
||||
<string name="family_location_background">Autoriser en arrière-plan</string>
|
||||
<string name="guard_service_label">DataControl — limitation des applications</string>
|
||||
<string name="guard_service_description">Détecte l\'application utilisée pour appliquer les plages horaires et les limites de temps définies par les parents. Le contenu des écrans n\'est pas lu.</string>
|
||||
<string name="family_guard_title">Limitation des applications</string>
|
||||
<string name="family_guard_desc">Plages horaires, temps d\'écran et blocage d\'applications décidés par le parent. Nécessite le service d\'accessibilité « DataControl ».</string>
|
||||
<string name="family_guard_hint">Android 13+ : si l\'activation est grisée, ouvrez Infos sur l\'application → ⋮ → « Autoriser les paramètres restreints », puis réessayez.</string>
|
||||
<string name="family_guard_enable">Activer le service</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:accessibilityEventTypes="typeWindowStateChanged"
|
||||
android:accessibilityFeedbackType="feedbackGeneric"
|
||||
android:canRetrieveWindowContent="false"
|
||||
android:description="@string/guard_service_description"
|
||||
android:notificationTimeout="100" />
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
versionName=0.1.3
|
||||
versionCode=4
|
||||
versionName=0.1.4
|
||||
versionCode=5
|
||||
Reference in new issue
Block a user