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:
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()
|
||||
}
|
||||
Reference in new issue
Block a user