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