release: v0.1.3 — correctif VPN, alerte pare-feu inactif, contrôle parental (FamilyControl), anti-désinstallation
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c63cc45c57
commit
82d47fcd72
25 files changed
+996
-16
No files matched your search
@@ -16,3 +16,6 @@ local.properties
|
|||||||
# Jeton Gitea (lecture/écriture) pour "make publish"/"make deploy" : local,
|
# Jeton Gitea (lecture/écriture) pour "make publish"/"make deploy" : local,
|
||||||
# jamais versionné.
|
# jamais versionné.
|
||||||
gitea-token.properties
|
gitea-token.properties
|
||||||
|
|
||||||
|
# Configuration Firebase locale (clés du projet).
|
||||||
|
firebase.properties
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# 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
|
## 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).
|
- Mise à jour depuis les Releases Gitea : bouton « Rechercher une mise à jour » dans l'onglet À propos (téléchargement + installation de l'APK).
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ plugins {
|
|||||||
alias(libs.plugins.kotlin.kapt)
|
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 {
|
val versionProps = Properties().apply {
|
||||||
load(rootProject.file("version.properties").inputStream())
|
load(rootProject.file("version.properties").inputStream())
|
||||||
}
|
}
|
||||||
@@ -29,6 +36,9 @@ android {
|
|||||||
useSupportLibrary = true
|
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())}\"")
|
buildConfigField("String", "BUILD_DATE", "\"${SimpleDateFormat("yyyy-MM-dd").format(Date())}\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +97,10 @@ dependencies {
|
|||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
implementation(libs.androidx.work.runtime.ktx)
|
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)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
<!-- Position de l'appareil, transmise au parent via FamilyControl (arrière-plan compris). -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||||
<!-- Nécessaire pour lister TOUTES les apps installées dans l'écran "Liste blanche" -->
|
<!-- Nécessaire pour lister TOUTES les apps installées dans l'écran "Liste blanche" -->
|
||||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
|
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||||
tools:ignore="QueryAllPackagesPermission" />
|
tools:ignore="QueryAllPackagesPermission" />
|
||||||
@@ -61,6 +65,19 @@
|
|||||||
android:resource="@xml/file_paths" />
|
android:resource="@xml/file_paths" />
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
|
<!-- Administrateur d'appareil : bloque la désinstallation tant qu'il est actif. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".admin.AppDeviceAdminReceiver"
|
||||||
|
android:exported="true"
|
||||||
|
android:permission="android.permission.BIND_DEVICE_ADMIN">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.app.device_admin"
|
||||||
|
android:resource="@xml/device_admin" />
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.app.action.DEVICE_ADMIN_ENABLED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".service.BootReceiver"
|
android:name=".service.BootReceiver"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.jttools.datacontrol.admin
|
||||||
|
|
||||||
|
import android.app.admin.DevicePolicyManager
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
|
||||||
|
/** Protection anti-désinstallation : tant que DataControl est administrateur de l'appareil, Android refuse de le désinstaller. */
|
||||||
|
object AdminController {
|
||||||
|
fun component(context: Context) = ComponentName(context, AppDeviceAdminReceiver::class.java)
|
||||||
|
|
||||||
|
fun isActive(context: Context): Boolean =
|
||||||
|
context.getSystemService(DevicePolicyManager::class.java).isAdminActive(component(context))
|
||||||
|
|
||||||
|
fun requestIntent(context: Context): Intent =
|
||||||
|
Intent(DevicePolicyManager.ACTION_ADD_DEVICE_ADMIN)
|
||||||
|
.putExtra(DevicePolicyManager.EXTRA_DEVICE_ADMIN, component(context))
|
||||||
|
.putExtra(
|
||||||
|
DevicePolicyManager.EXTRA_ADD_EXPLANATION,
|
||||||
|
"Empêche la désinstallation de DataControl sans mot de passe."
|
||||||
|
)
|
||||||
|
|
||||||
|
/** À n'appeler qu'après vérification du mot de passe. */
|
||||||
|
fun deactivate(context: Context) {
|
||||||
|
context.getSystemService(DevicePolicyManager::class.java).removeActiveAdmin(component(context))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.jttools.datacontrol.admin
|
||||||
|
|
||||||
|
import android.app.admin.DeviceAdminReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import com.jttools.datacontrol.remote.RemoteSync
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class AppDeviceAdminReceiver : DeviceAdminReceiver() {
|
||||||
|
|
||||||
|
@Inject lateinit var remoteSync: RemoteSync
|
||||||
|
|
||||||
|
override fun onDisableRequested(context: Context, intent: Intent): CharSequence =
|
||||||
|
"Désactiver la protection permet de désinstaller DataControl. Cette action est signalée à FamilyControl."
|
||||||
|
|
||||||
|
override fun onDisabled(context: Context, intent: Intent) {
|
||||||
|
// Prévient le parent : la protection anti-désinstallation vient d'être retirée.
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
remoteSync.pushEvent("admin_disabled", "Protection anti-désinstallation désactivée")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import java.time.LocalDate
|
|||||||
*/
|
*/
|
||||||
data class AppSettings(
|
data class AppSettings(
|
||||||
val controlEnabled: Boolean = false,
|
val controlEnabled: Boolean = false,
|
||||||
val planLimitMo: Long = 5000L,
|
val planLimitMo: Long = 1000L,
|
||||||
val planPriceEuro: Double = 19.99,
|
val planPriceEuro: Double = 19.99,
|
||||||
val overagePricePerMoEuro: Double = 0.01,
|
val overagePricePerMoEuro: Double = 0.01,
|
||||||
val planStartDateEpochDay: Long = LocalDate.now().toEpochDay(),
|
val planStartDateEpochDay: Long = LocalDate.now().toEpochDay(),
|
||||||
@@ -22,8 +22,15 @@ data class AppSettings(
|
|||||||
val passwordSalt: String? = null,
|
val passwordSalt: String? = null,
|
||||||
val cutoffCycleAnchorMillis: Long? = null,
|
val cutoffCycleAnchorMillis: Long? = null,
|
||||||
val cutoffInPulse: Boolean = false,
|
val cutoffInPulse: Boolean = false,
|
||||||
val cutoffPulseStartMillis: Long = 0L
|
val cutoffPulseStartMillis: Long = 0L,
|
||||||
|
/** Appairage avec FamilyControl (null tant que l'appareil n'est pas associé). */
|
||||||
|
val familyId: String? = null,
|
||||||
|
val deviceId: String? = null,
|
||||||
|
/** Horodatage (ms) de la dernière configuration distante appliquée. */
|
||||||
|
val remoteConfigVersion: Long = 0L
|
||||||
) {
|
) {
|
||||||
|
val isPaired: Boolean get() = !familyId.isNullOrEmpty() && !deviceId.isNullOrEmpty()
|
||||||
|
|
||||||
val planStartDate: LocalDate get() = LocalDate.ofEpochDay(planStartDateEpochDay)
|
val planStartDate: LocalDate get() = LocalDate.ofEpochDay(planStartDateEpochDay)
|
||||||
|
|
||||||
/** Un mot de passe de verrouillage protège la désactivation du contrôle et l'accès aux réglages. */
|
/** Un mot de passe de verrouillage protège la désactivation du contrôle et l'accès aux réglages. */
|
||||||
@@ -59,6 +66,9 @@ data class UsageState(
|
|||||||
get() = if (planLimitMo <= 0) 0f else (consumedMo.toFloat() / planLimitMo.toFloat()).coerceAtLeast(0f)
|
get() = if (planLimitMo <= 0) 0f else (consumedMo.toFloat() / planLimitMo.toFloat()).coerceAtLeast(0f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Trafic cumulé d'une journée, pour l'historique envoyé à FamilyControl. */
|
||||||
|
data class TrafficDay(val date: String, val mobileBytes: Long, val wifiBytes: Long)
|
||||||
|
|
||||||
enum class DataState {
|
enum class DataState {
|
||||||
OK, // sous le seuil d'alerte, liste blanche active, pas de coupure en cours
|
OK, // sous le seuil d'alerte, liste blanche active, pas de coupure en cours
|
||||||
WARNING, // proche ou au-delà du forfait (>= 80%), liste blanche active, pas de coupure en cours
|
WARNING, // proche ou au-delà du forfait (>= 80%), liste blanche active, pas de coupure en cours
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class InstalledAppsRepository @Inject constructor(
|
|||||||
@ApplicationContext private val context: Context
|
@ApplicationContext private val context: Context
|
||||||
) {
|
) {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
suspend fun listInstalledApps(): List<AppInfo> = withContext(Dispatchers.Default) {
|
suspend fun listInstalledApps(withIcons: Boolean = true): List<AppInfo> = withContext(Dispatchers.Default) {
|
||||||
val pm = context.packageManager
|
val pm = context.packageManager
|
||||||
val ownPackage = context.packageName
|
val ownPackage = context.packageName
|
||||||
pm.getInstalledApplications(PackageManager.GET_META_DATA)
|
pm.getInstalledApplications(PackageManager.GET_META_DATA)
|
||||||
@@ -24,7 +24,7 @@ class InstalledAppsRepository @Inject constructor(
|
|||||||
AppInfo(
|
AppInfo(
|
||||||
packageName = appInfo.packageName,
|
packageName = appInfo.packageName,
|
||||||
label = pm.getApplicationLabel(appInfo).toString(),
|
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
|
isSystemApp = (appInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ class SettingsRepository @Inject constructor(
|
|||||||
val CUTOFF_ANCHOR_MILLIS = longPreferencesKey("cutoff_anchor_millis")
|
val CUTOFF_ANCHOR_MILLIS = longPreferencesKey("cutoff_anchor_millis")
|
||||||
val CUTOFF_IN_PULSE = booleanPreferencesKey("cutoff_in_pulse")
|
val CUTOFF_IN_PULSE = booleanPreferencesKey("cutoff_in_pulse")
|
||||||
val CUTOFF_PULSE_START_MILLIS = longPreferencesKey("cutoff_pulse_start_millis")
|
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<AppSettings> = context.dataStore.data.map { prefs ->
|
val settingsFlow: Flow<AppSettings> = context.dataStore.data.map { prefs ->
|
||||||
@@ -63,7 +71,10 @@ class SettingsRepository @Inject constructor(
|
|||||||
passwordSalt = prefs[Keys.PASSWORD_SALT],
|
passwordSalt = prefs[Keys.PASSWORD_SALT],
|
||||||
cutoffCycleAnchorMillis = prefs[Keys.CUTOFF_ANCHOR_MILLIS],
|
cutoffCycleAnchorMillis = prefs[Keys.CUTOFF_ANCHOR_MILLIS],
|
||||||
cutoffInPulse = prefs[Keys.CUTOFF_IN_PULSE] ?: defaults.cutoffInPulse,
|
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
|
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<String>?,
|
||||||
|
whitelistedPackagesWifi: Set<String>?,
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Unit> = runCatching {
|
||||||
|
check(gateway.enabled) { "Firebase non configuré dans cette version" }
|
||||||
|
val db = gateway.db!!
|
||||||
|
val uid = gateway.ensureSignedIn() ?: error("Connexion Firebase impossible (réseau ?)")
|
||||||
|
val clean = code.trim().uppercase()
|
||||||
|
val pairing = db.collection("pairings").document(clean).get().await()
|
||||||
|
check(pairing.exists()) { "Code inconnu ou expiré" }
|
||||||
|
val familyId = pairing.getString("familyId") ?: error("Code invalide")
|
||||||
|
check((pairing.getLong("expiresAt") ?: 0L) > System.currentTimeMillis()) { "Code expiré" }
|
||||||
|
val deviceId = UUID.randomUUID().toString()
|
||||||
|
db.collection("families").document(familyId).collection("devices").document(deviceId).set(
|
||||||
|
mapOf(
|
||||||
|
"deviceUid" to uid,
|
||||||
|
"name" to deviceName.ifBlank { Build.MODEL },
|
||||||
|
"model" to "${Build.MANUFACTURER} ${Build.MODEL}",
|
||||||
|
"pairingCode" to clean,
|
||||||
|
"pairedAt" to System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
).await()
|
||||||
|
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<String, Any?>)
|
||||||
|
|
||||||
|
private fun configFlow(fid: String, did: String) = callbackFlow<Config?> {
|
||||||
|
val db = gateway.db
|
||||||
|
if (db == null || gateway.ensureSignedIn() == null) { trySend(null); close(); return@callbackFlow }
|
||||||
|
val reg = db.collection("families").document(fid).collection("devices").document(did)
|
||||||
|
.collection("config").document("remote")
|
||||||
|
.addSnapshotListener { snap, err ->
|
||||||
|
if (err != null) { Log.w(TAG, "config listener", err); return@addSnapshotListener }
|
||||||
|
if (snap != null && snap.exists()) trySend(Config(snap.data.orEmpty()))
|
||||||
|
}
|
||||||
|
awaitClose { reg.remove() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun apply(cfg: Config) {
|
||||||
|
val m = cfg.map
|
||||||
|
val version = (m["updatedAt"] as? Number)?.toLong() ?: return
|
||||||
|
fun strings(key: String) = (m[key] as? List<*>)?.filterIsInstance<String>()?.toSet()
|
||||||
|
settingsRepository.applyRemoteConfig(
|
||||||
|
version = version,
|
||||||
|
controlEnabled = m["controlEnabled"] as? Boolean,
|
||||||
|
whitelistedPackages = strings("whitelistedPackages"),
|
||||||
|
whitelistedPackagesWifi = strings("whitelistedPackagesWifi"),
|
||||||
|
wifiFilterEnabled = m["wifiFilterEnabled"] as? Boolean,
|
||||||
|
planLimitMo = (m["planLimitMo"] as? Number)?.toLong(),
|
||||||
|
cutoffIntervalMinutes = (m["cutoffIntervalMinutes"] as? Number)?.toInt()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String, Any?>) {
|
||||||
|
val s = settingsRepository.settingsFlow.first()
|
||||||
|
val ref = deviceDoc(s) ?: return
|
||||||
|
write(ref, data + ("lastSeen" to System.currentTimeMillis()))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun pushTraffic(day: TrafficDay) {
|
||||||
|
val s = settingsRepository.settingsFlow.first()
|
||||||
|
val ref = deviceDoc(s) ?: return
|
||||||
|
write(
|
||||||
|
ref.collection("traffic").document(day.date),
|
||||||
|
mapOf("date" to day.date, "mobileBytes" to day.mobileBytes, "wifiBytes" to day.wifiBytes,
|
||||||
|
"at" to System.currentTimeMillis())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun 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<String, Any?>) {
|
||||||
|
if (gateway.ensureSignedIn() == null) return
|
||||||
|
// Les écritures sont mises en file par le SDK hors-ligne : pas d'attente bloquante du réseau.
|
||||||
|
ref.set(data, SetOptions.merge())
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object { private const val TAG = "RemoteSync" }
|
||||||
|
}
|
||||||
@@ -34,13 +34,34 @@ class ConnectivityObserver @Inject constructor(
|
|||||||
else -> NetworkTransport.OTHER
|
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<Network, NetworkTransport>()
|
||||||
|
|
||||||
|
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() {
|
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||||
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
|
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) {
|
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)
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
trySend(transportOf(cm.getNetworkCapabilities(cm.activeNetwork)))
|
|
||||||
cm.registerNetworkCallback(request, callback)
|
cm.registerNetworkCallback(request, callback)
|
||||||
|
|
||||||
awaitClose { cm.unregisterNetworkCallback(callback) }
|
awaitClose { cm.unregisterNetworkCallback(callback) }
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import com.jttools.datacontrol.data.AppSettings
|
|||||||
import com.jttools.datacontrol.data.DataState
|
import com.jttools.datacontrol.data.DataState
|
||||||
import com.jttools.datacontrol.data.SettingsRepository
|
import com.jttools.datacontrol.data.SettingsRepository
|
||||||
import com.jttools.datacontrol.data.UsageRepository
|
import com.jttools.datacontrol.data.UsageRepository
|
||||||
|
import com.jttools.datacontrol.remote.DeviceReporter
|
||||||
|
import com.jttools.datacontrol.remote.RemoteSync
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -45,6 +47,8 @@ class DataMonitorService : LifecycleService() {
|
|||||||
@Inject lateinit var usageRepository: UsageRepository
|
@Inject lateinit var usageRepository: UsageRepository
|
||||||
@Inject lateinit var connectivityObserver: ConnectivityObserver
|
@Inject lateinit var connectivityObserver: ConnectivityObserver
|
||||||
@Inject lateinit var stateHolder: MonitorStateHolder
|
@Inject lateinit var stateHolder: MonitorStateHolder
|
||||||
|
@Inject lateinit var deviceReporter: DeviceReporter
|
||||||
|
@Inject lateinit var remoteSync: RemoteSync
|
||||||
|
|
||||||
private var latestSettings: AppSettings = AppSettings()
|
private var latestSettings: AppSettings = AppSettings()
|
||||||
private var latestTransport: NetworkTransport = NetworkTransport.NONE
|
private var latestTransport: NetworkTransport = NetworkTransport.NONE
|
||||||
@@ -52,16 +56,21 @@ class DataMonitorService : LifecycleService() {
|
|||||||
|
|
||||||
private var lastAppliedMode: VpnMode? = null
|
private var lastAppliedMode: VpnMode? = null
|
||||||
private var lastAppliedWhitelist: Set<String> = emptySet()
|
private var lastAppliedWhitelist: Set<String> = emptySet()
|
||||||
|
private var lastApplyMillis = 0L
|
||||||
|
|
||||||
private var cutoffCycleAnchorMillis: Long? = null
|
private var cutoffCycleAnchorMillis: Long? = null
|
||||||
private var inPulse = false
|
private var inPulse = false
|
||||||
private var pulseStartMillis: Long = 0L
|
private var pulseStartMillis: Long = 0L
|
||||||
private var settingsLoaded = false
|
private var settingsLoaded = false
|
||||||
|
private var lastControlEnabled: Boolean? = null
|
||||||
|
|
||||||
private val vpnRevokedReceiver = object : BroadcastReceiver() {
|
private val vpnRevokedReceiver = object : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
vpnRevoked = true
|
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
|
settingsLoaded = true
|
||||||
}
|
}
|
||||||
latestSettings = it
|
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
|
if (!it.controlEnabled) vpnRevoked = false
|
||||||
tick()
|
tick()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
deviceReporter.start(lifecycleScope)
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
connectivityObserver.observe().collect {
|
connectivityObserver.observe().collect {
|
||||||
latestTransport = it
|
latestTransport = it
|
||||||
@@ -137,7 +156,9 @@ class DataMonitorService : LifecycleService() {
|
|||||||
estimatedTotalPriceEuro = estimatedTotal,
|
estimatedTotalPriceEuro = estimatedTotal,
|
||||||
dataState = if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK,
|
dataState = if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK,
|
||||||
nextAutoResetDate = settings.nextAutoResetDate(),
|
nextAutoResetDate = settings.nextAutoResetDate(),
|
||||||
serviceRunning = false
|
serviceRunning = false,
|
||||||
|
firewallFault = false,
|
||||||
|
transport = latestTransport
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
updateNotification(stateHolder.snapshot.value)
|
updateNotification(stateHolder.snapshot.value)
|
||||||
@@ -148,6 +169,7 @@ class DataMonitorService : LifecycleService() {
|
|||||||
|
|
||||||
val dataState: DataState
|
val dataState: DataState
|
||||||
var nextCutoffAt: Long? = null
|
var nextCutoffAt: Long? = null
|
||||||
|
var firewallFault = false
|
||||||
|
|
||||||
when {
|
when {
|
||||||
latestTransport == NetworkTransport.WIFI -> {
|
latestTransport == NetworkTransport.WIFI -> {
|
||||||
@@ -188,12 +210,16 @@ class DataMonitorService : LifecycleService() {
|
|||||||
applyVpnMode(VpnMode.FILTER, settings.whitelistedPackages)
|
applyVpnMode(VpnMode.FILTER, settings.whitelistedPackages)
|
||||||
}
|
}
|
||||||
nextCutoffAt = if (inPulse) pulseStartMillis + PULSE_DURATION_MS else anchor + intervalMs
|
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
|
dataState = if (inPulse) DataState.CUT else if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
// Pas de réseau, transport inconnu, ou VPN non préparé/révoqué : rien à filtrer.
|
// Pas de réseau, transport inconnu, ou VPN non préparé/révoqué : rien à filtrer.
|
||||||
applyVpnMode(VpnMode.STOPPED, emptySet())
|
applyVpnMode(VpnMode.STOPPED, emptySet())
|
||||||
resetCutoffCycle()
|
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
|
dataState = if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,7 +233,9 @@ class DataMonitorService : LifecycleService() {
|
|||||||
dataState = dataState,
|
dataState = dataState,
|
||||||
nextAutoResetDate = settings.nextAutoResetDate(),
|
nextAutoResetDate = settings.nextAutoResetDate(),
|
||||||
nextCutoffAtMillis = nextCutoffAt,
|
nextCutoffAtMillis = nextCutoffAt,
|
||||||
serviceRunning = true
|
serviceRunning = true,
|
||||||
|
firewallFault = firewallFault,
|
||||||
|
transport = latestTransport
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
updateNotification(stateHolder.snapshot.value)
|
updateNotification(stateHolder.snapshot.value)
|
||||||
@@ -236,9 +264,14 @@ class DataMonitorService : LifecycleService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun applyVpnMode(mode: VpnMode, whitelist: Set<String>) {
|
private fun applyVpnMode(mode: VpnMode, whitelist: Set<String>) {
|
||||||
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
|
lastAppliedMode = mode
|
||||||
lastAppliedWhitelist = whitelist
|
lastAppliedWhitelist = whitelist
|
||||||
|
lastApplyMillis = System.currentTimeMillis()
|
||||||
when (mode) {
|
when (mode) {
|
||||||
VpnMode.STOPPED -> ContextCompat.startForegroundService(this, FirewallVpnService.stopIntent(this))
|
VpnMode.STOPPED -> ContextCompat.startForegroundService(this, FirewallVpnService.stopIntent(this))
|
||||||
VpnMode.FILTER -> ContextCompat.startForegroundService(
|
VpnMode.FILTER -> ContextCompat.startForegroundService(
|
||||||
@@ -262,6 +295,7 @@ class DataMonitorService : LifecycleService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val (emoji, stateLabel, color) = when {
|
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.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.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"))
|
snapshot.dataState == DataState.WARNING -> Triple("🟠", getString(R.string.dashboard_state_warning), Color.parseColor("#EF6C00"))
|
||||||
@@ -274,7 +308,8 @@ class DataMonitorService : LifecycleService() {
|
|||||||
} else "—"
|
} else "—"
|
||||||
val priceText = String.format("%.2f", snapshot.estimatedTotalPriceEuro)
|
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 €"
|
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.
|
// 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 €"
|
val contentText = "$usageText · $priceText €"
|
||||||
@@ -313,6 +348,7 @@ class DataMonitorService : LifecycleService() {
|
|||||||
private const val NOTIF_ID = 1001
|
private const val NOTIF_ID = 1001
|
||||||
private const val TICK_INTERVAL_MS = 20_000L
|
private const val TICK_INTERVAL_MS = 20_000L
|
||||||
private const val PULSE_DURATION_MS = 30_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
|
private const val WARNING_THRESHOLD = 0.8f
|
||||||
|
|
||||||
fun start(context: Context) {
|
fun start(context: Context) {
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ class FirewallVpnService : VpnService() {
|
|||||||
.setSession("DataControl")
|
.setSession("DataControl")
|
||||||
.addAddress(TUNNEL_ADDRESS, 32)
|
.addAddress(TUNNEL_ADDRESS, 32)
|
||||||
.addRoute("0.0.0.0", 0)
|
.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)
|
.setBlocking(true)
|
||||||
|
|
||||||
// Notre propre app doit toujours pouvoir communiquer normalement.
|
// Notre propre app doit toujours pouvoir communiquer normalement.
|
||||||
@@ -72,6 +75,7 @@ class FirewallVpnService : VpnService() {
|
|||||||
|
|
||||||
val fd = tunInterface ?: return
|
val fd = tunInterface ?: return
|
||||||
running = true
|
running = true
|
||||||
|
tunnelActive = true
|
||||||
readerThread = Thread({ blackhole(fd) }, "datacontrol-vpn-blackhole").apply {
|
readerThread = Thread({ blackhole(fd) }, "datacontrol-vpn-blackhole").apply {
|
||||||
isDaemon = true
|
isDaemon = true
|
||||||
start()
|
start()
|
||||||
@@ -102,6 +106,7 @@ class FirewallVpnService : VpnService() {
|
|||||||
|
|
||||||
private fun stopTunnel(keepForeground: Boolean = false) {
|
private fun stopTunnel(keepForeground: Boolean = false) {
|
||||||
running = false
|
running = false
|
||||||
|
tunnelActive = false
|
||||||
readerThread?.interrupt()
|
readerThread?.interrupt()
|
||||||
readerThread = null
|
readerThread = null
|
||||||
tunInterface?.let { runCatching { it.close() } }
|
tunInterface?.let { runCatching { it.close() } }
|
||||||
@@ -150,6 +155,11 @@ class FirewallVpnService : VpnService() {
|
|||||||
private const val CHANNEL_ID = "datacontrol_vpn"
|
private const val CHANNEL_ID = "datacontrol_vpn"
|
||||||
private const val NOTIF_ID = 2001
|
private const val NOTIF_ID = 2001
|
||||||
private const val TUNNEL_ADDRESS = "10.10.10.1"
|
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_FILTER = "com.jttools.datacontrol.action.VPN_START_FILTER"
|
||||||
const val ACTION_START_BLOCK_ALL = "com.jttools.datacontrol.action.VPN_START_BLOCK_ALL"
|
const val ACTION_START_BLOCK_ALL = "com.jttools.datacontrol.action.VPN_START_BLOCK_ALL"
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ data class MonitorSnapshot(
|
|||||||
val dataState: DataState = DataState.OK,
|
val dataState: DataState = DataState.OK,
|
||||||
val nextAutoResetDate: LocalDate = LocalDate.now(),
|
val nextAutoResetDate: LocalDate = LocalDate.now(),
|
||||||
val nextCutoffAtMillis: Long? = null,
|
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
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -34,6 +34,54 @@ class SystemActions(private val activity: ComponentActivity) {
|
|||||||
ActivityResultContracts.RequestPermission()
|
ActivityResultContracts.RequestPermission()
|
||||||
) { granted -> onNotificationResult?.invoke(granted) }
|
) { 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<Array<String>> = activity.registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestMultiplePermissions()
|
||||||
|
) { granted -> onLocationResult?.invoke(granted.values.any { it }) }
|
||||||
|
|
||||||
|
private val backgroundLocationLauncher: ActivityResultLauncher<String> = activity.registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { granted -> onBackgroundLocationResult?.invoke(granted) }
|
||||||
|
|
||||||
|
private val adminLauncher: ActivityResultLauncher<Intent> = 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 isVpnPrepared(): Boolean = FirewallVpnService.isPrepared(activity)
|
||||||
|
|
||||||
fun requestVpnPermission(onResult: (Boolean) -> Unit) {
|
fun requestVpnPermission(onResult: (Boolean) -> Unit) {
|
||||||
|
|||||||
@@ -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<String?>(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)) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PairingUiState> = _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)
|
||||||
|
}
|
||||||
@@ -222,6 +222,8 @@ private fun SettingsContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
com.jttools.datacontrol.ui.family.FamilySection(systemActions)
|
||||||
|
|
||||||
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
|
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
|
||||||
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.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)
|
Text(stringResource(R.string.settings_section_security), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||||
|
|||||||
@@ -123,4 +123,23 @@
|
|||||||
<string name="unit_euro">€</string>
|
<string name="unit_euro">€</string>
|
||||||
<string name="common_cancel">Annuler</string>
|
<string name="common_cancel">Annuler</string>
|
||||||
<string name="common_confirm">Confirmer</string>
|
<string name="common_confirm">Confirmer</string>
|
||||||
|
|
||||||
|
<string name="family_title">Contrôle parental (FamilyControl)</string>
|
||||||
|
<string name="family_unavailable">Liaison FamilyControl non configurée dans cette version (fichier firebase.properties absent à la compilation).</string>
|
||||||
|
<string name="family_paired">Appareil associé à FamilyControl : le parent peut gérer les accès réseau, voir la batterie, la position et l\'historique de trafic.</string>
|
||||||
|
<string name="family_unpair">Dissocier cet appareil</string>
|
||||||
|
<string name="family_pair_help">Saisissez le code d\'appairage affiché dans FamilyControl pour associer cet appareil.</string>
|
||||||
|
<string name="family_code">Code d\'appairage</string>
|
||||||
|
<string name="family_device_name">Nom de l\'appareil</string>
|
||||||
|
<string name="family_pair">Associer</string>
|
||||||
|
<string name="family_admin_title">Protection anti-désinstallation</string>
|
||||||
|
<string name="family_admin_on">Active : DataControl ne peut pas être désinstallé sans désactiver d\'abord cette protection (mot de passe demandé ici).</string>
|
||||||
|
<string name="family_admin_off">Inactive : l\'application peut être désinstallée librement.</string>
|
||||||
|
<string name="family_admin_enable">Activer la protection</string>
|
||||||
|
<string name="family_admin_disable">Désactiver la protection</string>
|
||||||
|
<string name="family_admin_needs_password">Définissez un mot de passe (section Sécurité) : sans lui, la protection peut être retirée librement.</string>
|
||||||
|
<string name="family_location_title">Position de l\'appareil</string>
|
||||||
|
<string name="family_location_desc">Partagée avec le parent via FamilyControl, y compris en arrière-plan (choisir « Toujours autoriser »).</string>
|
||||||
|
<string name="family_location_grant">Autoriser la position</string>
|
||||||
|
<string name="family_location_background">Autoriser en arrière-plan</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<device-admin>
|
||||||
|
<uses-policies />
|
||||||
|
</device-admin>
|
||||||
@@ -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=
|
||||||
@@ -16,6 +16,8 @@ hiltNavigationCompose = "1.2.0"
|
|||||||
datastore = "1.1.1"
|
datastore = "1.1.1"
|
||||||
coroutines = "1.8.1"
|
coroutines = "1.8.1"
|
||||||
workManager = "2.9.1"
|
workManager = "2.9.1"
|
||||||
|
firebaseBom = "33.1.2"
|
||||||
|
coroutinesPlayServices = "1.8.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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" }
|
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" }
|
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" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
versionName=0.1.2
|
versionName=0.1.3
|
||||||
versionCode=3
|
versionCode=4
|
||||||
Reference in new issue
Block a user