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 = 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 { 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) private fun configFlow(fid: String, did: String) = callbackFlow { 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()?.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) { 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) { 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) { 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 } }