feat: tunnel WireGuard vers le serveur familyd et mode propriétaire de l'appareil

- Firebase et le pare-feu local (trou noir, liste blanche par application, coupure cyclique locale) sont retirés
- Client WireGuard officiel (GoBackend) : tout le trafic passe par le serveur familyd, qui filtre
- Appairage par code ou lien familyd:// ; clé privée WireGuard et jeton chiffrés par l'Android Keystore
- Remontées par le tunnel : état, temps d'écran, applications installées, évènements (file persistante)
- Mode propriétaire (Device Owner) : VPN toujours actif avec blocage hors VPN, réglages verrouillés,
  garde-fou si le serveur ne répond plus pendant 15 min
- Plan de la refonte : docs/PLAN-wireguard.md

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 14:44:18 +01:00
1 parent f382eeb4fe
commit 33ea4ff947
40 files changed
+1680 -1635

No files matched your search

@@ -1,39 +1,32 @@
package com.jttools.datacontrol.data
import com.jttools.datacontrol.remote.FamilyLink
import java.time.LocalDate
/**
* Réglages persistés de l'application, avec leurs valeurs par défaut.
*/
data class AppSettings(
/** Tunnel WireGuard vers le serveur familyd monté en permanence (désactivation protégée par mot de passe). */
val controlEnabled: Boolean = false,
val planLimitMo: Long = 1000L,
val planPriceEuro: Double = 19.99,
val overagePricePerMoEuro: Double = 0.01,
val planStartDateEpochDay: Long = LocalDate.now().toEpochDay(),
val autoResetEnabled: Boolean = true,
val cutoffIntervalMinutes: Int = 15,
val baselineMobileBytesAtReset: Long = 0L,
val whitelistedPackages: Set<String> = DEFAULT_WHITELIST,
val whitelistedPackagesWifi: Set<String> = DEFAULT_WHITELIST,
val wifiFilterEnabled: Boolean = false,
val showSystemApps: Boolean = false,
val passwordHash: String? = null,
val passwordSalt: String? = null,
val cutoffCycleAnchorMillis: Long? = null,
val cutoffInPulse: Boolean = false,
val cutoffPulseStartMillis: Long = 0L,
/** Appairage avec FamilyControl (null tant que l'appareil n'est pas associé). */
val familyId: String? = null,
val deviceId: String? = null,
/** Horodatage (ms) de la dernière configuration distante appliquée. */
val remoteConfigVersion: Long = 0L,
/** Règles d'usage (plages horaires, limites de temps, applications interdites), au format JSON. */
/** Règles d'usage des applications (plages horaires, limites, interdites), envoyées par le serveur. */
val rulesJson: String? = null,
/** Configuration Firebase saisie dans l'application (null : voir firebase.properties de compilation). */
val firebase: com.jttools.datacontrol.remote.FirebaseConfig? = null
/** Liaison avec le serveur familyd (null tant que l'appareil n'est pas associé). */
val link: FamilyLink? = null,
/** Mode réseau appliqué par le serveur : open, allowlist ou cut. */
val serverMode: String = "open",
val serverModeReason: String = "",
val policyVersion: Long = 0L
) {
val isPaired: Boolean get() = !familyId.isNullOrEmpty() && !deviceId.isNullOrEmpty()
val isPaired: Boolean get() = link != null
val planStartDate: LocalDate get() = LocalDate.ofEpochDay(planStartDateEpochDay)
@@ -49,13 +42,6 @@ data class AppSettings(
}
return next
}
companion object {
const val PRONOTE_PACKAGE = "com.indexeducation.pronote"
/** Applications autorisées par défaut (tant que l'utilisateur n'a pas modifié la liste blanche). */
val DEFAULT_WHITELIST: Set<String> = setOf(PRONOTE_PACKAGE)
}
}
/** État calculé de la consommation, dérivé des réglages + de la mesure de données réelle. */
@@ -70,12 +56,9 @@ data class UsageState(
get() = if (planLimitMo <= 0) 0f else (consumedMo.toFloat() / planLimitMo.toFloat()).coerceAtLeast(0f)
}
/** Trafic cumulé d'une journée, pour l'historique envoyé à FamilyControl. */
data class TrafficDay(val date: String, val mobileBytes: Long, val wifiBytes: Long)
enum class DataState {
OK, // sous le seuil d'alerte, liste blanche active, pas de coupure en cours
WARNING, // proche ou au-delà du forfait (>= 80%), liste blanche active, pas de coupure en cours
CUT, // coupure totale cyclique en cours (indépendant du dépassement de forfait)
WIFI // Wi-Fi actif, aucun filtrage (sauf si filtrage Wi-Fi activé séparément)
OK, // sous le seuil d'alerte
WARNING, // proche ou au-delà du forfait (>= 80 %)
CUT, // le serveur coupe l'accès (mode « coupé » ou « liste blanche »)
WIFI // Wi-Fi actif
}
@@ -0,0 +1,51 @@
package com.jttools.datacontrol.data
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* Chiffrement des secrets (jeton de l'appareil, clé privée WireGuard) par une clé AES de l'Android
* Keystore : la clé ne sort jamais du matériel, une copie des fichiers de l'application ne suffit pas.
* Format : « v1:<base64(iv + chiffré)> ».
*/
object SecretBox {
private const val ALIAS = "datacontrol_secrets"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private fun key(): SecretKey {
val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(ks.getKey(ALIAS, null) as? SecretKey)?.let { return it }
val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
gen.init(
KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build()
)
return gen.generateKey()
}
fun encrypt(plain: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION).apply { init(Cipher.ENCRYPT_MODE, key()) }
val out = cipher.iv + cipher.doFinal(plain.toByteArray(Charsets.UTF_8))
return "v1:" + Base64.encodeToString(out, Base64.NO_WRAP)
}
/** null si la valeur est absente ou illisible (clé du Keystore perdue : il faut se ré-associer). */
fun decrypt(stored: String?): String? {
if (stored.isNullOrEmpty() || !stored.startsWith("v1:")) return null
return runCatching {
val raw = Base64.decode(stored.removePrefix("v1:"), Base64.NO_WRAP)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, raw, 0, 12))
String(cipher.doFinal(raw, 12, raw.size - 12), Charsets.UTF_8)
}.getOrNull()
}
}
@@ -2,27 +2,34 @@ 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.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.jttools.datacontrol.log.AppLog
import com.jttools.datacontrol.remote.FamilyLink
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.time.LocalDate
import javax.inject.Inject
import javax.inject.Singleton
private val Context.dataStore by preferencesDataStore(name = "datacontrol_settings")
/** Évènement à transmettre au serveur, gardé tant qu'il n'est pas parti (tunnel coupé, serveur injoignable). */
data class PendingEvent(val type: String, val detail: String, val at: Long)
@Singleton
class SettingsRepository @Inject constructor(
@ApplicationContext private val context: Context
@@ -34,42 +41,47 @@ class SettingsRepository @Inject constructor(
val OVERAGE_PRICE_PER_MO = doublePreferencesKey("overage_price_per_mo")
val PLAN_START_DATE_EPOCH_DAY = longPreferencesKey("plan_start_date_epoch_day")
val AUTO_RESET_ENABLED = booleanPreferencesKey("auto_reset_enabled")
val CUTOFF_INTERVAL_MINUTES = intPreferencesKey("cutoff_interval_minutes")
val BASELINE_MOBILE_BYTES = longPreferencesKey("baseline_mobile_bytes")
val WHITELISTED_PACKAGES = stringSetPreferencesKey("whitelisted_packages")
val WHITELISTED_PACKAGES_WIFI = stringSetPreferencesKey("whitelisted_packages_wifi")
val WIFI_FILTER_ENABLED = booleanPreferencesKey("wifi_filter_enabled")
val SHOW_SYSTEM_APPS = booleanPreferencesKey("show_system_apps")
val PASSWORD_HASH = stringPreferencesKey("password_hash")
val PASSWORD_SALT = stringPreferencesKey("password_salt")
val PASSWORD_RESET_V1_DONE = booleanPreferencesKey("password_reset_v1_done")
val CUTOFF_ANCHOR_MILLIS = longPreferencesKey("cutoff_anchor_millis")
val CUTOFF_IN_PULSE = booleanPreferencesKey("cutoff_in_pulse")
val CUTOFF_PULSE_START_MILLIS = longPreferencesKey("cutoff_pulse_start_millis")
val FAMILY_ID = stringPreferencesKey("family_id")
val DEVICE_ID = stringPreferencesKey("device_id")
val REMOTE_CONFIG_VERSION = longPreferencesKey("remote_config_version")
val RULES_JSON = stringPreferencesKey("rules_json")
val FIREBASE_API_KEY = stringPreferencesKey("firebase_api_key")
val FIREBASE_APP_ID = stringPreferencesKey("firebase_app_id")
val FIREBASE_PROJECT_ID = stringPreferencesKey("firebase_project_id")
val USAGE_DAY = stringPreferencesKey("usage_day")
val USAGE_JSON = stringPreferencesKey("usage_json")
val TRAFFIC_DAY = stringPreferencesKey("traffic_day")
val TRAFFIC_MOBILE_ACC = longPreferencesKey("traffic_mobile_acc")
val TRAFFIC_WIFI_ACC = longPreferencesKey("traffic_wifi_acc")
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 RULES_JSON = stringPreferencesKey("rules_json")
val USAGE_DAY = stringPreferencesKey("usage_day")
val USAGE_JSON = stringPreferencesKey("usage_json")
val CLOCK_SKEW_MS = longPreferencesKey("clock_skew_ms")
val CLOCK_ZONE = stringPreferencesKey("clock_zone")
// v0.2 : liaison familyd
val LINK_JSON = stringPreferencesKey("link_json")
val LINK_TOKEN_ENC = stringPreferencesKey("link_token_enc")
val WG_PRIVATE_ENC = stringPreferencesKey("wg_private_enc")
val SERVER_MODE = stringPreferencesKey("server_mode")
val SERVER_MODE_REASON = stringPreferencesKey("server_mode_reason")
val POLICY_VERSION = longPreferencesKey("policy_version")
val EVENT_QUEUE = stringPreferencesKey("event_queue")
val MIGRATED_V02 = booleanPreferencesKey("migrated_v02")
}
/** Clés de la v0.1 (Firebase, liste blanche locale, coupure cyclique locale) retirées à la mise à jour. */
private val obsoleteKeys: List<Preferences.Key<*>> = listOf(
intPreferencesKey("cutoff_interval_minutes"), stringSetPreferencesKey("whitelisted_packages"),
stringSetPreferencesKey("whitelisted_packages_wifi"), booleanPreferencesKey("wifi_filter_enabled"),
booleanPreferencesKey("show_system_apps"), longPreferencesKey("cutoff_anchor_millis"),
booleanPreferencesKey("cutoff_in_pulse"), longPreferencesKey("cutoff_pulse_start_millis"),
stringPreferencesKey("family_id"), stringPreferencesKey("device_id"),
longPreferencesKey("remote_config_version"), stringPreferencesKey("firebase_api_key"),
stringPreferencesKey("firebase_app_id"), stringPreferencesKey("firebase_project_id"),
stringPreferencesKey("traffic_day"), longPreferencesKey("traffic_mobile_acc"),
longPreferencesKey("traffic_wifi_acc"), longPreferencesKey("traffic_last_mobile"),
longPreferencesKey("traffic_last_other")
)
private companion object {
const val FREE_ATTEMPTS = 3
const val LOCK_BASE_MS = 30_000L
const val LOCK_MAX_MS = 15 * 60_000L
const val EVENT_QUEUE_MAX = 100
}
val settingsFlow: Flow<AppSettings> = context.dataStore.data.map { prefs ->
@@ -81,29 +93,29 @@ class SettingsRepository @Inject constructor(
overagePricePerMoEuro = prefs[Keys.OVERAGE_PRICE_PER_MO] ?: defaults.overagePricePerMoEuro,
planStartDateEpochDay = prefs[Keys.PLAN_START_DATE_EPOCH_DAY] ?: defaults.planStartDateEpochDay,
autoResetEnabled = prefs[Keys.AUTO_RESET_ENABLED] ?: defaults.autoResetEnabled,
cutoffIntervalMinutes = prefs[Keys.CUTOFF_INTERVAL_MINUTES] ?: defaults.cutoffIntervalMinutes,
baselineMobileBytesAtReset = prefs[Keys.BASELINE_MOBILE_BYTES] ?: defaults.baselineMobileBytesAtReset,
whitelistedPackages = prefs[Keys.WHITELISTED_PACKAGES] ?: defaults.whitelistedPackages,
whitelistedPackagesWifi = prefs[Keys.WHITELISTED_PACKAGES_WIFI] ?: defaults.whitelistedPackagesWifi,
wifiFilterEnabled = prefs[Keys.WIFI_FILTER_ENABLED] ?: defaults.wifiFilterEnabled,
showSystemApps = prefs[Keys.SHOW_SYSTEM_APPS] ?: defaults.showSystemApps,
passwordHash = prefs[Keys.PASSWORD_HASH],
passwordSalt = prefs[Keys.PASSWORD_SALT],
cutoffCycleAnchorMillis = prefs[Keys.CUTOFF_ANCHOR_MILLIS],
cutoffInPulse = prefs[Keys.CUTOFF_IN_PULSE] ?: defaults.cutoffInPulse,
cutoffPulseStartMillis = prefs[Keys.CUTOFF_PULSE_START_MILLIS] ?: defaults.cutoffPulseStartMillis,
familyId = prefs[Keys.FAMILY_ID],
deviceId = prefs[Keys.DEVICE_ID],
remoteConfigVersion = prefs[Keys.REMOTE_CONFIG_VERSION] ?: defaults.remoteConfigVersion,
rulesJson = prefs[Keys.RULES_JSON],
firebase = run {
val k = prefs[Keys.FIREBASE_API_KEY]; val a = prefs[Keys.FIREBASE_APP_ID]; val p = prefs[Keys.FIREBASE_PROJECT_ID]
if (k.isNullOrBlank() || a.isNullOrBlank() || p.isNullOrBlank()) null
else com.jttools.datacontrol.remote.FirebaseConfig(k, a, p)
}
link = FamilyLink.fromJson(prefs[Keys.LINK_JSON]),
serverMode = prefs[Keys.SERVER_MODE] ?: defaults.serverMode,
serverModeReason = prefs[Keys.SERVER_MODE_REASON] ?: defaults.serverModeReason,
policyVersion = prefs[Keys.POLICY_VERSION] ?: defaults.policyVersion
)
}
/** Passage à la v0.2 : l'ancien appairage Firebase et le filtrage local ne servent plus. */
suspend fun migrateToV02() {
context.dataStore.edit { prefs ->
if (prefs[Keys.MIGRATED_V02] == true) return@edit
obsoleteKeys.forEach { prefs.remove(it) }
prefs.remove(Keys.RULES_JSON)
// L'ancien contrôle (pare-feu local) est remplacé par le tunnel : à réactiver après appairage.
prefs[Keys.CONTROL_ENABLED] = false
prefs[Keys.MIGRATED_V02] = true
}
}
suspend fun setControlEnabled(enabled: Boolean) {
context.dataStore.edit { it[Keys.CONTROL_ENABLED] = enabled }
}
@@ -112,14 +124,12 @@ class SettingsRepository @Inject constructor(
planLimitMo: Long,
planPriceEuro: Double,
overagePricePerMoEuro: Double,
cutoffIntervalMinutes: Int,
autoResetEnabled: Boolean
) {
context.dataStore.edit { prefs ->
prefs[Keys.PLAN_LIMIT_MO] = planLimitMo
prefs[Keys.PLAN_PRICE_EURO] = planPriceEuro
prefs[Keys.OVERAGE_PRICE_PER_MO] = overagePricePerMoEuro
prefs[Keys.CUTOFF_INTERVAL_MINUTES] = cutoffIntervalMinutes
prefs[Keys.AUTO_RESET_ENABLED] = autoResetEnabled
}
}
@@ -129,22 +139,6 @@ class SettingsRepository @Inject constructor(
context.dataStore.edit { it[Keys.PLAN_START_DATE_EPOCH_DAY] = date.toEpochDay() }
}
suspend fun setWhitelistedPackages(packages: Set<String>) {
context.dataStore.edit { it[Keys.WHITELISTED_PACKAGES] = packages }
}
suspend fun setWhitelistedPackagesWifi(packages: Set<String>) {
context.dataStore.edit { it[Keys.WHITELISTED_PACKAGES_WIFI] = packages }
}
suspend fun setWifiFilterEnabled(enabled: Boolean) {
context.dataStore.edit { it[Keys.WIFI_FILTER_ENABLED] = enabled }
}
suspend fun setShowSystemApps(show: Boolean) {
context.dataStore.edit { it[Keys.SHOW_SYSTEM_APPS] = show }
}
/** Réinitialise le compteur de consommation : nouvelle date de début + nouvelle base de référence. */
suspend fun resetPlan(currentMobileBytes: Long, startDate: LocalDate = LocalDate.now()) {
context.dataStore.edit { prefs ->
@@ -161,18 +155,15 @@ class SettingsRepository @Inject constructor(
/**
* Force la consommation affichée à [consumedMo] sans changer la date de début du cycle, en
* recalculant la base de référence à partir du compteur système actuel ([currentTotalBytes]).
* Utile pour recaler l'app sur le décompte opérateur réel.
*
* La base peut devenir négative (ex: valeur saisie supérieure au compteur TrafficStats depuis
* le dernier redémarrage de l'appareil) : c'est volontaire, ne pas la borner à 0 sous peine
* d'écraser silencieusement la valeur saisie par l'utilisateur. [UsageRepository.consumedBytesSince]
* et [UsageRepository.hasCounterReset] gèrent correctement une base négative.
* La base peut devenir négative : c'est volontaire (voir [UsageRepository.consumedBytesSince]).
*/
suspend fun setConsumedMo(consumedMo: Long, currentTotalBytes: Long) {
val baseline = currentTotalBytes - UsageRepository.moToBytes(consumedMo)
context.dataStore.edit { it[Keys.BASELINE_MOBILE_BYTES] = baseline }
}
// --- Mot de passe ----------------------------------------------------------------------
/** Définit (ou supprime, si [password] est vide/null) le mot de passe de verrouillage. */
suspend fun setPassword(password: String?) {
if (password.isNullOrBlank()) {
@@ -192,19 +183,6 @@ class SettingsRepository @Inject constructor(
}
}
/** 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.
@@ -278,89 +256,21 @@ class SettingsRepository @Inject constructor(
return if (s < 60) "Trop d'essais : réessayez dans $s s." else "Trop d'essais : réessayez dans ${(s + 59) / 60} min."
}
/**
* Persiste l'état du cycle de coupure automatique, pour que le minuteur ne reparte pas de
* zéro si [DataMonitorService] est tué puis relancé par le système avant la fin de l'intervalle
* (fréquent sur les ROM qui tuent agressivement les apps en arrière-plan sans exemption batterie).
*/
suspend fun setCutoffCycleState(anchorMillis: Long?, inPulse: Boolean, pulseStartMillis: Long) {
context.dataStore.edit { prefs ->
if (anchorMillis == null) prefs.remove(Keys.CUTOFF_ANCHOR_MILLIS) else prefs[Keys.CUTOFF_ANCHOR_MILLIS] = anchorMillis
prefs[Keys.CUTOFF_IN_PULSE] = inPulse
prefs[Keys.CUTOFF_PULSE_START_MILLIS] = pulseStartMillis
}
// --- Horloge de référence et temps d'écran --------------------------------------------------
/** 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 setPairing(familyId: String?, deviceId: String?) {
suspend fun setTrustedClock(skewMs: Long, zoneId: String) {
context.dataStore.edit {
if (familyId == null || deviceId == null) {
it.remove(Keys.FAMILY_ID)
it.remove(Keys.DEVICE_ID)
it.remove(Keys.REMOTE_CONFIG_VERSION)
it.remove(Keys.RULES_JSON)
} else {
it[Keys.FAMILY_ID] = familyId
it[Keys.DEVICE_ID] = deviceId
it[Keys.REMOTE_CONFIG_VERSION] = 0L
}
if (skewMs == 0L) it.remove(Keys.CLOCK_SKEW_MS) else it[Keys.CLOCK_SKEW_MS] = skewMs
it[Keys.CLOCK_ZONE] = zoneId
}
}
/**
* Applique une configuration envoyée par FamilyControl. Chaque champ null est ignoré.
* [version] (horodatage de l'écriture côté parent) évite de ré-appliquer une ancienne
* configuration au redémarrage et d'écraser un réglage local plus récent.
*/
suspend fun applyRemoteConfig(
version: Long,
controlEnabled: Boolean?,
whitelistedPackages: Set<String>?,
whitelistedPackagesWifi: Set<String>?,
wifiFilterEnabled: Boolean?,
planLimitMo: Long?,
cutoffIntervalMinutes: Int?,
rulesJson: String?
) {
context.dataStore.edit { prefs ->
if ((prefs[Keys.REMOTE_CONFIG_VERSION] ?: 0L) >= version) return@edit
rulesJson?.let { prefs[Keys.RULES_JSON] = it }
prefs[Keys.REMOTE_CONFIG_VERSION] = version
controlEnabled?.let { prefs[Keys.CONTROL_ENABLED] = it }
whitelistedPackages?.let { prefs[Keys.WHITELISTED_PACKAGES] = it }
whitelistedPackagesWifi?.let { prefs[Keys.WHITELISTED_PACKAGES_WIFI] = it }
wifiFilterEnabled?.let { prefs[Keys.WIFI_FILTER_ENABLED] = it }
planLimitMo?.takeIf { it > 0 }?.let { prefs[Keys.PLAN_LIMIT_MO] = it }
cutoffIntervalMinutes?.takeIf { it > 0 }?.let { prefs[Keys.CUTOFF_INTERVAL_MINUTES] = it }
}
}
/**
* Cumule le trafic de la journée à partir des compteurs système (qui repartent de zéro au
* redémarrage : un compteur inférieur au précédent est traité comme un redémarrage).
* [otherBytes] = trafic non mobile (Wi-Fi, etc.) = total - mobile.
*/
suspend fun accumulateTraffic(today: String, mobileBytes: Long, otherBytes: Long): TrafficDay {
var result = TrafficDay(today, 0L, 0L)
context.dataStore.edit { prefs ->
val sameDay = prefs[Keys.TRAFFIC_DAY] == today
var mobileAcc = if (sameDay) prefs[Keys.TRAFFIC_MOBILE_ACC] ?: 0L else 0L
var wifiAcc = if (sameDay) prefs[Keys.TRAFFIC_WIFI_ACC] ?: 0L else 0L
val lastMobile = prefs[Keys.TRAFFIC_LAST_MOBILE]
val lastOther = prefs[Keys.TRAFFIC_LAST_OTHER]
if (lastMobile != null && lastOther != null) {
mobileAcc += if (mobileBytes >= lastMobile) mobileBytes - lastMobile else mobileBytes
wifiAcc += if (otherBytes >= lastOther) otherBytes - lastOther else otherBytes
}
prefs[Keys.TRAFFIC_DAY] = today
prefs[Keys.TRAFFIC_MOBILE_ACC] = mobileAcc
prefs[Keys.TRAFFIC_WIFI_ACC] = wifiAcc
prefs[Keys.TRAFFIC_LAST_MOBILE] = mobileBytes
prefs[Keys.TRAFFIC_LAST_OTHER] = otherBytes
result = TrafficDay(today, mobileAcc, wifiAcc)
}
return result
}
suspend fun loadUsage(): Pair<String, String>? {
val prefs = context.dataStore.data.first()
val day = prefs[Keys.USAGE_DAY] ?: return null
@@ -375,16 +285,71 @@ class SettingsRepository @Inject constructor(
}
}
/** Enregistre (ou efface, si null) la configuration Firebase saisie dans l'application. */
suspend fun setFirebaseConfig(config: com.jttools.datacontrol.remote.FirebaseConfig?) {
// --- Liaison familyd --------------------------------------------------------------------
/** Enregistre l'appairage. Le jeton et la clé privée sont chiffrés par l'Android Keystore. */
suspend fun savePairing(link: FamilyLink, deviceToken: String, wgPrivateKey: String) {
val token = SecretBox.encrypt(deviceToken)
val key = SecretBox.encrypt(wgPrivateKey)
context.dataStore.edit {
if (config == null) {
it.remove(Keys.FIREBASE_API_KEY); it.remove(Keys.FIREBASE_APP_ID); it.remove(Keys.FIREBASE_PROJECT_ID)
} else {
it[Keys.FIREBASE_API_KEY] = config.apiKey
it[Keys.FIREBASE_APP_ID] = config.appId
it[Keys.FIREBASE_PROJECT_ID] = config.projectId
}
it[Keys.LINK_JSON] = link.toJson()
it[Keys.LINK_TOKEN_ENC] = token
it[Keys.WG_PRIVATE_ENC] = key
it[Keys.SERVER_MODE] = "open"
it[Keys.SERVER_MODE_REASON] = ""
it[Keys.POLICY_VERSION] = 0L
it.remove(Keys.RULES_JSON)
it.remove(Keys.EVENT_QUEUE)
}
}
suspend fun clearPairing() {
context.dataStore.edit {
listOf(Keys.LINK_JSON, Keys.LINK_TOKEN_ENC, Keys.WG_PRIVATE_ENC, Keys.SERVER_MODE,
Keys.SERVER_MODE_REASON, Keys.POLICY_VERSION, Keys.RULES_JSON, Keys.EVENT_QUEUE).forEach { k -> it.remove(k) }
it[Keys.CONTROL_ENABLED] = false
}
}
suspend fun deviceToken(): String? = SecretBox.decrypt(context.dataStore.data.first()[Keys.LINK_TOKEN_ENC])
suspend fun wgPrivateKey(): String? = SecretBox.decrypt(context.dataStore.data.first()[Keys.WG_PRIVATE_ENC])
/** Consigne reçue du serveur (mode réseau en cours et règles d'applications). */
suspend fun applyServerPolicy(version: Long, mode: String, reason: String, rulesJson: String?) {
context.dataStore.edit {
it[Keys.POLICY_VERSION] = version
it[Keys.SERVER_MODE] = mode
it[Keys.SERVER_MODE_REASON] = reason
if (rulesJson.isNullOrBlank() || rulesJson == "{}") it.remove(Keys.RULES_JSON) else it[Keys.RULES_JSON] = rulesJson
}
}
// --- File des évènements --------------------------------------------------------------------
suspend fun queueEvent(event: PendingEvent) {
context.dataStore.edit {
val list = parseQueue(it[Keys.EVENT_QUEUE]) + event
it[Keys.EVENT_QUEUE] = serializeQueue(list.takeLast(EVENT_QUEUE_MAX))
}
}
suspend fun pendingEvents(): List<PendingEvent> = parseQueue(context.dataStore.data.first()[Keys.EVENT_QUEUE])
/** Retire de la file les [count] premiers évènements (envoyés avec succès). */
suspend fun dropEvents(count: Int) {
context.dataStore.edit {
val rest = parseQueue(it[Keys.EVENT_QUEUE]).drop(count)
if (rest.isEmpty()) it.remove(Keys.EVENT_QUEUE) else it[Keys.EVENT_QUEUE] = serializeQueue(rest)
}
}
private fun parseQueue(json: String?): List<PendingEvent> = runCatching {
val a = JSONArray(json ?: return emptyList())
(0 until a.length()).map { a.getJSONObject(it) }
.map { PendingEvent(it.getString("type"), it.optString("detail"), it.getLong("at")) }
}.getOrDefault(emptyList())
private fun serializeQueue(list: List<PendingEvent>): String =
JSONArray(list.map { JSONObject().put("type", it.type).put("detail", it.detail).put("at", it.at) }).toString()
}