Files
DataControl/app/src/main/java/com/jttools/datacontrol/remote/RemoteSync.kt
T
tuxgyverandClaude Opus 5.5 e269e9932a 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
2026-10-25 10:49:43 +01:00

238 lines
11 KiB
Kotlin

package com.jttools.datacontrol.remote
import android.os.Build
import android.util.Log
import com.google.firebase.firestore.DocumentReference
import com.google.firebase.firestore.SetOptions
import com.jttools.datacontrol.data.AppSettings
import com.jttools.datacontrol.data.InstalledAppsRepository
import com.jttools.datacontrol.data.SettingsRepository
import com.jttools.datacontrol.data.TrafficDay
import com.jttools.datacontrol.log.AppLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.first
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
/**
* Liaison avec FamilyControl via Firestore.
*
* Modèle (voir FamilyControl/firebase/firestore.rules) :
* - pairings/{code} : créé par le parent, lu une fois par l'enfant
* - families/{fid}/devices/{did} : état, batterie, position (écrit par l'appareil)
* - families/{fid}/devices/{did}/config/remote : consignes du parent (lues par l'appareil)
* - families/{fid}/devices/{did}/traffic/{yyyy-MM-dd}: historique de trafic
* - families/{fid}/devices/{did}/events/{auto} : journal d'évènements
*
* L'appareil ne s'auto-bloque jamais : l'app DataControl est toujours exclue du tunnel VPN
* (cf. FirewallVpnService), donc ces échanges passent même quand le réseau est coupé.
*/
@Singleton
class RemoteSync @Inject constructor(
private val gateway: FirebaseGateway,
private val settingsRepository: SettingsRepository,
private val installedApps: InstalledAppsRepository
) {
val available: Boolean get() = gateway.enabled
val gatewayState = gateway.state
private fun deviceDoc(s: AppSettings): DocumentReference? {
val db = gateway.db ?: return null
val fid = s.familyId ?: return null
val did = s.deviceId ?: return null
return db.collection("families").document(fid).collection("devices").document(did)
}
/** Associe cet appareil à la famille correspondant au [code] affiché dans FamilyControl. */
suspend fun pair(code: String, deviceName: String): Result<Unit> = runCatching {
check(gateway.enabled) { "Firebase non configuré dans cette version" }
val db = gateway.db!!
val uid = gateway.ensureSignedIn() ?: error("Connexion Firebase impossible (réseau ?)")
val clean = code.trim().uppercase()
val pairing = db.collection("pairings").document(clean).get().await()
check(pairing.exists()) { "Code inconnu ou expiré" }
val familyId = pairing.getString("familyId") ?: error("Code invalide")
check((pairing.getLong("expiresAt") ?: 0L) > System.currentTimeMillis()) { "Code expiré" }
val deviceId = UUID.randomUUID().toString()
db.collection("families").document(familyId).collection("devices").document(deviceId).set(
mapOf(
"deviceUid" to uid,
"name" to deviceName.ifBlank { Build.MODEL },
"model" to "${Build.MANUFACTURER} ${Build.MODEL}",
"pairingCode" to clean,
"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)
}
suspend fun unpair() {
val s = settingsRepository.settingsFlow.first()
pushEvent("unpaired", "Appareil dissocié depuis DataControl")
deviceDoc(s)?.let { runCatching { it.update("unpairedAt", System.currentTimeMillis()) } }
settingsRepository.setPairing(null, null)
}
/** Écoute les consignes du parent et les applique ; relancé à chaque (ré)initialisation de Firebase. */
@OptIn(ExperimentalCoroutinesApi::class)
fun start(scope: CoroutineScope) {
scope.launch {
gateway.state.collectLatest { st ->
if (!st.enabled) return@collectLatest
coroutineScope {
launch {
settingsRepository.settingsFlow
.map { it.familyId to it.deviceId }
.distinctUntilChanged()
.flatMapLatest { (fid, did) ->
if (fid == null || did == null) flowOf(null) else configFlow(fid, did)
}
.collect { cfg -> if (cfg != null) apply(cfg) }
}
launch {
// Si le parent supprime l'appareil de la famille, on se dissocie localement (plus de règles,
// plus d'envois qui échoueraient en silence).
settingsRepository.settingsFlow
.map { it.familyId to it.deviceId }
.distinctUntilChanged()
.flatMapLatest { (fid, did) -> if (fid == null || did == null) flowOf(null) else existenceFlow(fid, did) }
.collect { exists ->
if (exists == false) {
AppLog.w("FamilyControl", "Appareil supprimé par le parent : dissociation locale")
settingsRepository.setPairing(null, null)
}
}
}
launch {
// Liste des applications installées, pour que le parent puisse choisir lesquelles autoriser.
settingsRepository.settingsFlow.map { it.isPaired }.distinctUntilChanged().collect { paired ->
if (paired) pushApps()
}
}
}
}
}
}
/** Vrai/faux selon que le document de l'appareil existe côté serveur (ignore le cache local). */
private fun existenceFlow(fid: String, did: String) = callbackFlow<Boolean?> {
val db = gateway.db
if (db == null || gateway.ensureSignedIn() == null) { trySend(null); close(); return@callbackFlow }
val reg = db.collection("families").document(fid).collection("devices").document(did)
.addSnapshotListener { snap, err ->
if (err != null || snap == null || snap.metadata.isFromCache) return@addSnapshotListener
trySend(snap.exists())
}
awaitClose { reg.remove() }
}
private class Config(val map: Map<String, Any?>)
private fun configFlow(fid: String, did: String) = callbackFlow<Config?> {
val db = gateway.db
if (db == null || gateway.ensureSignedIn() == null) { trySend(null); close(); return@callbackFlow }
val reg = db.collection("families").document(fid).collection("devices").document(did)
.collection("config").document("remote")
.addSnapshotListener { snap, err ->
if (err != null) { Log.w(TAG, "config listener", err); return@addSnapshotListener }
if (snap != null && snap.exists()) trySend(Config(snap.data.orEmpty()))
}
awaitClose { reg.remove() }
}
private suspend fun apply(cfg: Config) {
val m = cfg.map
val version = (m["updatedAt"] as? Number)?.toLong() ?: return
fun strings(key: String) = (m[key] as? List<*>)?.filterIsInstance<String>()?.toSet()
settingsRepository.applyRemoteConfig(
version = version,
controlEnabled = m["controlEnabled"] as? Boolean,
whitelistedPackages = strings("whitelistedPackages"),
whitelistedPackagesWifi = strings("whitelistedPackagesWifi"),
wifiFilterEnabled = m["wifiFilterEnabled"] as? Boolean,
planLimitMo = (m["planLimitMo"] as? Number)?.toLong(),
cutoffIntervalMinutes = (m["cutoffIntervalMinutes"] as? Number)?.toInt(),
rulesJson = (m["rules"] as? Map<*, *>)?.let { org.json.JSONObject(it).toString() }
)
}
/** 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 {
mapOf("pkg" to it.packageName, "label" to it.label, "system" to it.isSystemApp)
}
write(ref.collection("meta").document("apps"), mapOf("apps" to apps, "at" to System.currentTimeMillis()))
}
/** Fusionne [data] dans le document de l'appareil (état, batterie, position…). */
suspend fun pushDevice(data: Map<String, Any?>) {
val s = settingsRepository.settingsFlow.first()
val ref = deviceDoc(s) ?: return
write(ref, data + ("lastSeen" to System.currentTimeMillis()))
}
suspend fun pushTraffic(day: TrafficDay) {
val s = settingsRepository.settingsFlow.first()
val ref = deviceDoc(s) ?: return
write(
ref.collection("traffic").document(day.date),
mapOf("date" to day.date, "mobileBytes" to day.mobileBytes, "wifiBytes" to day.wifiBytes,
"at" to System.currentTimeMillis())
)
}
suspend fun pushUsage(date: String, totalMs: Long, appMs: Map<String, Long>) {
val s = settingsRepository.settingsFlow.first()
val ref = deviceDoc(s) ?: return
write(
ref.collection("usage").document(date),
mapOf("date" to date, "totalMs" to totalMs, "apps" to appMs, "at" to System.currentTimeMillis())
)
}
suspend fun pushEvent(type: String, detail: String) {
val s = settingsRepository.settingsFlow.first()
val ref = deviceDoc(s) ?: return
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 {
withTimeoutOrNull(EVENT_TIMEOUT_MS) {
ref.collection("events").add(
mapOf("type" to type, "detail" to detail, "at" to System.currentTimeMillis())
).await()
}
}
}
private suspend fun write(ref: DocumentReference, data: Map<String, Any?>) {
if (gateway.ensureSignedIn() == null) return
// Les écritures sont mises en file par le SDK hors-ligne : pas d'attente bloquante du réseau.
ref.set(data, SetOptions.merge())
}
companion object {
private const val TAG = "RemoteSync"
private const val EVENT_TIMEOUT_MS = 8_000L
}
}