- 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
238 lines
11 KiB
Kotlin
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
|
|
}
|
|
}
|