diff --git a/.gitignore b/.gitignore
index 0bb1433..831639b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,3 +16,6 @@ local.properties
# Jeton Gitea (lecture/écriture) pour "make publish"/"make deploy" : local,
# jamais versionné.
gitea-token.properties
+
+# Configuration Firebase locale (clés du projet).
+firebase.properties
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 50464c3..19e12d5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## v0.1.3 – 2026-09-20
+
+- **Correctif VPN** : le suivi du réseau ignore désormais le tunnel VPN lui-même (le pare-feu ne s'arrête plus tout seul), le trafic IPv6 est aussi filtré, un tunnel perdu est remonté automatiquement, et la notification signale « PARE-FEU INACTIF » si les données mobiles ne sont pas filtrées.
+- Forfait par défaut : 1 Go (1000 Mo).
+- Contrôle parental : appairage avec **FamilyControl** (Firebase), envoi de la batterie, de la position, de l'état du pare-feu et de l'historique de trafic ; les accès réseau par application se pilotent à distance.
+- Protection anti-désinstallation (administrateur d'appareil) ; sa désactivation depuis l'app demande le mot de passe.
+
## v0.1.2 – 2026-09-20
- Mise à jour depuis les Releases Gitea : bouton « Rechercher une mise à jour » dans l'onglet À propos (téléchargement + installation de l'APK).
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 8cf504f..d9364d5 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -9,6 +9,13 @@ plugins {
alias(libs.plugins.kotlin.kapt)
}
+// Configuration Firebase (liaison distante avec FamilyControl) : fichier local non versionné.
+// Sans ce fichier l'app se compile normalement, mais l'appairage/le contrôle à distance sont désactivés.
+val firebaseProps = Properties().apply {
+ val f = rootProject.file("firebase.properties")
+ if (f.exists()) f.inputStream().use { load(it) }
+}
+
val versionProps = Properties().apply {
load(rootProject.file("version.properties").inputStream())
}
@@ -29,6 +36,9 @@ android {
useSupportLibrary = true
}
+ buildConfigField("String", "FIREBASE_API_KEY", "\"${firebaseProps.getProperty("apiKey", "")}\"")
+ buildConfigField("String", "FIREBASE_APP_ID", "\"${firebaseProps.getProperty("appId", "")}\"")
+ buildConfigField("String", "FIREBASE_PROJECT_ID", "\"${firebaseProps.getProperty("projectId", "")}\"")
buildConfigField("String", "BUILD_DATE", "\"${SimpleDateFormat("yyyy-MM-dd").format(Date())}\"")
}
@@ -87,6 +97,10 @@ dependencies {
implementation(libs.androidx.datastore.preferences)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.work.runtime.ktx)
+ implementation(platform(libs.firebase.bom))
+ implementation(libs.firebase.auth)
+ implementation(libs.firebase.firestore)
+ implementation(libs.kotlinx.coroutines.play.services)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0c5c857..01afdd5 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -10,6 +10,10 @@
+
+
+
+
@@ -61,6 +65,19 @@
android:resource="@xml/file_paths" />
+
+
+
+
+
+
+
+
= 80%), liste blanche active, pas de coupure en cours
diff --git a/app/src/main/java/com/jttools/datacontrol/data/InstalledAppsRepository.kt b/app/src/main/java/com/jttools/datacontrol/data/InstalledAppsRepository.kt
index f5a37d6..39b555e 100644
--- a/app/src/main/java/com/jttools/datacontrol/data/InstalledAppsRepository.kt
+++ b/app/src/main/java/com/jttools/datacontrol/data/InstalledAppsRepository.kt
@@ -14,7 +14,7 @@ class InstalledAppsRepository @Inject constructor(
@ApplicationContext private val context: Context
) {
@Suppress("DEPRECATION")
- suspend fun listInstalledApps(): List = withContext(Dispatchers.Default) {
+ suspend fun listInstalledApps(withIcons: Boolean = true): List = withContext(Dispatchers.Default) {
val pm = context.packageManager
val ownPackage = context.packageName
pm.getInstalledApplications(PackageManager.GET_META_DATA)
@@ -24,7 +24,7 @@ class InstalledAppsRepository @Inject constructor(
AppInfo(
packageName = appInfo.packageName,
label = pm.getApplicationLabel(appInfo).toString(),
- icon = runCatching { pm.getApplicationIcon(appInfo) }.getOrNull(),
+ icon = if (withIcons) runCatching { pm.getApplicationIcon(appInfo) }.getOrNull() else null,
isSystemApp = (appInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0
)
}
diff --git a/app/src/main/java/com/jttools/datacontrol/data/SettingsRepository.kt b/app/src/main/java/com/jttools/datacontrol/data/SettingsRepository.kt
index 6b9c79f..8d33d6b 100644
--- a/app/src/main/java/com/jttools/datacontrol/data/SettingsRepository.kt
+++ b/app/src/main/java/com/jttools/datacontrol/data/SettingsRepository.kt
@@ -42,6 +42,14 @@ class SettingsRepository @Inject constructor(
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 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 settingsFlow: Flow = context.dataStore.data.map { prefs ->
@@ -63,7 +71,10 @@ class SettingsRepository @Inject constructor(
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
+ 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
)
}
@@ -187,4 +198,71 @@ class SettingsRepository @Inject constructor(
prefs[Keys.CUTOFF_PULSE_START_MILLIS] = pulseStartMillis
}
}
+
+ suspend fun setPairing(familyId: String?, deviceId: 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)
+ } else {
+ it[Keys.FAMILY_ID] = familyId
+ it[Keys.DEVICE_ID] = deviceId
+ it[Keys.REMOTE_CONFIG_VERSION] = 0L
+ }
+ }
+ }
+
+ /**
+ * 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?,
+ whitelistedPackagesWifi: Set?,
+ wifiFilterEnabled: Boolean?,
+ planLimitMo: Long?,
+ cutoffIntervalMinutes: Int?
+ ) {
+ context.dataStore.edit { prefs ->
+ if ((prefs[Keys.REMOTE_CONFIG_VERSION] ?: 0L) >= version) return@edit
+ 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
+ }
}
diff --git a/app/src/main/java/com/jttools/datacontrol/remote/DeviceReporter.kt b/app/src/main/java/com/jttools/datacontrol/remote/DeviceReporter.kt
new file mode 100644
index 0000000..6a3c1b8
--- /dev/null
+++ b/app/src/main/java/com/jttools/datacontrol/remote/DeviceReporter.kt
@@ -0,0 +1,175 @@
+package com.jttools.datacontrol.remote
+
+import android.annotation.SuppressLint
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.PackageManager
+import android.location.Location
+import android.location.LocationListener
+import android.location.LocationManager
+import android.net.TrafficStats
+import android.os.BatteryManager
+import android.os.Looper
+import androidx.core.content.ContextCompat
+import com.jttools.datacontrol.BuildConfig
+import com.jttools.datacontrol.admin.AdminController
+import com.jttools.datacontrol.data.SettingsRepository
+import com.jttools.datacontrol.service.FirewallVpnService
+import com.jttools.datacontrol.service.MonitorStateHolder
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import java.time.LocalDate
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Envoie à FamilyControl (via [RemoteSync]) l'état de l'appareil : batterie, position,
+ * état du pare-feu, historique de trafic. Ne fait rien tant que l'appareil n'est pas associé.
+ */
+@Singleton
+class DeviceReporter @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val remote: RemoteSync,
+ private val settingsRepository: SettingsRepository,
+ private val stateHolder: MonitorStateHolder
+) {
+ private var started = false
+ private var lastBatterySent = -1 to false
+ private var lastBatterySentAt = 0L
+ private var lastLocationSentAt = 0L
+ private var lastStateSignature = ""
+ private var lastStateSentAt = 0L
+ private var locationRegistered = false
+
+ fun start(scope: CoroutineScope) {
+ if (started || !remote.available) return
+ started = true
+ remote.start(scope)
+
+ val batteryReceiver = object : BroadcastReceiver() {
+ override fun onReceive(c: Context?, intent: Intent?) {
+ if (intent != null) scope.launch { pushBattery(intent) }
+ }
+ }
+ // Récepteur enregistré dynamiquement : ACTION_BATTERY_CHANGED n'est pas livrable via le manifeste.
+ ContextCompat.registerReceiver(
+ context, batteryReceiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED), ContextCompat.RECEIVER_NOT_EXPORTED
+ )
+
+ scope.launch {
+ while (isActive) {
+ val s = settingsRepository.settingsFlow.first()
+ if (s.isPaired) {
+ registerLocationUpdates(scope)
+ pushState()
+ pushTraffic()
+ }
+ delay(REPORT_INTERVAL_MS)
+ }
+ }
+ }
+
+ private suspend fun pushBattery(intent: Intent) {
+ if (!settingsRepository.settingsFlow.first().isPaired) return
+ val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
+ val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, 100)
+ if (level < 0 || scale <= 0) return
+ val percent = level * 100 / scale
+ val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
+ val charging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL
+ val now = System.currentTimeMillis()
+ val changed = (percent to charging) != lastBatterySent
+ if (!changed || now - lastBatterySentAt < BATTERY_MIN_INTERVAL_MS && percent > LOW_BATTERY) return
+ lastBatterySent = percent to charging
+ lastBatterySentAt = now
+ remote.pushDevice(mapOf("battery" to mapOf("level" to percent, "charging" to charging, "at" to now)))
+ }
+
+ private suspend fun pushState() {
+ val s = settingsRepository.settingsFlow.first()
+ val snap = stateHolder.snapshot.value
+ val state = mapOf(
+ "controlEnabled" to s.controlEnabled,
+ "transport" to snap.transport.name,
+ "firewallFault" to snap.firewallFault,
+ "vpnPrepared" to FirewallVpnService.isPrepared(context),
+ "tunnelActive" to FirewallVpnService.tunnelActive,
+ "adminActive" to AdminController.isActive(context),
+ "consumedMo" to snap.consumedMo,
+ "planLimitMo" to s.planLimitMo,
+ "cutoffIntervalMinutes" to s.cutoffIntervalMinutes,
+ "wifiFilterEnabled" to s.wifiFilterEnabled,
+ "whitelistedPackages" to s.whitelistedPackages.sorted(),
+ "whitelistedPackagesWifi" to s.whitelistedPackagesWifi.sorted(),
+ "appliedConfigAt" to s.remoteConfigVersion,
+ "appVersion" to BuildConfig.VERSION_NAME
+ )
+ val signature = state.toString()
+ val now = System.currentTimeMillis()
+ // Évite les écritures inutiles ; un battement toutes les 5 min indique que l'appareil est vivant.
+ if (signature == lastStateSignature && now - lastStateSentAt < HEARTBEAT_MS) return
+ lastStateSignature = signature
+ lastStateSentAt = now
+ remote.pushDevice(mapOf("state" to (state + ("at" to now))))
+ }
+
+ private suspend fun pushTraffic() {
+ val mobile = TrafficStats.getMobileRxBytes() + TrafficStats.getMobileTxBytes()
+ val total = TrafficStats.getTotalRxBytes() + TrafficStats.getTotalTxBytes()
+ if (mobile < 0 || total < 0) return
+ val day = settingsRepository.accumulateTraffic(LocalDate.now().toString(), mobile, (total - mobile).coerceAtLeast(0L))
+ remote.pushTraffic(day)
+ }
+
+ @SuppressLint("MissingPermission")
+ private fun registerLocationUpdates(scope: CoroutineScope) {
+ if (locationRegistered) return
+ val fine = ContextCompat.checkSelfPermission(context, android.Manifest.permission.ACCESS_FINE_LOCATION) ==
+ PackageManager.PERMISSION_GRANTED
+ val coarse = ContextCompat.checkSelfPermission(context, android.Manifest.permission.ACCESS_COARSE_LOCATION) ==
+ PackageManager.PERMISSION_GRANTED
+ if (!fine && !coarse) return
+ val lm = context.getSystemService(LocationManager::class.java)
+ val listener = LocationListener { loc -> scope.launch { pushLocation(loc) } }
+ var registered = false
+ for (provider in listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)) {
+ if (provider == LocationManager.GPS_PROVIDER && !fine) continue
+ if (!lm.isProviderEnabled(provider)) continue
+ runCatching {
+ lm.requestLocationUpdates(provider, LOCATION_INTERVAL_MS, LOCATION_MIN_DISTANCE_M, listener, Looper.getMainLooper())
+ registered = true
+ }
+ }
+ locationRegistered = registered
+ }
+
+ private suspend fun pushLocation(loc: Location) {
+ val now = System.currentTimeMillis()
+ if (now - lastLocationSentAt < LOCATION_MIN_SEND_MS) return
+ lastLocationSentAt = now
+ remote.pushDevice(
+ mapOf(
+ "location" to mapOf(
+ "lat" to loc.latitude, "lng" to loc.longitude,
+ "accuracy" to loc.accuracy.toDouble(), "at" to now
+ )
+ )
+ )
+ }
+
+ private companion object {
+ const val REPORT_INTERVAL_MS = 60_000L
+ const val HEARTBEAT_MS = 5 * 60_000L
+ const val BATTERY_MIN_INTERVAL_MS = 2 * 60_000L
+ const val LOW_BATTERY = 20
+ const val LOCATION_INTERVAL_MS = 5 * 60_000L
+ const val LOCATION_MIN_DISTANCE_M = 50f
+ const val LOCATION_MIN_SEND_MS = 60_000L
+ }
+}
diff --git a/app/src/main/java/com/jttools/datacontrol/remote/FirebaseGateway.kt b/app/src/main/java/com/jttools/datacontrol/remote/FirebaseGateway.kt
new file mode 100644
index 0000000..4e4c590
--- /dev/null
+++ b/app/src/main/java/com/jttools/datacontrol/remote/FirebaseGateway.kt
@@ -0,0 +1,51 @@
+package com.jttools.datacontrol.remote
+
+import android.content.Context
+import com.google.firebase.FirebaseApp
+import com.google.firebase.FirebaseOptions
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.firestore.FirebaseFirestore
+import com.jttools.datacontrol.BuildConfig
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.tasks.await
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Accès à Firebase (Auth anonyme + Firestore) initialisé depuis `firebase.properties`
+ * (voir build.gradle.kts), sans plugin google-services : si la config est absente,
+ * [enabled] est faux et toutes les fonctions distantes restent inertes.
+ */
+@Singleton
+class FirebaseGateway @Inject constructor(
+ @ApplicationContext private val context: Context
+) {
+ val enabled: Boolean = BuildConfig.FIREBASE_API_KEY.isNotBlank() &&
+ BuildConfig.FIREBASE_APP_ID.isNotBlank() &&
+ BuildConfig.FIREBASE_PROJECT_ID.isNotBlank()
+
+ private val app: FirebaseApp? by lazy {
+ if (!enabled) return@lazy null
+ runCatching {
+ FirebaseApp.getApps(context).firstOrNull()
+ ?: FirebaseApp.initializeApp(
+ context,
+ FirebaseOptions.Builder()
+ .setApiKey(BuildConfig.FIREBASE_API_KEY)
+ .setApplicationId(BuildConfig.FIREBASE_APP_ID)
+ .setProjectId(BuildConfig.FIREBASE_PROJECT_ID)
+ .build()
+ )
+ }.getOrNull()
+ }
+
+ val db: FirebaseFirestore? get() = app?.let { FirebaseFirestore.getInstance(it) }
+ private val auth: FirebaseAuth? get() = app?.let { FirebaseAuth.getInstance(it) }
+
+ /** Identifiant de l'appareil côté Firebase (connexion anonyme, persistante). Null si indisponible. */
+ suspend fun ensureSignedIn(): String? {
+ val a = auth ?: return null
+ a.currentUser?.let { return it.uid }
+ return runCatching { a.signInAnonymously().await().user?.uid }.getOrNull()
+ }
+}
diff --git a/app/src/main/java/com/jttools/datacontrol/remote/RemoteSync.kt b/app/src/main/java/com/jttools/datacontrol/remote/RemoteSync.kt
new file mode 100644
index 0000000..ed7db4e
--- /dev/null
+++ b/app/src/main/java/com/jttools/datacontrol/remote/RemoteSync.kt
@@ -0,0 +1,177 @@
+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 kotlinx.coroutines.CoroutineScope
+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 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
+
+ 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()
+ 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 aux réglages locaux. */
+ @OptIn(ExperimentalCoroutinesApi::class)
+ fun start(scope: CoroutineScope) {
+ if (!gateway.enabled) return
+ scope.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) }
+ }
+ scope.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()
+ }
+ }
+ }
+
+ 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()
+ )
+ }
+
+ private 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 pushEvent(type: String, detail: String) {
+ val s = settingsRepository.settingsFlow.first()
+ val ref = deviceDoc(s) ?: return
+ runCatching {
+ ref.collection("events").add(
+ mapOf("type" to type, "detail" to detail, "at" to System.currentTimeMillis())
+ )
+ }
+ }
+
+ 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" }
+}
diff --git a/app/src/main/java/com/jttools/datacontrol/service/ConnectivityObserver.kt b/app/src/main/java/com/jttools/datacontrol/service/ConnectivityObserver.kt
index e0d6399..d712da8 100644
--- a/app/src/main/java/com/jttools/datacontrol/service/ConnectivityObserver.kt
+++ b/app/src/main/java/com/jttools/datacontrol/service/ConnectivityObserver.kt
@@ -34,13 +34,34 @@ class ConnectivityObserver @Inject constructor(
else -> NetworkTransport.OTHER
}
+ // Réseaux PHYSIQUES connectés. Le tunnel VPN du pare-feu est lui-même un réseau : s'il était
+ // pris en compte, son apparition (transport VPN ou hérité) ferait croire à un changement de
+ // réseau, le pare-feu s'arrêterait aussitôt et le VPN ne resterait jamais monté.
+ val physical = mutableMapOf()
+
+ fun publish() {
+ val values = synchronized(physical) { physical.values.toList() }
+ // Android privilégie le Wi-Fi quand les deux sont connectés.
+ trySend(
+ when {
+ NetworkTransport.WIFI in values -> NetworkTransport.WIFI
+ NetworkTransport.CELLULAR in values -> NetworkTransport.CELLULAR
+ values.isNotEmpty() -> NetworkTransport.OTHER
+ else -> NetworkTransport.NONE
+ }
+ )
+ }
+
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
- trySend(transportOf(caps))
+ if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return
+ synchronized(physical) { physical[network] = transportOf(caps) }
+ publish()
}
override fun onLost(network: Network) {
- trySend(NetworkTransport.NONE)
+ val removed = synchronized(physical) { physical.remove(network) != null }
+ if (removed) publish()
}
}
@@ -48,7 +69,6 @@ class ConnectivityObserver @Inject constructor(
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
- trySend(transportOf(cm.getNetworkCapabilities(cm.activeNetwork)))
cm.registerNetworkCallback(request, callback)
awaitClose { cm.unregisterNetworkCallback(callback) }
diff --git a/app/src/main/java/com/jttools/datacontrol/service/DataMonitorService.kt b/app/src/main/java/com/jttools/datacontrol/service/DataMonitorService.kt
index b404b37..650ef06 100644
--- a/app/src/main/java/com/jttools/datacontrol/service/DataMonitorService.kt
+++ b/app/src/main/java/com/jttools/datacontrol/service/DataMonitorService.kt
@@ -20,6 +20,8 @@ import com.jttools.datacontrol.data.AppSettings
import com.jttools.datacontrol.data.DataState
import com.jttools.datacontrol.data.SettingsRepository
import com.jttools.datacontrol.data.UsageRepository
+import com.jttools.datacontrol.remote.DeviceReporter
+import com.jttools.datacontrol.remote.RemoteSync
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -45,6 +47,8 @@ class DataMonitorService : LifecycleService() {
@Inject lateinit var usageRepository: UsageRepository
@Inject lateinit var connectivityObserver: ConnectivityObserver
@Inject lateinit var stateHolder: MonitorStateHolder
+ @Inject lateinit var deviceReporter: DeviceReporter
+ @Inject lateinit var remoteSync: RemoteSync
private var latestSettings: AppSettings = AppSettings()
private var latestTransport: NetworkTransport = NetworkTransport.NONE
@@ -52,16 +56,21 @@ class DataMonitorService : LifecycleService() {
private var lastAppliedMode: VpnMode? = null
private var lastAppliedWhitelist: Set = emptySet()
+ private var lastApplyMillis = 0L
private var cutoffCycleAnchorMillis: Long? = null
private var inPulse = false
private var pulseStartMillis: Long = 0L
private var settingsLoaded = false
+ private var lastControlEnabled: Boolean? = null
private val vpnRevokedReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
vpnRevoked = true
- lifecycleScope.launch { settingsRepository.setControlEnabled(false) }
+ lifecycleScope.launch {
+ settingsRepository.setControlEnabled(false)
+ remoteSync.pushEvent("vpn_revoked", "Autorisation VPN retirée : le filtrage est désactivé")
+ }
}
}
@@ -94,10 +103,20 @@ class DataMonitorService : LifecycleService() {
settingsLoaded = true
}
latestSettings = it
+ if (lastControlEnabled != null && lastControlEnabled != it.controlEnabled && it.isPaired) {
+ launch {
+ remoteSync.pushEvent(
+ "control_changed",
+ if (it.controlEnabled) "Contrôle activé" else "Contrôle désactivé"
+ )
+ }
+ }
+ lastControlEnabled = it.controlEnabled
if (!it.controlEnabled) vpnRevoked = false
tick()
}
}
+ deviceReporter.start(lifecycleScope)
lifecycleScope.launch {
connectivityObserver.observe().collect {
latestTransport = it
@@ -137,7 +156,9 @@ class DataMonitorService : LifecycleService() {
estimatedTotalPriceEuro = estimatedTotal,
dataState = if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK,
nextAutoResetDate = settings.nextAutoResetDate(),
- serviceRunning = false
+ serviceRunning = false,
+ firewallFault = false,
+ transport = latestTransport
)
}
updateNotification(stateHolder.snapshot.value)
@@ -148,6 +169,7 @@ class DataMonitorService : LifecycleService() {
val dataState: DataState
var nextCutoffAt: Long? = null
+ var firewallFault = false
when {
latestTransport == NetworkTransport.WIFI -> {
@@ -188,12 +210,16 @@ class DataMonitorService : LifecycleService() {
applyVpnMode(VpnMode.FILTER, settings.whitelistedPackages)
}
nextCutoffAt = if (inPulse) pulseStartMillis + PULSE_DURATION_MS else anchor + intervalMs
+ firewallFault = !FirewallVpnService.tunnelActive &&
+ System.currentTimeMillis() - lastApplyMillis > TUNNEL_RETRY_DELAY_MS
dataState = if (inPulse) DataState.CUT else if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK
}
else -> {
// Pas de réseau, transport inconnu, ou VPN non préparé/révoqué : rien à filtrer.
applyVpnMode(VpnMode.STOPPED, emptySet())
resetCutoffCycle()
+ // Sur données mobiles sans autorisation VPN valide, rien n'est filtré : à signaler.
+ firewallFault = latestTransport == NetworkTransport.CELLULAR
dataState = if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK
}
}
@@ -207,7 +233,9 @@ class DataMonitorService : LifecycleService() {
dataState = dataState,
nextAutoResetDate = settings.nextAutoResetDate(),
nextCutoffAtMillis = nextCutoffAt,
- serviceRunning = true
+ serviceRunning = true,
+ firewallFault = firewallFault,
+ transport = latestTransport
)
}
updateNotification(stateHolder.snapshot.value)
@@ -236,9 +264,14 @@ class DataMonitorService : LifecycleService() {
}
private fun applyVpnMode(mode: VpnMode, whitelist: Set) {
- if (mode == lastAppliedMode && (mode != VpnMode.FILTER || whitelist == lastAppliedWhitelist)) return
+ // Si le tunnel a disparu (service VPN tué/relancé, établissement échoué), on le remonte au
+ // tick suivant au lieu de croire qu'il est toujours actif.
+ val tunnelLost = mode != VpnMode.STOPPED && !FirewallVpnService.tunnelActive &&
+ System.currentTimeMillis() - lastApplyMillis > TUNNEL_RETRY_DELAY_MS
+ if (!tunnelLost && mode == lastAppliedMode && (mode != VpnMode.FILTER || whitelist == lastAppliedWhitelist)) return
lastAppliedMode = mode
lastAppliedWhitelist = whitelist
+ lastApplyMillis = System.currentTimeMillis()
when (mode) {
VpnMode.STOPPED -> ContextCompat.startForegroundService(this, FirewallVpnService.stopIntent(this))
VpnMode.FILTER -> ContextCompat.startForegroundService(
@@ -262,6 +295,7 @@ class DataMonitorService : LifecycleService() {
}
val (emoji, stateLabel, color) = when {
+ snapshot.firewallFault -> Triple("⚠️", getString(R.string.dashboard_state_warning), Color.parseColor("#C62828"))
!snapshot.serviceRunning -> Triple("⚪", getString(R.string.dashboard_control_disabled), Color.parseColor("#757575"))
snapshot.dataState == DataState.OK -> Triple("🟢", getString(R.string.dashboard_state_ok), Color.parseColor("#2E7D32"))
snapshot.dataState == DataState.WARNING -> Triple("🟠", getString(R.string.dashboard_state_warning), Color.parseColor("#EF6C00"))
@@ -274,7 +308,8 @@ class DataMonitorService : LifecycleService() {
} else "—"
val priceText = String.format("%.2f", snapshot.estimatedTotalPriceEuro)
- val usageText = "$emoji $stateLabel · ${snapshot.consumedMo}/${snapshot.planLimitMo} Mo ($percentText)"
+ val warn = if (snapshot.firewallFault) "⚠️ PARE-FEU INACTIF — données non filtrées · " else ""
+ val usageText = "$warn$emoji $stateLabel · ${snapshot.consumedMo}/${snapshot.planLimitMo} Mo ($percentText)"
val costText = "${getString(R.string.dashboard_cost_total)} : $priceText €"
// Le total est aussi dans la vue repliée : sinon il n'est visible qu'une fois la notification dépliée.
val contentText = "$usageText · $priceText €"
@@ -313,6 +348,7 @@ class DataMonitorService : LifecycleService() {
private const val NOTIF_ID = 1001
private const val TICK_INTERVAL_MS = 20_000L
private const val PULSE_DURATION_MS = 30_000L
+ private const val TUNNEL_RETRY_DELAY_MS = 5_000L
private const val WARNING_THRESHOLD = 0.8f
fun start(context: Context) {
diff --git a/app/src/main/java/com/jttools/datacontrol/service/FirewallVpnService.kt b/app/src/main/java/com/jttools/datacontrol/service/FirewallVpnService.kt
index f820edf..147cf01 100644
--- a/app/src/main/java/com/jttools/datacontrol/service/FirewallVpnService.kt
+++ b/app/src/main/java/com/jttools/datacontrol/service/FirewallVpnService.kt
@@ -59,6 +59,9 @@ class FirewallVpnService : VpnService() {
.setSession("DataControl")
.addAddress(TUNNEL_ADDRESS, 32)
.addRoute("0.0.0.0", 0)
+ // Sans route IPv6, le trafic IPv6 des applications bloquées contournerait le pare-feu.
+ .addAddress(TUNNEL_ADDRESS_V6, 128)
+ .addRoute("::", 0)
.setBlocking(true)
// Notre propre app doit toujours pouvoir communiquer normalement.
@@ -72,6 +75,7 @@ class FirewallVpnService : VpnService() {
val fd = tunInterface ?: return
running = true
+ tunnelActive = true
readerThread = Thread({ blackhole(fd) }, "datacontrol-vpn-blackhole").apply {
isDaemon = true
start()
@@ -102,6 +106,7 @@ class FirewallVpnService : VpnService() {
private fun stopTunnel(keepForeground: Boolean = false) {
running = false
+ tunnelActive = false
readerThread?.interrupt()
readerThread = null
tunInterface?.let { runCatching { it.close() } }
@@ -150,6 +155,11 @@ class FirewallVpnService : VpnService() {
private const val CHANNEL_ID = "datacontrol_vpn"
private const val NOTIF_ID = 2001
private const val TUNNEL_ADDRESS = "10.10.10.1"
+ private const val TUNNEL_ADDRESS_V6 = "fd00:10:10::1"
+
+ /** Vrai tant que le tunnel est réellement établi (faux si le service a été tué/relancé sans intent). */
+ @Volatile var tunnelActive = false
+ private set
const val ACTION_START_FILTER = "com.jttools.datacontrol.action.VPN_START_FILTER"
const val ACTION_START_BLOCK_ALL = "com.jttools.datacontrol.action.VPN_START_BLOCK_ALL"
diff --git a/app/src/main/java/com/jttools/datacontrol/service/MonitorStateHolder.kt b/app/src/main/java/com/jttools/datacontrol/service/MonitorStateHolder.kt
index 58680cc..f03ca8c 100644
--- a/app/src/main/java/com/jttools/datacontrol/service/MonitorStateHolder.kt
+++ b/app/src/main/java/com/jttools/datacontrol/service/MonitorStateHolder.kt
@@ -15,7 +15,10 @@ data class MonitorSnapshot(
val dataState: DataState = DataState.OK,
val nextAutoResetDate: LocalDate = LocalDate.now(),
val nextCutoffAtMillis: Long? = null,
- val serviceRunning: Boolean = false
+ val serviceRunning: Boolean = false,
+ /** Contrôle activé sur données mobiles mais tunnel VPN non monté : les données ne sont PAS filtrées. */
+ val firewallFault: Boolean = false,
+ val transport: NetworkTransport = NetworkTransport.NONE
)
/**
diff --git a/app/src/main/java/com/jttools/datacontrol/ui/SystemActions.kt b/app/src/main/java/com/jttools/datacontrol/ui/SystemActions.kt
index b201f47..95ef19f 100644
--- a/app/src/main/java/com/jttools/datacontrol/ui/SystemActions.kt
+++ b/app/src/main/java/com/jttools/datacontrol/ui/SystemActions.kt
@@ -34,6 +34,54 @@ class SystemActions(private val activity: ComponentActivity) {
ActivityResultContracts.RequestPermission()
) { granted -> onNotificationResult?.invoke(granted) }
+ private var onLocationResult: ((Boolean) -> Unit)? = null
+ private var onBackgroundLocationResult: ((Boolean) -> Unit)? = null
+ private var onAdminResult: ((Boolean) -> Unit)? = null
+
+ private val locationLauncher: ActivityResultLauncher> = activity.registerForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { granted -> onLocationResult?.invoke(granted.values.any { it }) }
+
+ private val backgroundLocationLauncher: ActivityResultLauncher = activity.registerForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { granted -> onBackgroundLocationResult?.invoke(granted) }
+
+ private val adminLauncher: ActivityResultLauncher = activity.registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result -> onAdminResult?.invoke(result.resultCode == android.app.Activity.RESULT_OK) }
+
+ fun hasLocationPermission(): Boolean = ContextCompat.checkSelfPermission(
+ activity, android.Manifest.permission.ACCESS_FINE_LOCATION
+ ) == android.content.pm.PackageManager.PERMISSION_GRANTED
+
+ fun hasBackgroundLocationPermission(): Boolean =
+ Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || ContextCompat.checkSelfPermission(
+ activity, android.Manifest.permission.ACCESS_BACKGROUND_LOCATION
+ ) == android.content.pm.PackageManager.PERMISSION_GRANTED
+
+ fun requestLocationPermission(onResult: (Boolean) -> Unit) {
+ onLocationResult = onResult
+ locationLauncher.launch(
+ arrayOf(android.Manifest.permission.ACCESS_FINE_LOCATION, android.Manifest.permission.ACCESS_COARSE_LOCATION)
+ )
+ }
+
+ /** À demander APRÈS la position au premier plan (exigence Android 11+). */
+ fun requestBackgroundLocationPermission(onResult: (Boolean) -> Unit) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { onResult(true); return }
+ onBackgroundLocationResult = onResult
+ backgroundLocationLauncher.launch(android.Manifest.permission.ACCESS_BACKGROUND_LOCATION)
+ }
+
+ fun isAdminActive(): Boolean = com.jttools.datacontrol.admin.AdminController.isActive(activity)
+
+ fun requestAdmin(onResult: (Boolean) -> Unit) {
+ onAdminResult = onResult
+ adminLauncher.launch(com.jttools.datacontrol.admin.AdminController.requestIntent(activity))
+ }
+
+ fun deactivateAdmin() = com.jttools.datacontrol.admin.AdminController.deactivate(activity)
+
fun isVpnPrepared(): Boolean = FirewallVpnService.isPrepared(activity)
fun requestVpnPermission(onResult: (Boolean) -> Unit) {
diff --git a/app/src/main/java/com/jttools/datacontrol/ui/family/FamilySection.kt b/app/src/main/java/com/jttools/datacontrol/ui/family/FamilySection.kt
new file mode 100644
index 0000000..b7444da
--- /dev/null
+++ b/app/src/main/java/com/jttools/datacontrol/ui/family/FamilySection.kt
@@ -0,0 +1,194 @@
+package com.jttools.datacontrol.ui.family
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.jttools.datacontrol.R
+import com.jttools.datacontrol.ui.SystemActions
+import com.jttools.datacontrol.ui.theme.StateOk
+import kotlinx.coroutines.launch
+
+/** Carte « Contrôle parental » des réglages : appairage FamilyControl, anti-désinstallation, position. */
+@Composable
+fun FamilySection(systemActions: SystemActions) {
+ val vm: FamilyViewModel = hiltViewModel()
+ val settings by vm.settings.collectAsStateWithLifecycle()
+ val pairing by vm.pairing.collectAsStateWithLifecycle()
+ val scope = rememberCoroutineScope()
+
+ var adminActive by remember { mutableStateOf(systemActions.isAdminActive()) }
+ var locationGranted by remember { mutableStateOf(systemActions.hasLocationPermission()) }
+ var backgroundGranted by remember { mutableStateOf(systemActions.hasBackgroundLocationPermission()) }
+ var code by remember { mutableStateOf("") }
+ var deviceName by remember { mutableStateOf(android.os.Build.MODEL) }
+
+ // Action sensible en attente de confirmation par mot de passe : "admin" ou "unpair".
+ var pendingAction by remember { mutableStateOf(null) }
+ var pwd by remember { mutableStateOf("") }
+ var pwdError by remember { mutableStateOf(false) }
+
+ LaunchedEffect(Unit) {
+ adminActive = systemActions.isAdminActive()
+ locationGranted = systemActions.hasLocationPermission()
+ backgroundGranted = systemActions.hasBackgroundLocationPermission()
+ }
+
+ fun runProtected(action: String, block: () -> Unit) {
+ if (settings.hasPassword) { pendingAction = action; pwd = ""; pwdError = false } else block()
+ }
+
+ fun execute(action: String) {
+ when (action) {
+ "admin" -> { systemActions.deactivateAdmin(); adminActive = systemActions.isAdminActive() }
+ "unpair" -> vm.unpair()
+ }
+ }
+
+ Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
+ Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Text(
+ stringResource(R.string.family_title),
+ style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold
+ )
+ HorizontalDivider()
+
+ // --- Appairage ---
+ when {
+ !vm.remoteAvailable -> Text(
+ stringResource(R.string.family_unavailable),
+ style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ settings.isPaired -> {
+ Text(stringResource(R.string.family_paired), color = StateOk, style = MaterialTheme.typography.bodyMedium)
+ OutlinedButton(onClick = { runProtected("unpair") { execute("unpair") } }) {
+ Text(stringResource(R.string.family_unpair))
+ }
+ }
+ else -> {
+ Text(stringResource(R.string.family_pair_help), style = MaterialTheme.typography.bodyMedium)
+ OutlinedTextField(
+ value = code, onValueChange = { code = it.uppercase().take(8) },
+ label = { Text(stringResource(R.string.family_code)) },
+ singleLine = true, modifier = Modifier.fillMaxWidth()
+ )
+ OutlinedTextField(
+ value = deviceName, onValueChange = { deviceName = it },
+ label = { Text(stringResource(R.string.family_device_name)) },
+ singleLine = true, modifier = Modifier.fillMaxWidth()
+ )
+ pairing.error?.let {
+ Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
+ }
+ Button(
+ enabled = code.length >= 6 && !pairing.busy,
+ onClick = { vm.pair(code, deviceName) }
+ ) { Text(stringResource(R.string.family_pair)) }
+ }
+ }
+
+ HorizontalDivider()
+
+ // --- Anti-désinstallation ---
+ Text(stringResource(R.string.family_admin_title), fontWeight = FontWeight.Medium)
+ Text(
+ stringResource(if (adminActive) R.string.family_admin_on else R.string.family_admin_off),
+ style = MaterialTheme.typography.bodyMedium,
+ color = if (adminActive) StateOk else MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ if (adminActive) {
+ OutlinedButton(onClick = { runProtected("admin") { execute("admin") } }) {
+ Text(stringResource(R.string.family_admin_disable))
+ }
+ } else {
+ Button(onClick = { systemActions.requestAdmin { adminActive = systemActions.isAdminActive() } }) {
+ Text(stringResource(R.string.family_admin_enable))
+ }
+ }
+ if (!settings.hasPassword) {
+ Text(
+ stringResource(R.string.family_admin_needs_password),
+ style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error
+ )
+ }
+
+ HorizontalDivider()
+
+ // --- Position ---
+ Text(stringResource(R.string.family_location_title), fontWeight = FontWeight.Medium)
+ Text(
+ stringResource(R.string.family_location_desc),
+ style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ when {
+ !locationGranted -> Button(onClick = {
+ systemActions.requestLocationPermission { locationGranted = it }
+ }) { Text(stringResource(R.string.family_location_grant)) }
+ !backgroundGranted -> Button(onClick = {
+ systemActions.requestBackgroundLocationPermission {
+ backgroundGranted = systemActions.hasBackgroundLocationPermission()
+ }
+ }) { Text(stringResource(R.string.family_location_background)) }
+ else -> Text(stringResource(R.string.settings_perm_granted), color = StateOk)
+ }
+ }
+ }
+
+ pendingAction?.let { action ->
+ AlertDialog(
+ onDismissRequest = { pendingAction = null },
+ title = { Text(stringResource(R.string.password_gate_title)) },
+ text = {
+ Column {
+ OutlinedTextField(
+ value = pwd, onValueChange = { pwd = it; pwdError = false },
+ label = { Text(stringResource(R.string.password_field_label)) },
+ visualTransformation = PasswordVisualTransformation(),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
+ isError = pwdError, singleLine = true, modifier = Modifier.fillMaxWidth()
+ )
+ if (pwdError) Text(
+ stringResource(R.string.password_incorrect),
+ color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall
+ )
+ }
+ },
+ confirmButton = {
+ Button(onClick = {
+ scope.launch {
+ if (vm.verifyPassword(pwd)) { pendingAction = null; execute(action) } else pwdError = true
+ }
+ }) { Text(stringResource(R.string.common_confirm)) }
+ },
+ dismissButton = {
+ OutlinedButton(onClick = { pendingAction = null }) { Text(stringResource(R.string.common_cancel)) }
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/com/jttools/datacontrol/ui/family/FamilyViewModel.kt b/app/src/main/java/com/jttools/datacontrol/ui/family/FamilyViewModel.kt
new file mode 100644
index 0000000..202998d
--- /dev/null
+++ b/app/src/main/java/com/jttools/datacontrol/ui/family/FamilyViewModel.kt
@@ -0,0 +1,47 @@
+package com.jttools.datacontrol.ui.family
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.jttools.datacontrol.data.AppSettings
+import com.jttools.datacontrol.data.SettingsRepository
+import com.jttools.datacontrol.remote.RemoteSync
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+data class PairingUiState(val busy: Boolean = false, val error: String? = null)
+
+@HiltViewModel
+class FamilyViewModel @Inject constructor(
+ private val settingsRepository: SettingsRepository,
+ private val remoteSync: RemoteSync
+) : ViewModel() {
+
+ val settings = settingsRepository.settingsFlow.stateIn(
+ viewModelScope, SharingStarted.WhileSubscribed(5_000), AppSettings()
+ )
+
+ val remoteAvailable: Boolean get() = remoteSync.available
+
+ private val _pairing = MutableStateFlow(PairingUiState())
+ val pairing: StateFlow = _pairing.asStateFlow()
+
+ fun pair(code: String, deviceName: String) {
+ _pairing.value = PairingUiState(busy = true)
+ viewModelScope.launch {
+ val result = remoteSync.pair(code, deviceName)
+ _pairing.value = PairingUiState(error = result.exceptionOrNull()?.message)
+ }
+ }
+
+ fun unpair() {
+ viewModelScope.launch { remoteSync.unpair() }
+ }
+
+ suspend fun verifyPassword(password: String): Boolean = settingsRepository.verifyPassword(password)
+}
diff --git a/app/src/main/java/com/jttools/datacontrol/ui/settings/SettingsScreen.kt b/app/src/main/java/com/jttools/datacontrol/ui/settings/SettingsScreen.kt
index 8d7bc10..d5e492f 100644
--- a/app/src/main/java/com/jttools/datacontrol/ui/settings/SettingsScreen.kt
+++ b/app/src/main/java/com/jttools/datacontrol/ui/settings/SettingsScreen.kt
@@ -222,6 +222,8 @@ private fun SettingsContent(
}
}
+ com.jttools.datacontrol.ui.family.FamilySection(systemActions)
+
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(stringResource(R.string.settings_section_security), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 63033e1..47e3f0f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -123,4 +123,23 @@
€
Annuler
Confirmer
+
+ Contrôle parental (FamilyControl)
+ Liaison FamilyControl non configurée dans cette version (fichier firebase.properties absent à la compilation).
+ Appareil associé à FamilyControl : le parent peut gérer les accès réseau, voir la batterie, la position et l\'historique de trafic.
+ Dissocier cet appareil
+ Saisissez le code d\'appairage affiché dans FamilyControl pour associer cet appareil.
+ Code d\'appairage
+ Nom de l\'appareil
+ Associer
+ Protection anti-désinstallation
+ Active : DataControl ne peut pas être désinstallé sans désactiver d\'abord cette protection (mot de passe demandé ici).
+ Inactive : l\'application peut être désinstallée librement.
+ Activer la protection
+ Désactiver la protection
+ Définissez un mot de passe (section Sécurité) : sans lui, la protection peut être retirée librement.
+ Position de l\'appareil
+ Partagée avec le parent via FamilyControl, y compris en arrière-plan (choisir « Toujours autoriser »).
+ Autoriser la position
+ Autoriser en arrière-plan
diff --git a/app/src/main/res/xml/device_admin.xml b/app/src/main/res/xml/device_admin.xml
new file mode 100644
index 0000000..eea7feb
--- /dev/null
+++ b/app/src/main/res/xml/device_admin.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/firebase.properties.example b/firebase.properties.example
new file mode 100644
index 0000000..652cde0
--- /dev/null
+++ b/firebase.properties.example
@@ -0,0 +1,5 @@
+# Copier vers firebase.properties (ignoré par git) et renseigner les valeurs de l'app Android
+# déclarée dans la console Firebase (Paramètres du projet > Vos applications).
+apiKey=
+appId=
+projectId=
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index cc998bd..be0baab 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -16,6 +16,8 @@ hiltNavigationCompose = "1.2.0"
datastore = "1.1.1"
coroutines = "1.8.1"
workManager = "2.9.1"
+firebaseBom = "33.1.2"
+coroutinesPlayServices = "1.8.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -42,6 +44,10 @@ androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navig
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" }
+firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
+firebase-auth = { group = "com.google.firebase", name = "firebase-auth" }
+firebase-firestore = { group = "com.google.firebase", name = "firebase-firestore" }
+kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutinesPlayServices" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
diff --git a/version.properties b/version.properties
index 624c632..679bf8f 100644
--- a/version.properties
+++ b/version.properties
@@ -1,2 +1,2 @@
-versionName=0.1.2
-versionCode=3
+versionName=0.1.3
+versionCode=4