fix: corrections de l'audit de sécurité et de robustesse

- VPN retiré : le contrôle reste activé (alerte parent, tunnel remonté après 30 s, autorisation redemandée à l'ouverture)
- Horloge de référence : changer date/heure/fuseau ne contourne plus les plages horaires ni le temps d'écran (signalé au parent)
- Mot de passe : PBKDF2 + attente croissante après 3 échecs ; conversion automatique des anciens hachages
- Écrans protégés : aucun affichage avant chargement des réglages ; effacement du journal protégé
- Signature release via keystore.properties (même clé que les versions publiées)
- Sauvegarde Google : réglages, appairage et journal exclus
- Alerte admin fiable (goAsync), relance refusée signalée, liste d'applis renvoyée à chaque installation, code d'appairage consommé
- Journal thread-safe et sans recopie à chaque ligne ; correctifs lint
- Tests unitaires (mot de passe, règles, versions, config Firebase) ; README aligné sur le comportement réel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ
This commit is contained in:
tuxgyverandClaude Opus 5.5 committed 2026-10-25 10:49:43 +01:00
1 parent f33776bbb5
commit e269e9932a
37 files changed
+809 -147

No files matched your search

@@ -4,16 +4,25 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.lifecycle.lifecycleScope
import com.jttools.datacontrol.data.SettingsRepository
import com.jttools.datacontrol.log.AppLog
import com.jttools.datacontrol.service.DataMonitorService
import com.jttools.datacontrol.ui.SystemActions
import com.jttools.datacontrol.ui.navigation.DataControlNavHost
import com.jttools.datacontrol.ui.theme.DataControlTheme
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var settingsRepository: SettingsRepository
private lateinit var systemActions: SystemActions
private var vpnAskedThisSession = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -29,4 +38,21 @@ class MainActivity : ComponentActivity() {
}
}
}
override fun onResume() {
super.onResume()
// Contrôle actif mais autorisation VPN perdue (retirée, ou prise par un autre VPN) :
// on la redemande à l'ouverture, une seule fois par session pour ne pas boucler.
if (vpnAskedThisSession) return
lifecycleScope.launch {
if (settingsRepository.settingsFlow.first().controlEnabled && !systemActions.isVpnPrepared()) {
vpnAskedThisSession = true
AppLog.w("Pare-feu", "Contrôle actif sans autorisation VPN : nouvelle demande")
systemActions.requestVpnPermission { granted ->
AppLog.i("Pare-feu", "Autorisation VPN ${if (granted) "rétablie" else "REFUSÉE"}")
if (granted) DataMonitorService.start(this@MainActivity)
}
}
}
}
}
@@ -20,8 +20,14 @@ class AppDeviceAdminReceiver : DeviceAdminReceiver() {
override fun onDisabled(context: Context, intent: Intent) {
// Prévient le parent : la protection anti-désinstallation vient d'être retirée.
// goAsync : sans lui, le processus peut être arrêté avant que l'alerte soit partie.
val pending = goAsync()
CoroutineScope(Dispatchers.IO).launch {
remoteSync.pushEvent("admin_disabled", "Protection anti-désinstallation désactivée")
try {
remoteSync.pushEvent("admin_disabled", "Protection anti-désinstallation désactivée")
} finally {
pending.finish()
}
}
}
}
@@ -2,10 +2,20 @@ package com.jttools.datacontrol.data
import java.security.MessageDigest
import java.security.SecureRandom
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
/** Hachage salé (SHA-256) du mot de passe de verrouillage — jamais stocké en clair. */
/**
* Hachage du mot de passe de verrouillage (PBKDF2-HMAC-SHA256, salé) — jamais stocké en clair.
*
* Format stocké : `pbkdf2:<itérations>:<hex>`. Les anciens hachages (SHA-256 simple, v0.1.8 et avant)
* restent vérifiables et sont convertis au prochain déverrouillage réussi.
*/
object PasswordHasher {
private const val SALT_BYTES = 16
private const val ITERATIONS = 100_000
private const val KEY_BITS = 256
private const val PREFIX = "pbkdf2:"
fun newSalt(): String {
val bytes = ByteArray(SALT_BYTES)
@@ -13,11 +23,40 @@ object PasswordHasher {
return bytes.toHex()
}
fun hash(password: String, saltHex: String): String {
fun hash(password: String, saltHex: String): String =
"$PREFIX$ITERATIONS:${pbkdf2(password, saltHex, ITERATIONS)}"
fun verify(password: String, saltHex: String, stored: String): Boolean {
val computed = if (stored.startsWith(PREFIX)) {
val iterations = stored.removePrefix(PREFIX).substringBefore(':').toIntOrNull() ?: return false
"$PREFIX$iterations:${pbkdf2(password, saltHex, iterations)}"
} else {
legacySha256(password, saltHex)
}
// Comparaison à temps constant.
return MessageDigest.isEqual(computed.toByteArray(Charsets.UTF_8), stored.toByteArray(Charsets.UTF_8))
}
/** Vrai pour un hachage ancien format, à recalculer avec [hash]. */
fun needsUpgrade(stored: String): Boolean = !stored.startsWith("$PREFIX$ITERATIONS:")
private fun pbkdf2(password: String, saltHex: String, iterations: Int): String {
val spec = PBEKeySpec(password.toCharArray(), saltHex.hexToBytes(), iterations, KEY_BITS)
try {
return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).encoded.toHex()
} finally {
spec.clearPassword()
}
}
private fun legacySha256(password: String, saltHex: String): String {
val digest = MessageDigest.getInstance("SHA-256")
digest.update(saltHex.toByteArray(Charsets.UTF_8))
return digest.digest(password.toByteArray(Charsets.UTF_8)).toHex()
}
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
private fun String.hexToBytes(): ByteArray =
ByteArray(length / 2) { i -> substring(2 * i, 2 * i + 2).toInt(16).toByte() }
}
@@ -1,6 +1,10 @@
package com.jttools.datacontrol.data
import android.content.Context
import android.os.SystemClock
import com.jttools.datacontrol.log.AppLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit
@@ -56,6 +60,16 @@ class SettingsRepository @Inject constructor(
val TRAFFIC_WIFI_ACC = longPreferencesKey("traffic_wifi_acc")
val TRAFFIC_LAST_MOBILE = longPreferencesKey("traffic_last_mobile")
val TRAFFIC_LAST_OTHER = longPreferencesKey("traffic_last_other")
val PASSWORD_FAILS = intPreferencesKey("password_fails")
val PASSWORD_LOCK_UNTIL = longPreferencesKey("password_lock_until")
val CLOCK_SKEW_MS = longPreferencesKey("clock_skew_ms")
val CLOCK_ZONE = stringPreferencesKey("clock_zone")
}
private companion object {
const val FREE_ATTEMPTS = 3
const val LOCK_BASE_MS = 30_000L
const val LOCK_MAX_MS = 15 * 60_000L
}
val settingsFlow: Flow<AppSettings> = context.dataStore.data.map { prefs ->
@@ -168,14 +182,29 @@ class SettingsRepository @Inject constructor(
}
} else {
val salt = PasswordHasher.newSalt()
val hash = PasswordHasher.hash(password, salt)
val hash = withContext(Dispatchers.Default) { PasswordHasher.hash(password, salt) }
context.dataStore.edit { prefs ->
prefs[Keys.PASSWORD_HASH] = hash
prefs[Keys.PASSWORD_SALT] = salt
prefs.remove(Keys.PASSWORD_FAILS)
prefs.remove(Keys.PASSWORD_LOCK_UNTIL)
}
}
}
/** Horloge de référence de [com.jttools.datacontrol.guard.GuardEngine] : décalage (ms) et fuseau retenus. */
suspend fun trustedClock(): Pair<Long, String?> {
val prefs = context.dataStore.data.first()
return (prefs[Keys.CLOCK_SKEW_MS] ?: 0L) to prefs[Keys.CLOCK_ZONE]
}
suspend fun setTrustedClock(skewMs: Long, zoneId: String) {
context.dataStore.edit {
if (skewMs == 0L) it.remove(Keys.CLOCK_SKEW_MS) else it[Keys.CLOCK_SKEW_MS] = skewMs
it[Keys.CLOCK_ZONE] = zoneId
}
}
/**
* Réinitialisation unique (v0.1.2) du mot de passe de verrouillage : le supprime une seule fois,
* puis mémorise qu'elle a eu lieu pour ne pas effacer un mot de passe défini ensuite.
@@ -190,12 +219,63 @@ class SettingsRepository @Inject constructor(
}
}
/** Vrai si aucun mot de passe n'est défini, ou si [password] correspond au mot de passe stocké. */
suspend fun verifyPassword(password: String): Boolean {
/** Fin du blocage en temps monotone : avancer l'horloge du téléphone ne raccourcit pas l'attente. */
@Volatile private var lockUntilElapsed = 0L
/**
* Vérifie [password]. Renvoie null s'il est correct (ou si aucun mot de passe n'est défini),
* sinon le message d'erreur à afficher. Après [FREE_ATTEMPTS] échecs, chaque nouvel essai
* impose une attente qui double (30 s, 1 min, 2 min… jusqu'à 15 min).
*/
suspend fun checkPassword(password: String): String? {
val prefs = context.dataStore.data.first()
val hash = prefs[Keys.PASSWORD_HASH] ?: return true
val salt = prefs[Keys.PASSWORD_SALT] ?: return true
return PasswordHasher.hash(password, salt) == hash
val hash = prefs[Keys.PASSWORD_HASH] ?: return null
val salt = prefs[Keys.PASSWORD_SALT] ?: return null
val remaining = maxOf(
(prefs[Keys.PASSWORD_LOCK_UNTIL] ?: 0L) - System.currentTimeMillis(),
lockUntilElapsed - SystemClock.elapsedRealtime()
)
if (remaining > 0) return lockMessage(remaining)
val ok = withContext(Dispatchers.Default) { PasswordHasher.verify(password, salt, hash) }
if (ok) {
val upgraded = if (PasswordHasher.needsUpgrade(hash)) {
withContext(Dispatchers.Default) { PasswordHasher.newSalt().let { it to PasswordHasher.hash(password, it) } }
} else null
lockUntilElapsed = 0L
context.dataStore.edit {
it.remove(Keys.PASSWORD_FAILS)
it.remove(Keys.PASSWORD_LOCK_UNTIL)
upgraded?.let { (newSalt, newHash) ->
it[Keys.PASSWORD_SALT] = newSalt
it[Keys.PASSWORD_HASH] = newHash
}
}
return null
}
var lockMs = 0L
context.dataStore.edit {
val fails = (it[Keys.PASSWORD_FAILS] ?: 0) + 1
it[Keys.PASSWORD_FAILS] = fails
if (fails >= FREE_ATTEMPTS) {
lockMs = (LOCK_BASE_MS shl (fails - FREE_ATTEMPTS).coerceAtMost(10)).coerceAtMost(LOCK_MAX_MS)
it[Keys.PASSWORD_LOCK_UNTIL] = System.currentTimeMillis() + lockMs
}
}
if (lockMs > 0) {
lockUntilElapsed = SystemClock.elapsedRealtime() + lockMs
AppLog.w("Mot de passe", "Mot de passe erroné, saisie bloquée ${lockMs / 1000} s")
return "Mot de passe incorrect. " + lockMessage(lockMs)
}
AppLog.w("Mot de passe", "Mot de passe erroné")
return "Mot de passe incorrect."
}
private fun lockMessage(ms: Long): String {
val s = (ms + 999) / 1000
return if (s < 60) "Trop d'essais : réessayez dans $s s." else "Trop d'essais : réessayez dans ${(s + 59) / 60} min."
}
/**
@@ -4,6 +4,7 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
@@ -32,6 +33,10 @@ class BlockedActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// Retour : renvoie à l'accueil, jamais vers l'application bloquée.
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() = goHome()
})
val label = intent.getStringExtra(EXTRA_LABEL).orEmpty()
val message = intent.getStringExtra(EXTRA_MESSAGE).orEmpty()
setContent {
@@ -55,8 +60,6 @@ class BlockedActivity : ComponentActivity() {
}
}
@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))
@@ -1,7 +1,16 @@
package com.jttools.datacontrol.guard
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.SystemClock
import android.provider.Settings
import androidx.core.content.ContextCompat
import com.jttools.datacontrol.data.SettingsRepository
import com.jttools.datacontrol.log.AppLog
import com.jttools.datacontrol.remote.RemoteSync
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -11,10 +20,12 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.json.JSONObject
import java.time.LocalDate
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.abs
data class Block(val message: String)
@@ -27,9 +38,19 @@ data class UsageSnapshot(val date: String, val totalMs: Long, val appMs: Map<Str
*
* 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.
*
* Horloge de référence : changer la date ou l'heure du téléphone ne doit ni ouvrir une plage horaire
* ni remettre à zéro le temps d'écran. Les sauts d'horloge sont repérés en comparant l'heure système
* au temps monotone ([SystemClock.elapsedRealtime], non modifiable). Si l'heure automatique (réseau)
* est désactivée, le saut est neutralisé (décalage mémorisé) et signalé au parent ; si elle est
* activée, l'heure système fait foi. Même principe pour le fuseau horaire.
*/
@Singleton
class GuardEngine @Inject constructor(private val settings: SettingsRepository) {
class GuardEngine @Inject constructor(
@ApplicationContext private val context: Context,
private val settings: SettingsRepository,
private val remoteSync: RemoteSync
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val lock = Any()
@@ -38,18 +59,51 @@ class GuardEngine @Inject constructor(private val settings: SettingsRepository)
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
// Horloge de référence.
private var skewMs = 0L
private var zone: ZoneId = ZoneId.systemDefault()
private var refSysMs = System.currentTimeMillis()
private var refElapsedMs = SystemClock.elapsedRealtime()
private var day = today()
private var totalMs = 0L
private val appMs = HashMap<String, Long>()
private val clockReceiver = object : BroadcastReceiver() {
override fun onReceive(c: Context?, intent: Intent?) {
synchronized(lock) {
if (intent?.action == Intent.ACTION_TIMEZONE_CHANGED) onZoneChanged()
flush()
}
}
}
fun start() {
synchronized(lock) { if (started) return; started = true }
scope.launch {
val (savedSkew, savedZone) = settings.trustedClock()
synchronized(lock) {
skewMs = if (autoTime()) 0L else savedSkew
zone = savedZone?.takeIf { !autoZone() }?.let { runCatching { ZoneId.of(it) }.getOrNull() }
?: ZoneId.systemDefault()
refSysMs = System.currentTimeMillis()
refElapsedMs = SystemClock.elapsedRealtime()
day = today()
}
settings.loadUsage()?.let { (savedDay, json) -> restore(savedDay, json) }
ContextCompat.registerReceiver(
context, clockReceiver,
IntentFilter().apply {
addAction(Intent.ACTION_TIME_CHANGED)
addAction(Intent.ACTION_TIMEZONE_CHANGED)
},
ContextCompat.RECEIVER_NOT_EXPORTED
)
settings.settingsFlow.map { it.rulesJson }.distinctUntilChanged().collect { rules = Rules.parse(it) }
}
scope.launch {
@@ -62,7 +116,7 @@ class GuardEngine @Inject constructor(private val settings: SettingsRepository)
}
private fun restore(savedDay: String, json: String) = synchronized(lock) {
if (savedDay != LocalDate.now().toString()) return@synchronized
if (savedDay != today()) return@synchronized
runCatching {
val o = JSONObject(json)
day = savedDay
@@ -89,9 +143,71 @@ class GuardEngine @Inject constructor(private val settings: SettingsRepository)
fun tick() = synchronized(lock) { flush() }
/** Heure de référence (heure système corrigée des réglages manuels repérés). À appeler sous verrou. */
private fun nowLocal(): LocalDateTime {
checkClockJump()
return LocalDateTime.ofInstant(Instant.ofEpochMilli(System.currentTimeMillis() + skewMs), zone)
}
private fun today(): String = nowLocal().toLocalDate().toString()
/** Repère un saut de l'heure système par rapport au temps monotone. À appeler sous verrou. */
private fun checkClockJump() {
val sys = System.currentTimeMillis()
val elapsed = SystemClock.elapsedRealtime()
val jump = sys - (refSysMs + (elapsed - refElapsedMs))
// La référence ne bouge qu'après un saut : des petits réglages successifs s'additionnent
// et finissent par être repérés.
if (abs(jump) < CLOCK_JUMP_TOLERANCE_MS) return
refSysMs = sys
refElapsedMs = elapsed
val auto = autoTime()
val newSkew = if (auto) 0L else skewMs - jump
if (newSkew == skewMs) return
skewMs = newSkew
saveClock()
val minutes = jump / 60_000
if (auto) {
AppLog.i("Horloge", "Heure corrigée par le réseau (${signed(minutes)} min)")
} else {
val detail = "Heure du téléphone modifiée à la main (${signed(minutes)} min) : ignorée pour les plages horaires et le temps d'écran"
AppLog.w("Horloge", detail)
scope.launch { remoteSync.pushEvent("clock_changed", detail) }
}
}
/** À appeler sous verrou. */
private fun onZoneChanged() {
val system = ZoneId.systemDefault()
if (system == zone) return
if (autoZone()) {
AppLog.i("Horloge", "Fuseau horaire : $zone → $system (automatique)")
zone = system
saveClock()
} else {
val detail = "Fuseau horaire changé à la main ($zone → $system) : ignoré pour les plages horaires et le temps d'écran"
AppLog.w("Horloge", detail)
scope.launch { remoteSync.pushEvent("clock_changed", detail) }
}
}
private fun saveClock() {
val s = skewMs
val z = zone.id
scope.launch { settings.setTrustedClock(s, z) }
}
private fun signed(v: Long) = if (v >= 0) "+$v" else "$v"
private fun autoTime(): Boolean =
Settings.Global.getInt(context.contentResolver, Settings.Global.AUTO_TIME, 1) == 1
private fun autoZone(): Boolean =
Settings.Global.getInt(context.contentResolver, Settings.Global.AUTO_TIME_ZONE, 1) == 1
private fun flush() {
val now = SystemClock.elapsedRealtime()
val today = LocalDate.now().toString()
val today = today()
if (today != day) {
day = today; totalMs = 0; appMs.clear(); dirty = true
}
@@ -111,22 +227,22 @@ class GuardEngine @Inject constructor(private val settings: SettingsRepository)
/** 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())
r.scheduleEnabled && !r.isOpenAt(nowLocal())
}
fun cutsMobileNow(): Boolean = synchronized(lock) {
val r = rules
r.scheduleEnabled && r.scheduleCutsMobile && !inWindow(r, LocalDateTime.now())
r.scheduleEnabled && r.scheduleCutsMobile && !r.isOpenAt(nowLocal())
}
fun check(pkg: String): Block? = synchronized(lock) {
flush()
val r = rules
if (r.isEmpty) return null
val now = LocalDateTime.now()
val now = nowLocal()
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)}")
if (r.scheduleEnabled && !r.isOpenAt(now)) {
return Block("L'appareil est verrouillé en dehors des plages horaires. ${r.nextOpeningText(now)}")
}
val bonusToday = r.bonusDate == day
if (r.dailyLimitMin > 0) {
@@ -142,31 +258,11 @@ class GuardEngine @Inject constructor(private val settings: SettingsRepository)
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
/** Écart cumulé toléré entre heure système et temps monotone (resynchronisations NTP ordinaires). */
const val CLOCK_JUMP_TOLERANCE_MS = 60_000L
}
}
@@ -2,6 +2,7 @@ package com.jttools.datacontrol.guard
import org.json.JSONArray
import org.json.JSONObject
import java.time.LocalDateTime
/** 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)
@@ -26,6 +27,30 @@ data class Rules(
val isEmpty: Boolean
get() = !scheduleEnabled && dailyLimitMin <= 0 && appLimits.isEmpty() && blocked.isEmpty()
/** Vrai si [now] tombe dans une plage autorisée (une plage qui passe minuit déborde sur le lendemain). */
fun isOpenAt(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 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)
}
}
/** Heure de la prochaine ouverture, en texte (« Reprise à 08:00. »), ou "" s'il n'y en a pas. */
fun nextOpeningText(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 = 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 ""
}
companion object {
val NONE = Rules()
@@ -2,9 +2,9 @@ package com.jttools.datacontrol.log
import android.content.Context
import android.util.Log
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
@@ -31,8 +31,9 @@ object AppLog {
private val io = Executors.newSingleThreadExecutor()
private val fmt = SimpleDateFormat("MM-dd HH:mm:ss", Locale.FRANCE)
private val lines = ArrayDeque<Entry>()
private val _entries = MutableStateFlow<List<String>>(emptyList())
val entries: StateFlow<List<String>> = _entries.asStateFlow()
/** Incrémenté à chaque changement : la liste n'est reconstruite que si l'onglet Journal l'observe. */
private val version = MutableStateFlow(0L)
val entries: Flow<List<String>> = version.map { synchronized(lines) { lines.map { it.text } } }
@Volatile private var file: File? = null
private var prunedSinceCompact = 0
@@ -65,8 +66,9 @@ object AppLog {
private fun add(level: String, tag: String, message: String) {
Log.println(when (level) { "E" -> Log.ERROR; "W" -> Log.WARN; else -> Log.INFO }, "DataControl/$tag", message)
val now = System.currentTimeMillis()
val entry = Entry(now, "${fmt.format(Date(now))} $level $tag: $message")
synchronized(lines) {
// SimpleDateFormat n'est pas sûr entre threads : formaté sous le verrou.
val entry = Entry(now, "${fmt.format(Date(now))} $level $tag: $message")
lines.addLast(entry)
prune(now)
publish()
@@ -87,7 +89,7 @@ object AppLog {
}
private fun publish() {
_entries.value = lines.map { it.text }
version.value++
}
/** Réécrit le fichier avec les seules entrées conservées. À appeler sous verrou. */
@@ -22,6 +22,7 @@ import com.jttools.datacontrol.service.FirewallVpnService
import com.jttools.datacontrol.service.MonitorStateHolder
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
@@ -51,6 +52,7 @@ class DeviceReporter @Inject constructor(
private var locationRegistered = false
private var lastGuardEnabled: Boolean? = null
private var lastUsageSentTotal = -1L
private var appsPushJob: Job? = null
fun start(scope: CoroutineScope) {
if (started) return
@@ -67,6 +69,27 @@ class DeviceReporter @Inject constructor(
context, batteryReceiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED), ContextCompat.RECEIVER_NOT_EXPORTED
)
// Application installée ou désinstallée : le parent doit voir la liste à jour pour choisir.
val packageReceiver = object : BroadcastReceiver() {
override fun onReceive(c: Context?, intent: Intent?) {
if (intent?.getBooleanExtra(Intent.EXTRA_REPLACING, false) == true) return // simple mise à jour
appsPushJob?.cancel()
appsPushJob = scope.launch {
delay(APPS_PUSH_DEBOUNCE_MS) // regroupe les installations en rafale
if (settingsRepository.settingsFlow.first().isPaired) remote.pushApps()
}
}
}
ContextCompat.registerReceiver(
context, packageReceiver,
IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REMOVED)
addDataScheme("package")
},
ContextCompat.RECEIVER_NOT_EXPORTED
)
scope.launch {
while (isActive) {
val s = settingsRepository.settingsFlow.first()
@@ -113,6 +136,9 @@ class DeviceReporter @Inject constructor(
"tunnelActive" to FirewallVpnService.tunnelActive,
"adminActive" to AdminController.isActive(context),
"guardEnabled" to guardEnabled,
"autoTime" to (android.provider.Settings.Global.getInt(
context.contentResolver, android.provider.Settings.Global.AUTO_TIME, 1
) == 1),
"rulesActive" to !guardEngine.rules.isEmpty,
"consumedMo" to snap.consumedMo,
"planLimitMo" to s.planLimitMo,
@@ -192,5 +218,6 @@ class DeviceReporter @Inject constructor(
const val LOCATION_INTERVAL_MS = 5 * 60_000L
const val LOCATION_MIN_DISTANCE_M = 50f
const val LOCATION_MIN_SEND_MS = 60_000L
const val APPS_PUSH_DEBOUNCE_MS = 5_000L
}
}
@@ -1,6 +1,5 @@
package com.jttools.datacontrol.remote
import android.util.Base64
import org.json.JSONObject
/** Identifiants d'un projet Firebase (ceux du bloc `firebaseConfig` de la console). */
@@ -14,7 +13,7 @@ data class FirebaseConfig(val apiKey: String, val appId: String, val projectId:
val text = input.trim()
if (text.startsWith("fc1:")) {
return runCatching {
val json = JSONObject(String(Base64.decode(text.removePrefix("fc1:"), Base64.DEFAULT), Charsets.UTF_8))
val json = JSONObject(String(java.util.Base64.getMimeDecoder().decode(text.removePrefix("fc1:")), Charsets.UTF_8))
build(json.optString("apiKey"), json.optString("appId"), json.optString("projectId"))
}.getOrNull()
}
@@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withTimeoutOrNull
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@@ -75,6 +76,10 @@ class RemoteSync @Inject constructor(
"pairedAt" to System.currentTimeMillis()
)
).await()
// Code à usage unique : supprimé une fois consommé (sans effet si les règles Firestore déployées
// ne l'autorisent pas encore — il expire de toute façon au bout de 10 min).
runCatching { db.collection("pairings").document(clean).delete().await() }
.onFailure { AppLog.w("FamilyControl", "Code d'appairage non supprimé : ${it.message}") }
settingsRepository.setPairing(familyId, deviceId)
}
@@ -168,7 +173,8 @@ class RemoteSync @Inject constructor(
)
}
private suspend fun pushApps() {
/** Envoie la liste des applications installées (au parent de choisir lesquelles autoriser). */
suspend fun pushApps() {
val s = settingsRepository.settingsFlow.first()
val ref = deviceDoc(s) ?: return
val apps = installedApps.listInstalledApps(withIcons = false).map {
@@ -206,10 +212,15 @@ class RemoteSync @Inject constructor(
suspend fun pushEvent(type: String, detail: String) {
val s = settingsRepository.settingsFlow.first()
val ref = deviceDoc(s) ?: return
if (gateway.ensureSignedIn() == null) return
// Attente bornée de l'accusé du serveur : appelé depuis un récepteur (goAsync), le processus
// peut être arrêté juste après ; hors ligne, l'écriture reste en file dans le SDK.
runCatching {
ref.collection("events").add(
mapOf("type" to type, "detail" to detail, "at" to System.currentTimeMillis())
)
withTimeoutOrNull(EVENT_TIMEOUT_MS) {
ref.collection("events").add(
mapOf("type" to type, "detail" to detail, "at" to System.currentTimeMillis())
).await()
}
}
}
@@ -219,5 +230,8 @@ class RemoteSync @Inject constructor(
ref.set(data, SetOptions.merge())
}
companion object { private const val TAG = "RemoteSync" }
companion object {
private const val TAG = "RemoteSync"
private const val EVENT_TIMEOUT_MS = 8_000L
}
}
@@ -1,9 +1,17 @@
package com.jttools.datacontrol.service
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.jttools.datacontrol.MainActivity
import com.jttools.datacontrol.R
import com.jttools.datacontrol.data.SettingsRepository
import com.jttools.datacontrol.remote.RemoteSync
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
@@ -16,6 +24,10 @@ import kotlinx.coroutines.flow.first
* (voir [BootReceiver]) ne suffisent pas toujours sur les ROM qui tuent agressivement les apps en
* arrière-plan (ex: ColorOS) sans exemption d'optimisation batterie. Démarrer un service déjà en
* cours d'exécution est sans effet, cet appel est donc sûr à répéter à chaque déclenchement.
*
* Sur Android 12+, ce démarrage depuis l'arrière-plan est refusé si l'application n'est pas exemptée
* d'optimisation de la batterie : on affiche alors une notification pour rouvrir l'application, et
* le parent est prévenu.
*/
class DataMonitorRestartWorker(
context: Context,
@@ -26,17 +38,47 @@ class DataMonitorRestartWorker(
@InstallIn(SingletonComponent::class)
interface RestartWorkerEntryPoint {
fun settingsRepository(): SettingsRepository
fun remoteSync(): RemoteSync
}
override suspend fun doWork(): Result {
val appContext = applicationContext
val entryPoint = EntryPointAccessors.fromApplication(appContext, RestartWorkerEntryPoint::class.java)
val enabled = entryPoint.settingsRepository().settingsFlow.first().controlEnabled
if (enabled) DataMonitorService.start(appContext)
if (enabled && !DataMonitorService.start(appContext)) {
notifyRestartNeeded(appContext)
entryPoint.remoteSync().pushEvent(
"service_stopped",
"Surveillance arrêtée par le système et non relançable en arrière-plan (optimisation de la batterie active)"
)
}
return Result.success()
}
private fun notifyRestartNeeded(context: Context) {
val nm = context.getSystemService(NotificationManager::class.java)
nm.createNotificationChannel(
NotificationChannel(ALERT_CHANNEL_ID, "Alertes DataControl", NotificationManager.IMPORTANCE_HIGH)
)
val open = PendingIntent.getActivity(
context, 0, Intent(context, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE
)
nm.notify(
ALERT_NOTIF_ID,
NotificationCompat.Builder(context, ALERT_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("DataControl est arrêté")
.setContentText("Touchez pour relancer la surveillance.")
.setContentIntent(open)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
)
}
companion object {
const val WORK_NAME = "data_monitor_restart_watchdog"
private const val ALERT_CHANNEL_ID = "datacontrol_alerts"
private const val ALERT_NOTIF_ID = 3001
}
}
@@ -9,7 +9,7 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Color
import android.os.Build
import android.os.SystemClock
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleService
@@ -55,7 +55,14 @@ class DataMonitorService : LifecycleService() {
private var latestSettings: AppSettings = AppSettings()
private var latestTransport: NetworkTransport = NetworkTransport.NONE
private var vpnRevoked = false
/**
* Instant (temps monotone) du dernier retrait du VPN. Le contrôle reste ACTIVÉ : retirer le VPN
* ne doit pas suffire à lever la protection sans mot de passe. On laisse un délai avant de
* remonter le tunnel, pour ne pas se battre en boucle avec un autre VPN.
*/
private var vpnRevokedAt: Long? = null
private val vpnRevoked: Boolean
get() = vpnRevokedAt?.let { SystemClock.elapsedRealtime() - it < VPN_RETRY_AFTER_REVOKE_MS } ?: false
private var lastAppliedMode: VpnMode? = null
private var lastAppliedWhitelist: Set<String> = emptySet()
@@ -70,11 +77,12 @@ class DataMonitorService : LifecycleService() {
private val vpnRevokedReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
vpnRevoked = true
vpnRevokedAt = SystemClock.elapsedRealtime()
AppLog.w("Pare-feu", "VPN retiré : contrôle maintenu, nouvelle tentative dans ${VPN_RETRY_AFTER_REVOKE_MS / 1000} s")
lifecycleScope.launch {
settingsRepository.setControlEnabled(false)
remoteSync.pushEvent("vpn_revoked", "Autorisation VPN retirée : le filtrage est désactivé")
remoteSync.pushEvent("vpn_revoked", "Autorisation VPN retirée : données mobiles non filtrées tant qu'elle n'est pas rétablie")
}
tick()
}
}
@@ -117,7 +125,7 @@ class DataMonitorService : LifecycleService() {
}
}
lastControlEnabled = it.controlEnabled
if (!it.controlEnabled) vpnRevoked = false
if (!it.controlEnabled) vpnRevokedAt = null
tick()
}
}
@@ -179,7 +187,7 @@ class DataMonitorService : LifecycleService() {
when {
latestTransport == NetworkTransport.WIFI -> {
if (settings.wifiFilterEnabled && FirewallVpnService.isPrepared(this)) {
if (settings.wifiFilterEnabled && !vpnRevoked && FirewallVpnService.isPrepared(this)) {
applyVpnMode(VpnMode.FILTER, settings.whitelistedPackagesWifi)
} else {
applyVpnMode(VpnMode.STOPPED, emptySet())
@@ -332,14 +340,12 @@ class DataMonitorService : LifecycleService() {
private fun buildNotification(snapshot: MonitorSnapshot): Notification {
val nm = getSystemService(NotificationManager::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW
).apply { description = getString(R.string.notification_channel_description) }
nm.createNotificationChannel(channel)
}
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW
).apply { description = getString(R.string.notification_channel_description) }
nm.createNotificationChannel(channel)
val (emoji, stateLabel, color) = when {
snapshot.firewallFault -> Triple("⚠️", getString(R.string.dashboard_state_warning), Color.parseColor("#C62828"))
@@ -353,7 +359,7 @@ class DataMonitorService : LifecycleService() {
val percentText = if (snapshot.planLimitMo > 0) {
"${(snapshot.consumedMo * 100 / snapshot.planLimitMo)}%"
} else "—"
val priceText = String.format("%.2f", snapshot.estimatedTotalPriceEuro)
val priceText = String.format(java.util.Locale.FRENCH, "%.2f", snapshot.estimatedTotalPriceEuro)
val warn = if (snapshot.firewallFault) "⚠️ PARE-FEU INACTIF — données non filtrées · " else ""
val usageText = "$warn$emoji $stateLabel · ${snapshot.consumedMo}/${snapshot.planLimitMo} Mo ($percentText)"
@@ -396,10 +402,19 @@ class DataMonitorService : LifecycleService() {
private const val TICK_INTERVAL_MS = 20_000L
private const val PULSE_DURATION_MS = 30_000L
private const val TUNNEL_RETRY_DELAY_MS = 5_000L
private const val VPN_RETRY_AFTER_REVOKE_MS = 30_000L
private const val WARNING_THRESHOLD = 0.8f
fun start(context: Context) {
/**
* Démarre le service. Renvoie faux si Android refuse (Android 12+ interdit de démarrer un service
* de premier plan depuis l'arrière-plan sans exemption d'optimisation de la batterie).
*/
fun start(context: Context): Boolean = try {
ContextCompat.startForegroundService(context, Intent(context, DataMonitorService::class.java))
true
} catch (e: Exception) {
AppLog.e("Service", "Démarrage de DataMonitorService refusé par le système", e)
false
}
fun stop(context: Context) {
@@ -8,7 +8,6 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.VpnService
import android.os.Build
import android.os.ParcelFileDescriptor
import android.util.Log
import androidx.core.app.NotificationCompat
@@ -143,14 +142,12 @@ class FirewallVpnService : VpnService() {
private fun buildNotification(): Notification {
val nm = getSystemService(NotificationManager::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_MIN
)
nm.createNotificationChannel(channel)
}
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_MIN
)
nm.createNotificationChannel(channel)
val openApp = PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE
@@ -35,14 +35,18 @@ import kotlinx.coroutines.launch
/**
* Verrouille [content] derrière une saisie de mot de passe tant que [passwordSet] est vrai.
* [passwordSet] null = réglages pas encore chargés : rien n'est affiché (sinon l'écran protégé
* apparaîtrait le temps d'une image avant le verrou).
* [verify] renvoie null si le mot de passe est bon, sinon le message d'erreur.
* Le déverrouillage n'est valable que pour la composition courante (redemandé à chaque visite de l'écran).
*/
@Composable
fun PasswordGate(
passwordSet: Boolean,
verify: suspend (String) -> Boolean,
passwordSet: Boolean?,
verify: suspend (String) -> String?,
content: @Composable () -> Unit
) {
if (passwordSet == null) return
var unlocked by remember(passwordSet) { mutableStateOf(!passwordSet) }
if (unlocked) {
content()
@@ -50,7 +54,8 @@ fun PasswordGate(
}
var password by remember { mutableStateOf("") }
var error by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Column(
@@ -64,31 +69,27 @@ fun PasswordGate(
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it; error = false },
onValueChange = { password = it; error = null },
label = { Text(stringResource(R.string.password_field_label)) },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
isError = error,
isError = error != null,
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
if (error) {
error?.let {
Spacer(Modifier.height(4.dp))
Text(
stringResource(R.string.password_incorrect),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(16.dp))
Button(
enabled = !busy,
onClick = {
busy = true
scope.launch {
if (verify(password)) {
unlocked = true
} else {
error = true
}
val result = verify(password)
busy = false
if (result == null) unlocked = true else error = result
}
},
modifier = Modifier.fillMaxWidth()
@@ -100,7 +101,7 @@ fun PasswordGate(
@Composable
fun PasswordPromptDialog(
title: String,
error: Boolean,
error: String?,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit
) {
@@ -117,17 +118,13 @@ fun PasswordPromptDialog(
label = { Text(stringResource(R.string.password_field_label)) },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
isError = error,
isError = error != null,
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
if (error) {
error?.let {
Spacer(Modifier.height(4.dp))
Text(
stringResource(R.string.password_incorrect),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
}
},
@@ -56,9 +56,10 @@ fun DashboardScreen(systemActions: SystemActions) {
val viewModel: DashboardViewModel = hiltViewModel()
val settings by viewModel.settings.collectAsStateWithLifecycle()
val snapshot by viewModel.snapshot.collectAsStateWithLifecycle()
val passwordSet by viewModel.passwordSet.collectAsStateWithLifecycle()
var showResetDialog by remember { mutableStateOf(false) }
var showDisablePasswordDialog by remember { mutableStateOf(false) }
var disablePasswordError by remember { mutableStateOf(false) }
var disablePasswordError by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val stateColor = when {
@@ -170,9 +171,9 @@ fun DashboardScreen(systemActions: SystemActions) {
}
}
}
} else {
if (settings.hasPassword) {
disablePasswordError = false
} else if (passwordSet != null) { // null : réglages pas encore chargés
if (passwordSet == true) {
disablePasswordError = null
showDisablePasswordDialog = true
} else {
viewModel.setControlEnabled(false)
@@ -243,11 +244,12 @@ fun DashboardScreen(systemActions: SystemActions) {
onDismiss = { showDisablePasswordDialog = false },
onConfirm = { password ->
scope.launch {
if (viewModel.verifyPassword(password)) {
val error = viewModel.checkPassword(password)
if (error == null) {
viewModel.setControlEnabled(false)
showDisablePasswordDialog = false
} else {
disablePasswordError = true
disablePasswordError = error
}
}
}
@@ -10,6 +10,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
@HiltViewModel
@@ -33,5 +35,10 @@ class DashboardViewModel @Inject constructor(
viewModelScope.launch { settingsRepository.resetPlan(usageRepository.totalMobileBytes()) }
}
suspend fun verifyPassword(password: String): Boolean = settingsRepository.verifyPassword(password)
/** null tant que les réglages ne sont pas chargés, sinon vrai si un mot de passe est défini. */
val passwordSet: StateFlow<Boolean?> = settingsRepository.settingsFlow.map<AppSettings, Boolean?> { it.hasPassword }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
/** null si le mot de passe est bon, sinon le message d'erreur. */
suspend fun checkPassword(password: String): String? = settingsRepository.checkPassword(password)
}
@@ -58,7 +58,7 @@ fun FamilySection(systemActions: SystemActions) {
// Action sensible en attente de confirmation par mot de passe : "admin" ou "unpair".
var pendingAction by remember { mutableStateOf<String?>(null) }
var pwd by remember { mutableStateOf("") }
var pwdError by remember { mutableStateOf(false) }
var pwdError by remember { mutableStateOf<String?>(null) }
// Au retour des écrans système (accessibilité, permissions, admin), on relit l'état réel.
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
@@ -69,7 +69,7 @@ fun FamilySection(systemActions: SystemActions) {
}
fun runProtected(action: String, block: () -> Unit) {
if (settings.hasPassword) { pendingAction = action; pwd = ""; pwdError = false } else block()
if (settings.hasPassword) { pendingAction = action; pwd = ""; pwdError = null } else block()
}
fun execute(action: String) {
@@ -227,22 +227,23 @@ fun FamilySection(systemActions: SystemActions) {
text = {
Column {
OutlinedTextField(
value = pwd, onValueChange = { pwd = it; pwdError = false },
value = pwd, onValueChange = { pwd = it; pwdError = null },
label = { Text(stringResource(R.string.password_field_label)) },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
isError = pwdError, singleLine = true, modifier = Modifier.fillMaxWidth()
isError = pwdError != null, singleLine = true, modifier = Modifier.fillMaxWidth()
)
if (pwdError) Text(
stringResource(R.string.password_incorrect),
pwdError?.let { Text(
it,
color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall
)
) }
}
},
confirmButton = {
Button(onClick = {
scope.launch {
if (vm.verifyPassword(pwd)) { pendingAction = null; execute(action) } else pwdError = true
val err = vm.checkPassword(pwd)
if (err == null) { pendingAction = null; execute(action) } else pwdError = err
}
}) { Text(stringResource(R.string.common_confirm)) }
},
@@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.map
import javax.inject.Inject
data class PairingUiState(val busy: Boolean = false, val error: String? = null)
@@ -54,5 +55,10 @@ class FamilyViewModel @Inject constructor(
viewModelScope.launch { remoteSync.unpair() }
}
suspend fun verifyPassword(password: String): Boolean = settingsRepository.verifyPassword(password)
/** null tant que les réglages ne sont pas chargés, sinon vrai si un mot de passe est défini. */
val passwordSet: StateFlow<Boolean?> = settingsRepository.settingsFlow.map<AppSettings, Boolean?> { it.hasPassword }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
/** null si le mot de passe est bon, sinon le message d'erreur. */
suspend fun checkPassword(password: String): String? = settingsRepository.checkPassword(password)
}
@@ -33,12 +33,21 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.jttools.datacontrol.log.AppLog
import com.jttools.datacontrol.service.FirewallVpnService
import androidx.compose.runtime.rememberCoroutineScope
import androidx.hilt.navigation.compose.hiltViewModel
import com.jttools.datacontrol.ui.components.PasswordPromptDialog
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun JournalScreen() {
val context = LocalContext.current
val entries by AppLog.entries.collectAsStateWithLifecycle()
val entries by AppLog.entries.collectAsStateWithLifecycle(initialValue = emptyList())
val viewModel: JournalViewModel = hiltViewModel()
val passwordSet by viewModel.passwordSet.collectAsStateWithLifecycle()
var askPassword by remember { mutableStateOf(false) }
var passwordError by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
// É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)) }
@@ -61,7 +70,12 @@ fun JournalScreen() {
.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") }
OutlinedButton(
enabled = passwordSet != null,
onClick = {
if (passwordSet == true) { passwordError = null; askPassword = true } else AppLog.clear()
}
) { Text("Effacer") }
}
LazyColumn(Modifier.fillMaxWidth().weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
items(entries.asReversed()) { line ->
@@ -74,8 +88,28 @@ fun JournalScreen() {
}
}
}
if (askPassword) {
ClearPasswordDialog(
error = passwordError,
onDismiss = { askPassword = false },
onConfirm = { password ->
scope.launch {
val error = viewModel.checkPassword(password)
if (error == null) { AppLog.clear(); askPassword = false } else passwordError = error
}
}
)
}
}
@Composable
private fun ClearPasswordDialog(
error: String?,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit
) = PasswordPromptDialog(title = "Effacer le journal", error = error, onDismiss = onDismiss, onConfirm = onConfirm)
private fun liveStatus(context: Context): String = buildString {
appendLine("VPN autorisé (prepare) : ${FirewallVpnService.isPrepared(context)}")
appendLine("Tunnel VPN monté : ${FirewallVpnService.tunnelActive}")
@@ -0,0 +1,24 @@
package com.jttools.datacontrol.ui.journal
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.jttools.datacontrol.data.AppSettings
import com.jttools.datacontrol.data.SettingsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
/** Effacer le journal fait disparaître la trace des usages : protégé par le mot de passe. */
@HiltViewModel
class JournalViewModel @Inject constructor(
private val settingsRepository: SettingsRepository
) : ViewModel() {
val passwordSet: StateFlow<Boolean?> = settingsRepository.settingsFlow.map<AppSettings, Boolean?> { it.hasPassword }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
suspend fun checkPassword(password: String): String? = settingsRepository.checkPassword(password)
}
@@ -53,8 +53,9 @@ import java.util.Locale
fun SettingsScreen(systemActions: SystemActions) {
val viewModel: SettingsViewModel = hiltViewModel()
val settings by viewModel.settings.collectAsStateWithLifecycle()
val passwordSet by viewModel.passwordSet.collectAsStateWithLifecycle()
PasswordGate(passwordSet = settings.hasPassword, verify = { viewModel.verifyPassword(it) }) {
PasswordGate(passwordSet = passwordSet, verify = { viewModel.checkPassword(it) }) {
SettingsContent(systemActions, viewModel, settings)
}
}
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.time.LocalDate
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@HiltViewModel
@@ -55,5 +56,10 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { settingsRepository.setPassword(password) }
}
suspend fun verifyPassword(password: String): Boolean = settingsRepository.verifyPassword(password)
/** null tant que les réglages ne sont pas chargés, sinon vrai si un mot de passe est défini. */
val passwordSet: StateFlow<Boolean?> = settingsRepository.settingsFlow.map<AppSettings, Boolean?> { it.hasPassword }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
/** null si le mot de passe est bon, sinon le message d'erreur. */
suspend fun checkPassword(password: String): String? = settingsRepository.checkPassword(password)
}
@@ -49,8 +49,9 @@ import com.jttools.datacontrol.ui.components.PasswordGate
fun WhitelistScreen() {
val viewModel: WhitelistViewModel = hiltViewModel()
val settings by viewModel.settings.collectAsStateWithLifecycle()
val passwordSet by viewModel.passwordSet.collectAsStateWithLifecycle()
PasswordGate(passwordSet = settings.hasPassword, verify = { viewModel.verifyPassword(it) }) {
PasswordGate(passwordSet = passwordSet, verify = { viewModel.checkPassword(it) }) {
WhitelistContent(viewModel, settings)
}
}
@@ -12,6 +12,8 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
@HiltViewModel
@@ -62,5 +64,10 @@ class WhitelistViewModel @Inject constructor(
viewModelScope.launch { settingsRepository.setShowSystemApps(show) }
}
suspend fun verifyPassword(password: String): Boolean = settingsRepository.verifyPassword(password)
/** null tant que les réglages ne sont pas chargés, sinon vrai si un mot de passe est défini. */
val passwordSet: StateFlow<Boolean?> = settingsRepository.settingsFlow.map<AppSettings, Boolean?> { it.hasPassword }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
/** null si le mot de passe est bon, sinon le message d'erreur. */
suspend fun checkPassword(password: String): String? = settingsRepository.checkPassword(password)
}