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:
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)
|
||||
}
|
||||
@@ -12,10 +12,8 @@
|
||||
<!-- Dashboard -->
|
||||
<string name="dashboard_title">Tableau de bord</string>
|
||||
<string name="dashboard_consumed">Données consommées</string>
|
||||
<string name="dashboard_plan_label">Forfait</string>
|
||||
<string name="dashboard_remaining">Restant</string>
|
||||
<string name="dashboard_overage">Dépassement</string>
|
||||
<string name="dashboard_state_label">État des données</string>
|
||||
<string name="dashboard_state_ok">Sous le forfait</string>
|
||||
<string name="dashboard_state_warning">Proche du forfait</string>
|
||||
<string name="dashboard_state_cut">Coupure totale en cours</string>
|
||||
@@ -30,7 +28,6 @@
|
||||
<string name="dashboard_reset_now">Réinitialiser le forfait maintenant</string>
|
||||
<string name="dashboard_reset_confirm_title">Réinitialiser le forfait ?</string>
|
||||
<string name="dashboard_reset_confirm_message">Le compteur de données consommées sera remis à zéro immédiatement.</string>
|
||||
<string name="dashboard_permissions_needed">Autorisations requises pour fonctionner correctement</string>
|
||||
<string name="dashboard_control_disabled">Contrôle désactivé</string>
|
||||
<string name="dashboard_next_cutoff">Prochaine coupure</string>
|
||||
<string name="dashboard_cutoff_wifi">Wi-Fi actif (pas de coupure)</string>
|
||||
@@ -52,8 +49,6 @@
|
||||
<string name="settings_cutoff_interval">Intervalle de coupure (minutes)</string>
|
||||
<string name="settings_cutoff_help">Une coupure totale (même pour la liste blanche) se déclenche cycliquement à cet intervalle, que le forfait soit dépassé ou non.</string>
|
||||
<string name="settings_section_permissions">Autorisations</string>
|
||||
<string name="settings_perm_usage_access">Accès aux statistiques d\'utilisation</string>
|
||||
<string name="settings_perm_usage_access_desc">Nécessaire pour mesurer la consommation de données mobiles</string>
|
||||
<string name="settings_perm_vpn">Pare-feu local (VPN)</string>
|
||||
<string name="settings_perm_vpn_desc">Nécessaire pour filtrer les applications sur les données mobiles</string>
|
||||
<string name="settings_perm_notifications">Notifications</string>
|
||||
@@ -64,7 +59,6 @@
|
||||
<string name="settings_perm_missing">Manquante</string>
|
||||
<string name="settings_save">Enregistrer</string>
|
||||
<string name="settings_edit_date">Modifier</string>
|
||||
<string name="settings_saved">Réglages enregistrés</string>
|
||||
<string name="settings_section_security">Sécurité</string>
|
||||
<string name="settings_password_active">Mot de passe actif — protège la désactivation et les réglages</string>
|
||||
<string name="settings_password_inactive">Aucun mot de passe défini</string>
|
||||
@@ -80,11 +74,9 @@
|
||||
<!-- Verrouillage par mot de passe -->
|
||||
<string name="password_gate_title">Zone protégée</string>
|
||||
<string name="password_field_label">Mot de passe</string>
|
||||
<string name="password_incorrect">Mot de passe incorrect</string>
|
||||
<string name="password_unlock">Déverrouiller</string>
|
||||
|
||||
<!-- About -->
|
||||
<string name="about_title">À propos</string>
|
||||
<string name="about_version">Version %1$s</string>
|
||||
<string name="update_section">Mise à jour</string>
|
||||
<string name="update_check">Rechercher une mise à jour</string>
|
||||
@@ -116,8 +108,6 @@
|
||||
<string name="notification_title">DataControl</string>
|
||||
|
||||
<!-- Common -->
|
||||
<string name="unit_mo">Mo</string>
|
||||
<string name="unit_euro">€</string>
|
||||
<string name="common_cancel">Annuler</string>
|
||||
<string name="common_confirm">Confirmer</string>
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Android 11 et avant. Réglages (DataStore), appairage, mot de passe et journal sont propres à cet
|
||||
appareil : restaurés ailleurs, l'appareil se croirait associé alors que Firestore refuse ses écritures. -->
|
||||
<full-backup-content>
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="file" path="datastore/" />
|
||||
<exclude domain="file" path="journal.txt" />
|
||||
</full-backup-content>
|
||||
@@ -1,6 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Android 12+. Voir backup_rules.xml : rien de propre à l'appareil ne doit être sauvegardé ni transféré. -->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="file" path="datastore/" />
|
||||
<exclude domain="file" path="journal.txt" />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="file" path="datastore/" />
|
||||
<exclude domain="file" path="journal.txt" />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.jttools.datacontrol.data
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.security.MessageDigest
|
||||
|
||||
class PasswordHasherTest {
|
||||
|
||||
@Test fun `bon et mauvais mot de passe`() {
|
||||
val salt = PasswordHasher.newSalt()
|
||||
val hash = PasswordHasher.hash("1234", salt)
|
||||
assertTrue(hash.startsWith("pbkdf2:"))
|
||||
assertTrue(PasswordHasher.verify("1234", salt, hash))
|
||||
assertFalse(PasswordHasher.verify("1235", salt, hash))
|
||||
assertFalse(PasswordHasher.needsUpgrade(hash))
|
||||
}
|
||||
|
||||
@Test fun `le sel change le hachage`() {
|
||||
val a = PasswordHasher.hash("1234", PasswordHasher.newSalt())
|
||||
val b = PasswordHasher.hash("1234", PasswordHasher.newSalt())
|
||||
assertTrue(a != b)
|
||||
}
|
||||
|
||||
@Test fun `ancien format SHA-256 toujours accepte et a convertir`() {
|
||||
val salt = "00112233445566778899aabbccddeeff"
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
md.update(salt.toByteArray())
|
||||
val legacy = md.digest("secret".toByteArray()).joinToString("") { "%02x".format(it) }
|
||||
assertTrue(PasswordHasher.verify("secret", salt, legacy))
|
||||
assertFalse(PasswordHasher.verify("autre", salt, legacy))
|
||||
assertTrue(PasswordHasher.needsUpgrade(legacy))
|
||||
}
|
||||
|
||||
@Test fun `hachage illisible refuse`() {
|
||||
assertFalse(PasswordHasher.verify("1234", PasswordHasher.newSalt(), "pbkdf2:abc:00"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.jttools.datacontrol.data
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class UpdateManagerTest {
|
||||
@Test fun `comparaison de versions`() {
|
||||
assertTrue(UpdateManager.isNewer("0.1.9", "0.1.8"))
|
||||
assertTrue(UpdateManager.isNewer("0.2.0", "0.1.10"))
|
||||
assertTrue(UpdateManager.isNewer("0.1.10", "0.1.9")) // numérique, pas alphabétique
|
||||
assertTrue(UpdateManager.isNewer("1.0", "0.9.9"))
|
||||
assertFalse(UpdateManager.isNewer("0.1.8", "0.1.8"))
|
||||
assertFalse(UpdateManager.isNewer("0.1.8", "0.1.8.0"))
|
||||
assertFalse(UpdateManager.isNewer("0.1.7", "0.1.8"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.jttools.datacontrol.guard
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class RulesTest {
|
||||
|
||||
// 2026-10-26 est un lundi.
|
||||
private fun at(day: Int, h: Int, m: Int = 0) = LocalDateTime.of(2026, 10, 25, h, m).plusDays(day.toLong())
|
||||
|
||||
private val json = """
|
||||
{"schedule":{"enabled":true,"cutsMobile":false,
|
||||
"windows":[{"days":[1,2,3,4,5],"start":480,"end":1200},{"days":[5],"start":1320,"end":120}]},
|
||||
"dailyLimitMin":90,
|
||||
"appLimits":[{"pkg":"com.game","min":30}],
|
||||
"blocked":["com.bad"],
|
||||
"bonus":{"date":"2026-10-26","globalMin":15,"apps":[{"pkg":"com.game","min":10}]}}
|
||||
""".trimIndent()
|
||||
|
||||
@Test fun `lecture complete`() {
|
||||
val r = Rules.parse(json)
|
||||
assertTrue(r.scheduleEnabled)
|
||||
assertFalse(r.scheduleCutsMobile)
|
||||
assertEquals(2, r.windows.size)
|
||||
assertEquals(90, r.dailyLimitMin)
|
||||
assertEquals(listOf(AppLimit("com.game", 30)), r.appLimits)
|
||||
assertEquals(setOf("com.bad"), r.blocked)
|
||||
assertEquals("2026-10-26", r.bonusDate)
|
||||
assertEquals(15, r.bonusGlobalMin)
|
||||
assertFalse(r.isEmpty)
|
||||
}
|
||||
|
||||
@Test fun `json vide ou invalide donne aucune regle`() {
|
||||
assertTrue(Rules.parse(null).isEmpty)
|
||||
assertTrue(Rules.parse("").isEmpty)
|
||||
assertTrue(Rules.parse("{pas du json").isEmpty)
|
||||
}
|
||||
|
||||
@Test fun `plage de journee`() {
|
||||
val r = Rules.parse(json)
|
||||
assertTrue(r.isOpenAt(at(1, 8))) // lundi 08:00 : début inclus
|
||||
assertTrue(r.isOpenAt(at(1, 19, 59)))
|
||||
assertFalse(r.isOpenAt(at(1, 20))) // fin exclue
|
||||
assertFalse(r.isOpenAt(at(1, 7, 59)))
|
||||
assertFalse(r.isOpenAt(at(6, 12))) // samedi : pas de plage de journée
|
||||
}
|
||||
|
||||
@Test fun `plage qui passe minuit deborde sur le lendemain`() {
|
||||
val r = Rules.parse(json)
|
||||
assertTrue(r.isOpenAt(at(5, 23))) // vendredi 23:00
|
||||
assertTrue(r.isOpenAt(at(6, 1, 30))) // samedi 01:30, suite de la plage du vendredi
|
||||
assertFalse(r.isOpenAt(at(6, 2)))
|
||||
assertFalse(r.isOpenAt(at(7, 1))) // dimanche 01:00 : pas de plage samedi soir
|
||||
}
|
||||
|
||||
@Test fun `prochaine ouverture`() {
|
||||
val r = Rules.parse(json)
|
||||
assertEquals("Reprise à 08:00.", r.nextOpeningText(at(1, 6)))
|
||||
assertEquals("Reprise à 08:00 (dans 2 j).", r.nextOpeningText(at(6, 12)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.jttools.datacontrol.remote
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import java.util.Base64
|
||||
|
||||
class FirebaseConfigTest {
|
||||
private val expected = FirebaseConfig("AIzaTest", "1:123:android:abc", "mon-projet")
|
||||
|
||||
@Test fun `bloc firebaseConfig de la console`() {
|
||||
val js = """
|
||||
const firebaseConfig = {
|
||||
apiKey: "AIzaTest",
|
||||
authDomain: "mon-projet.firebaseapp.com",
|
||||
projectId: "mon-projet",
|
||||
appId: "1:123:android:abc"
|
||||
};
|
||||
""".trimIndent()
|
||||
assertEquals(expected, FirebaseConfig.parse(js))
|
||||
}
|
||||
|
||||
@Test fun `json`() {
|
||||
assertEquals(
|
||||
expected,
|
||||
FirebaseConfig.parse("""{"apiKey":"AIzaTest","appId":"1:123:android:abc","projectId":"mon-projet"}""")
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `chaine fc1 de FamilyControl`() {
|
||||
val b64 = Base64.getEncoder().encodeToString(
|
||||
"""{"apiKey":"AIzaTest","appId":"1:123:android:abc","projectId":"mon-projet"}""".toByteArray()
|
||||
)
|
||||
assertEquals(expected, FirebaseConfig.parse("fc1:$b64"))
|
||||
assertEquals(expected, FirebaseConfig.parse(" fc1:${b64.chunked(20).joinToString("\n")} "))
|
||||
}
|
||||
|
||||
@Test fun `champ manquant ou illisible`() {
|
||||
assertNull(FirebaseConfig.parse("""{"apiKey":"AIzaTest","projectId":"mon-projet"}"""))
|
||||
assertNull(FirebaseConfig.parse("fc1:!!!"))
|
||||
assertNull(FirebaseConfig.parse(""))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user