feat: tunnel WireGuard vers le serveur familyd et mode propriétaire de l'appareil
- Firebase et le pare-feu local (trou noir, liste blanche par application, coupure cyclique locale) sont retirés - Client WireGuard officiel (GoBackend) : tout le trafic passe par le serveur familyd, qui filtre - Appairage par code ou lien familyd:// ; clé privée WireGuard et jeton chiffrés par l'Android Keystore - Remontées par le tunnel : état, temps d'écran, applications installées, évènements (file persistante) - Mode propriétaire (Device Owner) : VPN toujours actif avec blocage hors VPN, réglages verrouillés, garde-fou si le serveur ne répond plus pendant 15 min - Plan de la refonte : docs/PLAN-wireguard.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ
This commit is contained in:
1 parent
f382eeb4fe
commit
33ea4ff947
40 files changed
+1680
-1635
No files matched your search
@@ -9,8 +9,6 @@ import com.jttools.datacontrol.service.DataMonitorRestartWorker
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
@@ -19,15 +17,18 @@ import javax.inject.Inject
|
||||
class DataControlApp : Application() {
|
||||
|
||||
@Inject lateinit var settingsRepository: SettingsRepository
|
||||
@Inject lateinit var firebaseGateway: com.jttools.datacontrol.remote.FirebaseGateway
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
com.jttools.datacontrol.log.AppLog.init(this)
|
||||
CoroutineScope(Dispatchers.IO).launch { settingsRepository.resetPasswordOnce() }
|
||||
// La config Firebase se saisit dans l'appli : on l'applique au démarrage et à chaque modification.
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
settingsRepository.settingsFlow.map { it.firebase }.distinctUntilChanged().collect { firebaseGateway.apply(it) }
|
||||
settingsRepository.resetPasswordOnce()
|
||||
settingsRepository.migrateToV02()
|
||||
}
|
||||
// VPN « toujours actif » (réglage Android ou mode propriétaire) : le système démarre le service
|
||||
// VPN de la bibliothèque ; on relance alors la surveillance, qui monte le tunnel.
|
||||
com.wireguard.android.backend.GoBackend.setAlwaysOnCallback {
|
||||
com.jttools.datacontrol.service.DataMonitorService.start(this)
|
||||
}
|
||||
// Filet de sécurité : relance périodiquement DataMonitorService s'il a été tué en arrière-plan.
|
||||
// enqueueUniquePeriodicWork + KEEP : sans effet si déjà planifié, donc sûr à appeler à chaque démarrage.
|
||||
|
||||
@@ -20,6 +20,10 @@ object AdminController {
|
||||
"Empêche la désinstallation de DataControl sans mot de passe."
|
||||
)
|
||||
|
||||
/** Vrai si DataControl est « propriétaire de l'appareil » (provisionné par adb, voir le plan, phase 4). */
|
||||
fun isDeviceOwner(context: Context): Boolean =
|
||||
context.getSystemService(DevicePolicyManager::class.java).isDeviceOwnerApp(context.packageName)
|
||||
|
||||
/** À 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,115 @@
|
||||
package com.jttools.datacontrol.admin
|
||||
|
||||
import android.app.admin.DevicePolicyManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.UserManager
|
||||
import com.jttools.datacontrol.log.AppLog
|
||||
|
||||
/**
|
||||
* Mode « propriétaire de l'appareil » (Device Owner), provisionné une fois par adb :
|
||||
* adb shell dpm set-device-owner com.jttools.datacontrol/.admin.AppDeviceAdminReceiver
|
||||
*
|
||||
* DataControl impose alors au système : VPN toujours actif (connexions bloquées hors tunnel), réglages
|
||||
* VPN / date et heure / DNS privé verrouillés, pas de réinitialisation, pas de mode sans échec, pas
|
||||
* d'autre utilisateur, désinstallation impossible. Sans ce mode, toutes ces fonctions sont inertes.
|
||||
*/
|
||||
object OwnerPolicy {
|
||||
|
||||
/** Restrictions posées tant que la protection est active (le débogage USB reste permis : maintenance). */
|
||||
private val RESTRICTIONS = buildList {
|
||||
add(UserManager.DISALLOW_CONFIG_VPN)
|
||||
add(UserManager.DISALLOW_CONFIG_DATE_TIME)
|
||||
add(UserManager.DISALLOW_FACTORY_RESET)
|
||||
add(UserManager.DISALLOW_SAFE_BOOT)
|
||||
add(UserManager.DISALLOW_ADD_USER)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) add(UserManager.DISALLOW_CONFIG_PRIVATE_DNS)
|
||||
}
|
||||
|
||||
/** État voulu, appliqué tel quel ; [lockdown] = connexions bloquées hors VPN. */
|
||||
data class Target(val protect: Boolean, val lockdown: Boolean)
|
||||
|
||||
private var applied: Target? = null
|
||||
|
||||
fun isDeviceOwner(context: Context): Boolean = AdminController.isDeviceOwner(context)
|
||||
|
||||
private fun dpm(context: Context) = context.getSystemService(DevicePolicyManager::class.java)
|
||||
|
||||
/** Applique [target] si DataControl est propriétaire et que l'état a changé. Renvoie faux en cas d'échec. */
|
||||
fun sync(context: Context, target: Target, force: Boolean = false): Boolean {
|
||||
if (!isDeviceOwner(context)) {
|
||||
applied = null
|
||||
return true
|
||||
}
|
||||
if (!force && target == applied) return true
|
||||
val dpm = dpm(context)
|
||||
val admin = AdminController.component(context)
|
||||
return try {
|
||||
if (target.protect) {
|
||||
// Le système accorde lui-même l'autorisation VPN au paquet désigné : aucune fenêtre de consentement.
|
||||
dpm.setAlwaysOnVpnPackage(admin, context.packageName, target.lockdown)
|
||||
RESTRICTIONS.forEach { dpm.addUserRestriction(admin, it) }
|
||||
dpm.setUninstallBlocked(admin, context.packageName, true)
|
||||
requireAutoTime(context)
|
||||
} else {
|
||||
dpm.setAlwaysOnVpnPackage(admin, null, false)
|
||||
RESTRICTIONS.forEach { dpm.clearUserRestriction(admin, it) }
|
||||
dpm.setUninstallBlocked(admin, context.packageName, false)
|
||||
}
|
||||
applied = target
|
||||
AppLog.i("Propriétaire", when {
|
||||
!target.protect -> "Protections levées (contrôle désactivé ou appareil non associé)"
|
||||
target.lockdown -> "VPN toujours actif, connexions bloquées hors VPN, réglages verrouillés"
|
||||
else -> "VPN toujours actif SANS blocage hors VPN (serveur injoignable), réglages verrouillés"
|
||||
})
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
AppLog.e("Propriétaire", "Application des protections impossible", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun requireAutoTime(context: Context) {
|
||||
val dpm = dpm(context)
|
||||
val admin = AdminController.component(context)
|
||||
runCatching {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
dpm.setAutoTimeEnabled(admin, true)
|
||||
dpm.setAutoTimeZoneEnabled(admin, true)
|
||||
} else {
|
||||
dpm.setAutoTimeRequired(admin, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** VPN toujours actif imposé par DataControl, et avec quel blocage. */
|
||||
fun alwaysOnState(context: Context): Pair<Boolean, Boolean> {
|
||||
if (!isDeviceOwner(context)) return false to false
|
||||
val dpm = dpm(context)
|
||||
val admin = AdminController.component(context)
|
||||
return runCatching {
|
||||
val pkg = dpm.getAlwaysOnVpnPackage(admin)
|
||||
val lockdown = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && dpm.isAlwaysOnVpnLockdownEnabled(admin)
|
||||
(pkg == context.packageName) to lockdown
|
||||
}.getOrDefault(false to false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sortie du mode propriétaire (après vérification du mot de passe) : protections levées, puis
|
||||
* DataControl redevient une application ordinaire (l'administrateur d'appareil simple reste actif).
|
||||
*/
|
||||
fun release(context: Context): Boolean {
|
||||
if (!isDeviceOwner(context)) return true
|
||||
sync(context, Target(protect = false, lockdown = false), force = true)
|
||||
return runCatching {
|
||||
dpm(context).clearDeviceOwnerApp(context.packageName)
|
||||
applied = null
|
||||
AppLog.w("Propriétaire", "Mode propriétaire de l'appareil retiré")
|
||||
true
|
||||
}.getOrElse {
|
||||
AppLog.e("Propriétaire", "Retrait du mode propriétaire impossible", it)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +1,32 @@
|
||||
package com.jttools.datacontrol.data
|
||||
|
||||
import com.jttools.datacontrol.remote.FamilyLink
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* Réglages persistés de l'application, avec leurs valeurs par défaut.
|
||||
*/
|
||||
data class AppSettings(
|
||||
/** Tunnel WireGuard vers le serveur familyd monté en permanence (désactivation protégée par mot de passe). */
|
||||
val controlEnabled: Boolean = false,
|
||||
val planLimitMo: Long = 1000L,
|
||||
val planPriceEuro: Double = 19.99,
|
||||
val overagePricePerMoEuro: Double = 0.01,
|
||||
val planStartDateEpochDay: Long = LocalDate.now().toEpochDay(),
|
||||
val autoResetEnabled: Boolean = true,
|
||||
val cutoffIntervalMinutes: Int = 15,
|
||||
val baselineMobileBytesAtReset: Long = 0L,
|
||||
val whitelistedPackages: Set<String> = DEFAULT_WHITELIST,
|
||||
val whitelistedPackagesWifi: Set<String> = DEFAULT_WHITELIST,
|
||||
val wifiFilterEnabled: Boolean = false,
|
||||
val showSystemApps: Boolean = false,
|
||||
val passwordHash: String? = null,
|
||||
val passwordSalt: String? = null,
|
||||
val cutoffCycleAnchorMillis: Long? = null,
|
||||
val cutoffInPulse: Boolean = false,
|
||||
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,
|
||||
/** Règles d'usage (plages horaires, limites de temps, applications interdites), au format JSON. */
|
||||
/** Règles d'usage des applications (plages horaires, limites, interdites), envoyées par le serveur. */
|
||||
val rulesJson: String? = null,
|
||||
/** Configuration Firebase saisie dans l'application (null : voir firebase.properties de compilation). */
|
||||
val firebase: com.jttools.datacontrol.remote.FirebaseConfig? = null
|
||||
/** Liaison avec le serveur familyd (null tant que l'appareil n'est pas associé). */
|
||||
val link: FamilyLink? = null,
|
||||
/** Mode réseau appliqué par le serveur : open, allowlist ou cut. */
|
||||
val serverMode: String = "open",
|
||||
val serverModeReason: String = "",
|
||||
val policyVersion: Long = 0L
|
||||
) {
|
||||
val isPaired: Boolean get() = !familyId.isNullOrEmpty() && !deviceId.isNullOrEmpty()
|
||||
val isPaired: Boolean get() = link != null
|
||||
|
||||
val planStartDate: LocalDate get() = LocalDate.ofEpochDay(planStartDateEpochDay)
|
||||
|
||||
@@ -49,13 +42,6 @@ data class AppSettings(
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PRONOTE_PACKAGE = "com.indexeducation.pronote"
|
||||
|
||||
/** Applications autorisées par défaut (tant que l'utilisateur n'a pas modifié la liste blanche). */
|
||||
val DEFAULT_WHITELIST: Set<String> = setOf(PRONOTE_PACKAGE)
|
||||
}
|
||||
}
|
||||
|
||||
/** État calculé de la consommation, dérivé des réglages + de la mesure de données réelle. */
|
||||
@@ -70,12 +56,9 @@ data class UsageState(
|
||||
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 {
|
||||
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
|
||||
CUT, // coupure totale cyclique en cours (indépendant du dépassement de forfait)
|
||||
WIFI // Wi-Fi actif, aucun filtrage (sauf si filtrage Wi-Fi activé séparément)
|
||||
OK, // sous le seuil d'alerte
|
||||
WARNING, // proche ou au-delà du forfait (>= 80 %)
|
||||
CUT, // le serveur coupe l'accès (mode « coupé » ou « liste blanche »)
|
||||
WIFI // Wi-Fi actif
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.jttools.datacontrol.data
|
||||
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
/**
|
||||
* Chiffrement des secrets (jeton de l'appareil, clé privée WireGuard) par une clé AES de l'Android
|
||||
* Keystore : la clé ne sort jamais du matériel, une copie des fichiers de l'application ne suffit pas.
|
||||
* Format : « v1:<base64(iv + chiffré)> ».
|
||||
*/
|
||||
object SecretBox {
|
||||
private const val ALIAS = "datacontrol_secrets"
|
||||
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||
|
||||
private fun key(): SecretKey {
|
||||
val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
(ks.getKey(ALIAS, null) as? SecretKey)?.let { return it }
|
||||
val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
|
||||
gen.init(
|
||||
KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(256)
|
||||
.build()
|
||||
)
|
||||
return gen.generateKey()
|
||||
}
|
||||
|
||||
fun encrypt(plain: String): String {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION).apply { init(Cipher.ENCRYPT_MODE, key()) }
|
||||
val out = cipher.iv + cipher.doFinal(plain.toByteArray(Charsets.UTF_8))
|
||||
return "v1:" + Base64.encodeToString(out, Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
/** null si la valeur est absente ou illisible (clé du Keystore perdue : il faut se ré-associer). */
|
||||
fun decrypt(stored: String?): String? {
|
||||
if (stored.isNullOrEmpty() || !stored.startsWith("v1:")) return null
|
||||
return runCatching {
|
||||
val raw = Base64.decode(stored.removePrefix("v1:"), Base64.NO_WRAP)
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, raw, 0, 12))
|
||||
String(cipher.doFinal(raw, 12, raw.size - 12), Charsets.UTF_8)
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -2,27 +2,34 @@ package com.jttools.datacontrol.data
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import com.jttools.datacontrol.log.AppLog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.doublePreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.jttools.datacontrol.log.AppLog
|
||||
import com.jttools.datacontrol.remote.FamilyLink
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.time.LocalDate
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.dataStore by preferencesDataStore(name = "datacontrol_settings")
|
||||
|
||||
/** Évènement à transmettre au serveur, gardé tant qu'il n'est pas parti (tunnel coupé, serveur injoignable). */
|
||||
data class PendingEvent(val type: String, val detail: String, val at: Long)
|
||||
|
||||
@Singleton
|
||||
class SettingsRepository @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
@@ -34,42 +41,47 @@ class SettingsRepository @Inject constructor(
|
||||
val OVERAGE_PRICE_PER_MO = doublePreferencesKey("overage_price_per_mo")
|
||||
val PLAN_START_DATE_EPOCH_DAY = longPreferencesKey("plan_start_date_epoch_day")
|
||||
val AUTO_RESET_ENABLED = booleanPreferencesKey("auto_reset_enabled")
|
||||
val CUTOFF_INTERVAL_MINUTES = intPreferencesKey("cutoff_interval_minutes")
|
||||
val BASELINE_MOBILE_BYTES = longPreferencesKey("baseline_mobile_bytes")
|
||||
val WHITELISTED_PACKAGES = stringSetPreferencesKey("whitelisted_packages")
|
||||
val WHITELISTED_PACKAGES_WIFI = stringSetPreferencesKey("whitelisted_packages_wifi")
|
||||
val WIFI_FILTER_ENABLED = booleanPreferencesKey("wifi_filter_enabled")
|
||||
val SHOW_SYSTEM_APPS = booleanPreferencesKey("show_system_apps")
|
||||
val PASSWORD_HASH = stringPreferencesKey("password_hash")
|
||||
val PASSWORD_SALT = stringPreferencesKey("password_salt")
|
||||
val PASSWORD_RESET_V1_DONE = booleanPreferencesKey("password_reset_v1_done")
|
||||
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 RULES_JSON = stringPreferencesKey("rules_json")
|
||||
val FIREBASE_API_KEY = stringPreferencesKey("firebase_api_key")
|
||||
val FIREBASE_APP_ID = stringPreferencesKey("firebase_app_id")
|
||||
val FIREBASE_PROJECT_ID = stringPreferencesKey("firebase_project_id")
|
||||
val USAGE_DAY = stringPreferencesKey("usage_day")
|
||||
val USAGE_JSON = stringPreferencesKey("usage_json")
|
||||
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 PASSWORD_FAILS = intPreferencesKey("password_fails")
|
||||
val PASSWORD_LOCK_UNTIL = longPreferencesKey("password_lock_until")
|
||||
val RULES_JSON = stringPreferencesKey("rules_json")
|
||||
val USAGE_DAY = stringPreferencesKey("usage_day")
|
||||
val USAGE_JSON = stringPreferencesKey("usage_json")
|
||||
val CLOCK_SKEW_MS = longPreferencesKey("clock_skew_ms")
|
||||
val CLOCK_ZONE = stringPreferencesKey("clock_zone")
|
||||
// v0.2 : liaison familyd
|
||||
val LINK_JSON = stringPreferencesKey("link_json")
|
||||
val LINK_TOKEN_ENC = stringPreferencesKey("link_token_enc")
|
||||
val WG_PRIVATE_ENC = stringPreferencesKey("wg_private_enc")
|
||||
val SERVER_MODE = stringPreferencesKey("server_mode")
|
||||
val SERVER_MODE_REASON = stringPreferencesKey("server_mode_reason")
|
||||
val POLICY_VERSION = longPreferencesKey("policy_version")
|
||||
val EVENT_QUEUE = stringPreferencesKey("event_queue")
|
||||
val MIGRATED_V02 = booleanPreferencesKey("migrated_v02")
|
||||
}
|
||||
|
||||
/** Clés de la v0.1 (Firebase, liste blanche locale, coupure cyclique locale) retirées à la mise à jour. */
|
||||
private val obsoleteKeys: List<Preferences.Key<*>> = listOf(
|
||||
intPreferencesKey("cutoff_interval_minutes"), stringSetPreferencesKey("whitelisted_packages"),
|
||||
stringSetPreferencesKey("whitelisted_packages_wifi"), booleanPreferencesKey("wifi_filter_enabled"),
|
||||
booleanPreferencesKey("show_system_apps"), longPreferencesKey("cutoff_anchor_millis"),
|
||||
booleanPreferencesKey("cutoff_in_pulse"), longPreferencesKey("cutoff_pulse_start_millis"),
|
||||
stringPreferencesKey("family_id"), stringPreferencesKey("device_id"),
|
||||
longPreferencesKey("remote_config_version"), stringPreferencesKey("firebase_api_key"),
|
||||
stringPreferencesKey("firebase_app_id"), stringPreferencesKey("firebase_project_id"),
|
||||
stringPreferencesKey("traffic_day"), longPreferencesKey("traffic_mobile_acc"),
|
||||
longPreferencesKey("traffic_wifi_acc"), longPreferencesKey("traffic_last_mobile"),
|
||||
longPreferencesKey("traffic_last_other")
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val FREE_ATTEMPTS = 3
|
||||
const val LOCK_BASE_MS = 30_000L
|
||||
const val LOCK_MAX_MS = 15 * 60_000L
|
||||
const val EVENT_QUEUE_MAX = 100
|
||||
}
|
||||
|
||||
val settingsFlow: Flow<AppSettings> = context.dataStore.data.map { prefs ->
|
||||
@@ -81,29 +93,29 @@ class SettingsRepository @Inject constructor(
|
||||
overagePricePerMoEuro = prefs[Keys.OVERAGE_PRICE_PER_MO] ?: defaults.overagePricePerMoEuro,
|
||||
planStartDateEpochDay = prefs[Keys.PLAN_START_DATE_EPOCH_DAY] ?: defaults.planStartDateEpochDay,
|
||||
autoResetEnabled = prefs[Keys.AUTO_RESET_ENABLED] ?: defaults.autoResetEnabled,
|
||||
cutoffIntervalMinutes = prefs[Keys.CUTOFF_INTERVAL_MINUTES] ?: defaults.cutoffIntervalMinutes,
|
||||
baselineMobileBytesAtReset = prefs[Keys.BASELINE_MOBILE_BYTES] ?: defaults.baselineMobileBytesAtReset,
|
||||
whitelistedPackages = prefs[Keys.WHITELISTED_PACKAGES] ?: defaults.whitelistedPackages,
|
||||
whitelistedPackagesWifi = prefs[Keys.WHITELISTED_PACKAGES_WIFI] ?: defaults.whitelistedPackagesWifi,
|
||||
wifiFilterEnabled = prefs[Keys.WIFI_FILTER_ENABLED] ?: defaults.wifiFilterEnabled,
|
||||
showSystemApps = prefs[Keys.SHOW_SYSTEM_APPS] ?: defaults.showSystemApps,
|
||||
passwordHash = prefs[Keys.PASSWORD_HASH],
|
||||
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,
|
||||
familyId = prefs[Keys.FAMILY_ID],
|
||||
deviceId = prefs[Keys.DEVICE_ID],
|
||||
remoteConfigVersion = prefs[Keys.REMOTE_CONFIG_VERSION] ?: defaults.remoteConfigVersion,
|
||||
rulesJson = prefs[Keys.RULES_JSON],
|
||||
firebase = run {
|
||||
val k = prefs[Keys.FIREBASE_API_KEY]; val a = prefs[Keys.FIREBASE_APP_ID]; val p = prefs[Keys.FIREBASE_PROJECT_ID]
|
||||
if (k.isNullOrBlank() || a.isNullOrBlank() || p.isNullOrBlank()) null
|
||||
else com.jttools.datacontrol.remote.FirebaseConfig(k, a, p)
|
||||
}
|
||||
link = FamilyLink.fromJson(prefs[Keys.LINK_JSON]),
|
||||
serverMode = prefs[Keys.SERVER_MODE] ?: defaults.serverMode,
|
||||
serverModeReason = prefs[Keys.SERVER_MODE_REASON] ?: defaults.serverModeReason,
|
||||
policyVersion = prefs[Keys.POLICY_VERSION] ?: defaults.policyVersion
|
||||
)
|
||||
}
|
||||
|
||||
/** Passage à la v0.2 : l'ancien appairage Firebase et le filtrage local ne servent plus. */
|
||||
suspend fun migrateToV02() {
|
||||
context.dataStore.edit { prefs ->
|
||||
if (prefs[Keys.MIGRATED_V02] == true) return@edit
|
||||
obsoleteKeys.forEach { prefs.remove(it) }
|
||||
prefs.remove(Keys.RULES_JSON)
|
||||
// L'ancien contrôle (pare-feu local) est remplacé par le tunnel : à réactiver après appairage.
|
||||
prefs[Keys.CONTROL_ENABLED] = false
|
||||
prefs[Keys.MIGRATED_V02] = true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setControlEnabled(enabled: Boolean) {
|
||||
context.dataStore.edit { it[Keys.CONTROL_ENABLED] = enabled }
|
||||
}
|
||||
@@ -112,14 +124,12 @@ class SettingsRepository @Inject constructor(
|
||||
planLimitMo: Long,
|
||||
planPriceEuro: Double,
|
||||
overagePricePerMoEuro: Double,
|
||||
cutoffIntervalMinutes: Int,
|
||||
autoResetEnabled: Boolean
|
||||
) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[Keys.PLAN_LIMIT_MO] = planLimitMo
|
||||
prefs[Keys.PLAN_PRICE_EURO] = planPriceEuro
|
||||
prefs[Keys.OVERAGE_PRICE_PER_MO] = overagePricePerMoEuro
|
||||
prefs[Keys.CUTOFF_INTERVAL_MINUTES] = cutoffIntervalMinutes
|
||||
prefs[Keys.AUTO_RESET_ENABLED] = autoResetEnabled
|
||||
}
|
||||
}
|
||||
@@ -129,22 +139,6 @@ class SettingsRepository @Inject constructor(
|
||||
context.dataStore.edit { it[Keys.PLAN_START_DATE_EPOCH_DAY] = date.toEpochDay() }
|
||||
}
|
||||
|
||||
suspend fun setWhitelistedPackages(packages: Set<String>) {
|
||||
context.dataStore.edit { it[Keys.WHITELISTED_PACKAGES] = packages }
|
||||
}
|
||||
|
||||
suspend fun setWhitelistedPackagesWifi(packages: Set<String>) {
|
||||
context.dataStore.edit { it[Keys.WHITELISTED_PACKAGES_WIFI] = packages }
|
||||
}
|
||||
|
||||
suspend fun setWifiFilterEnabled(enabled: Boolean) {
|
||||
context.dataStore.edit { it[Keys.WIFI_FILTER_ENABLED] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setShowSystemApps(show: Boolean) {
|
||||
context.dataStore.edit { it[Keys.SHOW_SYSTEM_APPS] = show }
|
||||
}
|
||||
|
||||
/** Réinitialise le compteur de consommation : nouvelle date de début + nouvelle base de référence. */
|
||||
suspend fun resetPlan(currentMobileBytes: Long, startDate: LocalDate = LocalDate.now()) {
|
||||
context.dataStore.edit { prefs ->
|
||||
@@ -161,18 +155,15 @@ class SettingsRepository @Inject constructor(
|
||||
/**
|
||||
* Force la consommation affichée à [consumedMo] sans changer la date de début du cycle, en
|
||||
* recalculant la base de référence à partir du compteur système actuel ([currentTotalBytes]).
|
||||
* Utile pour recaler l'app sur le décompte opérateur réel.
|
||||
*
|
||||
* La base peut devenir négative (ex: valeur saisie supérieure au compteur TrafficStats depuis
|
||||
* le dernier redémarrage de l'appareil) : c'est volontaire, ne pas la borner à 0 sous peine
|
||||
* d'écraser silencieusement la valeur saisie par l'utilisateur. [UsageRepository.consumedBytesSince]
|
||||
* et [UsageRepository.hasCounterReset] gèrent correctement une base négative.
|
||||
* La base peut devenir négative : c'est volontaire (voir [UsageRepository.consumedBytesSince]).
|
||||
*/
|
||||
suspend fun setConsumedMo(consumedMo: Long, currentTotalBytes: Long) {
|
||||
val baseline = currentTotalBytes - UsageRepository.moToBytes(consumedMo)
|
||||
context.dataStore.edit { it[Keys.BASELINE_MOBILE_BYTES] = baseline }
|
||||
}
|
||||
|
||||
// --- Mot de passe ----------------------------------------------------------------------
|
||||
|
||||
/** Définit (ou supprime, si [password] est vide/null) le mot de passe de verrouillage. */
|
||||
suspend fun setPassword(password: String?) {
|
||||
if (password.isNullOrBlank()) {
|
||||
@@ -192,19 +183,6 @@ class SettingsRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Horloge de référence de [com.jttools.datacontrol.guard.GuardEngine] : décalage (ms) et fuseau retenus. */
|
||||
suspend fun trustedClock(): Pair<Long, String?> {
|
||||
val prefs = context.dataStore.data.first()
|
||||
return (prefs[Keys.CLOCK_SKEW_MS] ?: 0L) to prefs[Keys.CLOCK_ZONE]
|
||||
}
|
||||
|
||||
suspend fun setTrustedClock(skewMs: Long, zoneId: String) {
|
||||
context.dataStore.edit {
|
||||
if (skewMs == 0L) it.remove(Keys.CLOCK_SKEW_MS) else it[Keys.CLOCK_SKEW_MS] = skewMs
|
||||
it[Keys.CLOCK_ZONE] = zoneId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Réinitialisation unique (v0.1.2) du mot de passe de verrouillage : le supprime une seule fois,
|
||||
* puis mémorise qu'elle a eu lieu pour ne pas effacer un mot de passe défini ensuite.
|
||||
@@ -278,89 +256,21 @@ class SettingsRepository @Inject constructor(
|
||||
return if (s < 60) "Trop d'essais : réessayez dans $s s." else "Trop d'essais : réessayez dans ${(s + 59) / 60} min."
|
||||
}
|
||||
|
||||
/**
|
||||
* Persiste l'état du cycle de coupure automatique, pour que le minuteur ne reparte pas de
|
||||
* zéro si [DataMonitorService] est tué puis relancé par le système avant la fin de l'intervalle
|
||||
* (fréquent sur les ROM qui tuent agressivement les apps en arrière-plan sans exemption batterie).
|
||||
*/
|
||||
suspend fun setCutoffCycleState(anchorMillis: Long?, inPulse: Boolean, pulseStartMillis: Long) {
|
||||
context.dataStore.edit { prefs ->
|
||||
if (anchorMillis == null) prefs.remove(Keys.CUTOFF_ANCHOR_MILLIS) else prefs[Keys.CUTOFF_ANCHOR_MILLIS] = anchorMillis
|
||||
prefs[Keys.CUTOFF_IN_PULSE] = inPulse
|
||||
prefs[Keys.CUTOFF_PULSE_START_MILLIS] = pulseStartMillis
|
||||
}
|
||||
// --- Horloge de référence et temps d'écran --------------------------------------------------
|
||||
|
||||
/** Horloge de référence de [com.jttools.datacontrol.guard.GuardEngine] : décalage (ms) et fuseau retenus. */
|
||||
suspend fun trustedClock(): Pair<Long, String?> {
|
||||
val prefs = context.dataStore.data.first()
|
||||
return (prefs[Keys.CLOCK_SKEW_MS] ?: 0L) to prefs[Keys.CLOCK_ZONE]
|
||||
}
|
||||
|
||||
suspend fun setPairing(familyId: String?, deviceId: String?) {
|
||||
suspend fun setTrustedClock(skewMs: Long, zoneId: 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)
|
||||
it.remove(Keys.RULES_JSON)
|
||||
} else {
|
||||
it[Keys.FAMILY_ID] = familyId
|
||||
it[Keys.DEVICE_ID] = deviceId
|
||||
it[Keys.REMOTE_CONFIG_VERSION] = 0L
|
||||
}
|
||||
if (skewMs == 0L) it.remove(Keys.CLOCK_SKEW_MS) else it[Keys.CLOCK_SKEW_MS] = skewMs
|
||||
it[Keys.CLOCK_ZONE] = zoneId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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?,
|
||||
rulesJson: String?
|
||||
) {
|
||||
context.dataStore.edit { prefs ->
|
||||
if ((prefs[Keys.REMOTE_CONFIG_VERSION] ?: 0L) >= version) return@edit
|
||||
rulesJson?.let { prefs[Keys.RULES_JSON] = it }
|
||||
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
|
||||
}
|
||||
|
||||
suspend fun loadUsage(): Pair<String, String>? {
|
||||
val prefs = context.dataStore.data.first()
|
||||
val day = prefs[Keys.USAGE_DAY] ?: return null
|
||||
@@ -375,16 +285,71 @@ class SettingsRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Enregistre (ou efface, si null) la configuration Firebase saisie dans l'application. */
|
||||
suspend fun setFirebaseConfig(config: com.jttools.datacontrol.remote.FirebaseConfig?) {
|
||||
// --- Liaison familyd --------------------------------------------------------------------
|
||||
|
||||
/** Enregistre l'appairage. Le jeton et la clé privée sont chiffrés par l'Android Keystore. */
|
||||
suspend fun savePairing(link: FamilyLink, deviceToken: String, wgPrivateKey: String) {
|
||||
val token = SecretBox.encrypt(deviceToken)
|
||||
val key = SecretBox.encrypt(wgPrivateKey)
|
||||
context.dataStore.edit {
|
||||
if (config == null) {
|
||||
it.remove(Keys.FIREBASE_API_KEY); it.remove(Keys.FIREBASE_APP_ID); it.remove(Keys.FIREBASE_PROJECT_ID)
|
||||
} else {
|
||||
it[Keys.FIREBASE_API_KEY] = config.apiKey
|
||||
it[Keys.FIREBASE_APP_ID] = config.appId
|
||||
it[Keys.FIREBASE_PROJECT_ID] = config.projectId
|
||||
}
|
||||
it[Keys.LINK_JSON] = link.toJson()
|
||||
it[Keys.LINK_TOKEN_ENC] = token
|
||||
it[Keys.WG_PRIVATE_ENC] = key
|
||||
it[Keys.SERVER_MODE] = "open"
|
||||
it[Keys.SERVER_MODE_REASON] = ""
|
||||
it[Keys.POLICY_VERSION] = 0L
|
||||
it.remove(Keys.RULES_JSON)
|
||||
it.remove(Keys.EVENT_QUEUE)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearPairing() {
|
||||
context.dataStore.edit {
|
||||
listOf(Keys.LINK_JSON, Keys.LINK_TOKEN_ENC, Keys.WG_PRIVATE_ENC, Keys.SERVER_MODE,
|
||||
Keys.SERVER_MODE_REASON, Keys.POLICY_VERSION, Keys.RULES_JSON, Keys.EVENT_QUEUE).forEach { k -> it.remove(k) }
|
||||
it[Keys.CONTROL_ENABLED] = false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deviceToken(): String? = SecretBox.decrypt(context.dataStore.data.first()[Keys.LINK_TOKEN_ENC])
|
||||
|
||||
suspend fun wgPrivateKey(): String? = SecretBox.decrypt(context.dataStore.data.first()[Keys.WG_PRIVATE_ENC])
|
||||
|
||||
/** Consigne reçue du serveur (mode réseau en cours et règles d'applications). */
|
||||
suspend fun applyServerPolicy(version: Long, mode: String, reason: String, rulesJson: String?) {
|
||||
context.dataStore.edit {
|
||||
it[Keys.POLICY_VERSION] = version
|
||||
it[Keys.SERVER_MODE] = mode
|
||||
it[Keys.SERVER_MODE_REASON] = reason
|
||||
if (rulesJson.isNullOrBlank() || rulesJson == "{}") it.remove(Keys.RULES_JSON) else it[Keys.RULES_JSON] = rulesJson
|
||||
}
|
||||
}
|
||||
|
||||
// --- File des évènements --------------------------------------------------------------------
|
||||
|
||||
suspend fun queueEvent(event: PendingEvent) {
|
||||
context.dataStore.edit {
|
||||
val list = parseQueue(it[Keys.EVENT_QUEUE]) + event
|
||||
it[Keys.EVENT_QUEUE] = serializeQueue(list.takeLast(EVENT_QUEUE_MAX))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun pendingEvents(): List<PendingEvent> = parseQueue(context.dataStore.data.first()[Keys.EVENT_QUEUE])
|
||||
|
||||
/** Retire de la file les [count] premiers évènements (envoyés avec succès). */
|
||||
suspend fun dropEvents(count: Int) {
|
||||
context.dataStore.edit {
|
||||
val rest = parseQueue(it[Keys.EVENT_QUEUE]).drop(count)
|
||||
if (rest.isEmpty()) it.remove(Keys.EVENT_QUEUE) else it[Keys.EVENT_QUEUE] = serializeQueue(rest)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseQueue(json: String?): List<PendingEvent> = runCatching {
|
||||
val a = JSONArray(json ?: return emptyList())
|
||||
(0 until a.length()).map { a.getJSONObject(it) }
|
||||
.map { PendingEvent(it.getString("type"), it.optString("detail"), it.getLong("at")) }
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
private fun serializeQueue(list: List<PendingEvent>): String =
|
||||
JSONArray(list.map { JSONObject().put("type", it.type).put("detail", it.detail).put("at", it.at) }).toString()
|
||||
}
|
||||
@@ -118,12 +118,11 @@ class AppGuardService : AccessibilityService() {
|
||||
}
|
||||
if (pkg == packageName) return "AUTORISÉ (DataControl, toujours exempté) [$net]" to false
|
||||
if (!snap.serviceRunning) return "libre (contrôle désactivé) [$net]" to false
|
||||
return when (snap.appliedMode) {
|
||||
"BLOCK_ALL" -> "BLOQUÉ (coupure totale en cours) [$net]" to true
|
||||
"FILTER" ->
|
||||
if (pkg in snap.effectiveWhitelist) "AUTORISÉ (liste blanche) [$net]" to false
|
||||
else "BLOQUÉ (hors liste blanche) [$net]" to true
|
||||
else -> "libre (aucun filtrage sur ce réseau) [$net]" to false
|
||||
if (!snap.tunnelUp) return "NON FILTRÉ (tunnel coupé) [$net]" to false
|
||||
return when (snap.serverMode) {
|
||||
"cut" -> "BLOQUÉ (Internet coupé par le serveur) [$net]" to true
|
||||
"allowlist" -> "LIMITÉ (liste blanche du serveur) [$net]" to true
|
||||
else -> "filtré par le serveur (accès libre) [$net]" to false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,31 +9,36 @@ 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.Build
|
||||
import android.os.Looper
|
||||
import android.provider.Settings
|
||||
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.guard.AppGuardService
|
||||
import com.jttools.datacontrol.guard.GuardEngine
|
||||
import com.jttools.datacontrol.service.FirewallVpnService
|
||||
import com.jttools.datacontrol.service.MonitorStateHolder
|
||||
import com.jttools.datacontrol.service.NetworkTransport
|
||||
import com.jttools.datacontrol.vpn.TunnelManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDate
|
||||
import org.json.JSONObject
|
||||
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é.
|
||||
* Envoie au serveur familyd l'état du téléphone : batterie, position, réseau, protections actives,
|
||||
* temps d'écran, applications installées. Le volume de données n'est plus envoyé : le serveur le
|
||||
* mesure lui-même sur le tunnel. Ne fait rien tant que l'appareil n'est pas associé.
|
||||
*/
|
||||
@Singleton
|
||||
class DeviceReporter @Inject constructor(
|
||||
@@ -41,18 +46,20 @@ class DeviceReporter @Inject constructor(
|
||||
private val remote: RemoteSync,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val stateHolder: MonitorStateHolder,
|
||||
private val guardEngine: GuardEngine
|
||||
private val guardEngine: GuardEngine,
|
||||
private val tunnel: TunnelManager
|
||||
) {
|
||||
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 battery: JSONObject? = null
|
||||
private var location: JSONObject? = null
|
||||
private var lastSignature = ""
|
||||
private var lastSentAt = 0L
|
||||
private var lastLocationAt = 0L
|
||||
private var locationRegistered = false
|
||||
private var lastGuardEnabled: Boolean? = null
|
||||
private var lastUsageSentTotal = -1L
|
||||
private var appsPushJob: Job? = null
|
||||
private var appsSent = false
|
||||
|
||||
fun start(scope: CoroutineScope) {
|
||||
if (started) return
|
||||
@@ -61,7 +68,13 @@ class DeviceReporter @Inject constructor(
|
||||
|
||||
val batteryReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, intent: Intent?) {
|
||||
if (intent != null) scope.launch { pushBattery(intent) }
|
||||
intent ?: return
|
||||
val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
|
||||
val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, 100)
|
||||
if (level < 0 || scale <= 0) return
|
||||
val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
|
||||
val charging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL
|
||||
battery = JSONObject().put("level", level * 100 / scale).put("charging", charging)
|
||||
}
|
||||
}
|
||||
// Récepteur enregistré dynamiquement : ACTION_BATTERY_CHANGED n'est pas livrable via le manifeste.
|
||||
@@ -90,92 +103,68 @@ class DeviceReporter @Inject constructor(
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED
|
||||
)
|
||||
|
||||
// Changement de réseau : envoyé tout de suite (le forfait et la coupure cyclique en dépendent).
|
||||
scope.launch {
|
||||
stateHolder.snapshot.map { it.transport }.distinctUntilChanged().collect {
|
||||
if (settingsRepository.settingsFlow.first().isPaired) pushState(force = true)
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
val s = settingsRepository.settingsFlow.first()
|
||||
if (s.isPaired) {
|
||||
registerLocationUpdates(scope)
|
||||
pushState()
|
||||
pushTraffic()
|
||||
if (s.isPaired && tunnel.up.value) {
|
||||
registerLocationUpdates()
|
||||
pushState(force = false)
|
||||
pushUsage()
|
||||
if (!appsSent) appsSent = remote.pushApps()
|
||||
}
|
||||
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
|
||||
private suspend fun pushState(force: Boolean) {
|
||||
val guardEnabled = AppGuardService.isEnabled(context)
|
||||
if (lastGuardEnabled == true && !guardEnabled) {
|
||||
remote.pushEvent("guard_disabled", "Service d'accessibilité désactivé : les limites d'applications ne sont plus appliquées")
|
||||
}
|
||||
lastGuardEnabled = guardEnabled
|
||||
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),
|
||||
"guardEnabled" to guardEnabled,
|
||||
"autoTime" to (android.provider.Settings.Global.getInt(
|
||||
context.contentResolver, android.provider.Settings.Global.AUTO_TIME, 1
|
||||
) == 1),
|
||||
"rulesActive" to !guardEngine.rules.isEmpty,
|
||||
"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 state = JSONObject()
|
||||
.put("transport", when (stateHolder.snapshot.value.transport) {
|
||||
NetworkTransport.CELLULAR -> "mobile"
|
||||
NetworkTransport.WIFI -> "wifi"
|
||||
else -> "none"
|
||||
})
|
||||
.put("app_version", BuildConfig.VERSION_NAME)
|
||||
.put("android", Build.VERSION.RELEASE)
|
||||
.put("guard_enabled", guardEnabled)
|
||||
.put("admin_active", AdminController.isActive(context))
|
||||
.put("device_owner", AdminController.isDeviceOwner(context))
|
||||
.put("always_on_vpn", com.jttools.datacontrol.admin.OwnerPolicy.alwaysOnState(context).let { (on, lock) ->
|
||||
JSONObject().put("enabled", on).put("lockdown", lock)
|
||||
})
|
||||
.put("auto_time", Settings.Global.getInt(context.contentResolver, Settings.Global.AUTO_TIME, 1) == 1)
|
||||
battery?.let { state.put("battery", it) }
|
||||
location?.let { state.put("location", it) }
|
||||
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)
|
||||
// Évite les envois inutiles ; un battement toutes les 5 min suffit quand rien ne change.
|
||||
if (!force && signature == lastSignature && now - lastSentAt < HEARTBEAT_MS) return
|
||||
if (remote.pushState(state)) {
|
||||
lastSignature = signature
|
||||
lastSentAt = now
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pushUsage() {
|
||||
val u = guardEngine.usage()
|
||||
// Ne renvoie que si le temps d'écran a évolué (ou changement de jour).
|
||||
if (u.totalMs == lastUsageSentTotal) return
|
||||
lastUsageSentTotal = u.totalMs
|
||||
remote.pushUsage(u.date, u.totalMs, u.appMs)
|
||||
if (remote.pushUsage(u.date, u.totalMs, u.appMs)) lastUsageSentTotal = u.totalMs
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun registerLocationUpdates(scope: CoroutineScope) {
|
||||
private fun registerLocationUpdates() {
|
||||
if (locationRegistered) return
|
||||
val fine = ContextCompat.checkSelfPermission(context, android.Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
@@ -183,7 +172,7 @@ class DeviceReporter @Inject constructor(
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!fine && !coarse) return
|
||||
val lm = context.getSystemService(LocationManager::class.java)
|
||||
val listener = LocationListener { loc -> scope.launch { pushLocation(loc) } }
|
||||
val listener = LocationListener { loc -> onLocation(loc) }
|
||||
var registered = false
|
||||
for (provider in listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)) {
|
||||
if (provider == LocationManager.GPS_PROVIDER && !fine) continue
|
||||
@@ -196,25 +185,17 @@ class DeviceReporter @Inject constructor(
|
||||
locationRegistered = registered
|
||||
}
|
||||
|
||||
private suspend fun pushLocation(loc: Location) {
|
||||
private fun onLocation(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
|
||||
)
|
||||
)
|
||||
)
|
||||
if (now - lastLocationAt < LOCATION_MIN_SEND_MS) return
|
||||
lastLocationAt = now
|
||||
location = JSONObject().put("lat", loc.latitude).put("lng", loc.longitude)
|
||||
.put("accuracy", loc.accuracy.toDouble()).put("at", 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,88 @@
|
||||
package com.jttools.datacontrol.remote
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/** Erreur renvoyée par le serveur ([code] HTTP) ou réseau ([code] = 0), avec un message lisible. */
|
||||
class ApiException(val code: Int, message: String) : IOException(message)
|
||||
|
||||
/**
|
||||
* Client de l'API familyd. Appels bloquants : à lancer sur Dispatchers.IO.
|
||||
* Les routes des appareils passent par le tunnel ([FamilyLink.apiBase], http://10.78.0.1:8100,
|
||||
* déjà chiffré par WireGuard) ; l'appairage passe par l'adresse publique en HTTPS.
|
||||
*/
|
||||
class FamilyApi(private val apiBase: String, private val token: String) {
|
||||
|
||||
fun policy(): JSONObject = JSONObject(request("GET", "$apiBase/api/device/policy", token, null))
|
||||
|
||||
fun serverTime(): Long = JSONObject(request("GET", "$apiBase/api/device/time", token, null)).getLong("time")
|
||||
|
||||
fun state(state: JSONObject) {
|
||||
request("POST", "$apiBase/api/device/state", token, state)
|
||||
}
|
||||
|
||||
fun usage(date: String, totalMs: Long, apps: Map<String, Long>) {
|
||||
request("POST", "$apiBase/api/device/usage", token,
|
||||
JSONObject().put("date", date).put("total_ms", totalMs).put("apps", JSONObject(apps as Map<*, *>)))
|
||||
}
|
||||
|
||||
fun event(type: String, detail: String, at: Long) {
|
||||
request("POST", "$apiBase/api/device/events", token,
|
||||
JSONObject().put("type", type).put("detail", detail.take(500)).put("at", at))
|
||||
}
|
||||
|
||||
fun apps(apps: JSONArray) {
|
||||
request("POST", "$apiBase/api/device/apps", token, JSONObject().put("apps", apps))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CONNECT_TIMEOUT_MS = 10_000
|
||||
private const val READ_TIMEOUT_MS = 15_000
|
||||
|
||||
/** Appairage (public, HTTPS) : renvoie la réponse brute, à lire avec [FamilyLink.fromPairResponse]. */
|
||||
fun pair(serverUrl: String, code: String, publicKey: String, name: String, model: String, appVersion: String): String {
|
||||
require(serverUrl.startsWith("https://")) { "L'adresse du serveur doit commencer par https://" }
|
||||
val body = JSONObject().put("code", code).put("public_key", publicKey).put("name", name)
|
||||
.put("model", model).put("app_version", appVersion)
|
||||
return request("POST", "${serverUrl.trimEnd('/')}/api/pair", null, body)
|
||||
}
|
||||
|
||||
fun request(method: String, url: String, token: String?, body: JSONObject?): String {
|
||||
val conn = try {
|
||||
(URL(url).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = method
|
||||
connectTimeout = CONNECT_TIMEOUT_MS
|
||||
readTimeout = READ_TIMEOUT_MS
|
||||
setRequestProperty("Accept", "application/json")
|
||||
token?.let { setRequestProperty("Authorization", "Bearer $it") }
|
||||
if (body != null) {
|
||||
doOutput = true
|
||||
setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
throw ApiException(0, "Serveur injoignable : ${e.message}")
|
||||
}
|
||||
try {
|
||||
if (body != null) conn.outputStream.use { it.write(body.toString().toByteArray(Charsets.UTF_8)) }
|
||||
val code = conn.responseCode
|
||||
val text = (if (code in 200..299) conn.inputStream else conn.errorStream)
|
||||
?.bufferedReader()?.use { it.readText() }.orEmpty()
|
||||
if (code !in 200..299) {
|
||||
val detail = runCatching { JSONObject(text).optString("detail") }.getOrNull()
|
||||
throw ApiException(code, detail?.takeIf { it.isNotBlank() } ?: "Erreur du serveur ($code)")
|
||||
}
|
||||
return text
|
||||
} catch (e: ApiException) {
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
throw ApiException(0, "Serveur injoignable : ${e.message}")
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.jttools.datacontrol.remote
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.net.URI
|
||||
import java.net.URLDecoder
|
||||
|
||||
/**
|
||||
* Liaison avec le serveur familyd, reçue à l'appairage (sans les secrets : jeton de l'appareil et clé
|
||||
* privée WireGuard sont stockés à part, chiffrés par l'Android Keystore, voir SecretBox).
|
||||
*/
|
||||
data class FamilyLink(
|
||||
val serverUrl: String,
|
||||
val deviceId: String,
|
||||
val name: String,
|
||||
val address: String,
|
||||
val dns: String,
|
||||
val mtu: Int,
|
||||
val serverPublicKey: String,
|
||||
val endpoint: String,
|
||||
val allowedIps: List<String>,
|
||||
val keepalive: Int,
|
||||
val apiBase: String
|
||||
) {
|
||||
/** Adresse du téléphone dans le tunnel, sans le /32. */
|
||||
val tunnelIp: String get() = address.substringBefore('/')
|
||||
|
||||
fun toJson(): String = JSONObject()
|
||||
.put("serverUrl", serverUrl).put("deviceId", deviceId).put("name", name)
|
||||
.put("address", address).put("dns", dns).put("mtu", mtu)
|
||||
.put("serverPublicKey", serverPublicKey).put("endpoint", endpoint)
|
||||
.put("allowedIps", JSONArray(allowedIps)).put("keepalive", keepalive).put("apiBase", apiBase)
|
||||
.toString()
|
||||
|
||||
/** Configuration WireGuard au format wg-quick (lue par la bibliothèque officielle). */
|
||||
fun wgQuickConfig(privateKey: String): String = buildString {
|
||||
appendLine("[Interface]")
|
||||
appendLine("PrivateKey = $privateKey")
|
||||
appendLine("Address = $address")
|
||||
appendLine("DNS = $dns")
|
||||
appendLine("MTU = $mtu")
|
||||
appendLine()
|
||||
appendLine("[Peer]")
|
||||
appendLine("PublicKey = $serverPublicKey")
|
||||
appendLine("Endpoint = $endpoint")
|
||||
appendLine("AllowedIPs = ${allowedIps.joinToString(", ")}")
|
||||
appendLine("PersistentKeepalive = $keepalive")
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromJson(json: String?): FamilyLink? {
|
||||
if (json.isNullOrBlank()) return null
|
||||
return runCatching {
|
||||
val o = JSONObject(json)
|
||||
val ips = o.getJSONArray("allowedIps")
|
||||
FamilyLink(
|
||||
serverUrl = o.getString("serverUrl"), deviceId = o.getString("deviceId"),
|
||||
name = o.optString("name"), address = o.getString("address"), dns = o.getString("dns"),
|
||||
mtu = o.optInt("mtu", 1280), serverPublicKey = o.getString("serverPublicKey"),
|
||||
endpoint = o.getString("endpoint"),
|
||||
allowedIps = (0 until ips.length()).map { ips.getString(it) },
|
||||
keepalive = o.optInt("keepalive", 25), apiBase = o.getString("apiBase")
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/** Réponse de POST /api/pair → liaison + jeton de l'appareil. */
|
||||
fun fromPairResponse(serverUrl: String, json: String): Pair<FamilyLink, String> {
|
||||
val o = JSONObject(json)
|
||||
val wg = o.getJSONObject("wireguard")
|
||||
val ips = wg.getJSONArray("allowed_ips")
|
||||
val link = FamilyLink(
|
||||
serverUrl = serverUrl, deviceId = o.getString("device_id"), name = o.optString("name"),
|
||||
address = wg.getString("address"), dns = wg.getString("dns"), mtu = wg.optInt("mtu", 1280),
|
||||
serverPublicKey = wg.getString("server_public_key"), endpoint = wg.getString("endpoint"),
|
||||
allowedIps = (0 until ips.length()).map { ips.getString(it) },
|
||||
keepalive = wg.optInt("persistent_keepalive", 25), apiBase = o.getString("api").trimEnd('/')
|
||||
)
|
||||
require(link.serverPublicKey.isNotBlank()) { "Le serveur n'a pas fourni sa clé WireGuard." }
|
||||
return link to o.getString("token")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ce que l'utilisateur saisit pour s'associer : le code seul (« ABCD 2345 »), ou le lien
|
||||
* « familyd://pair?url=https://…&code=… » affiché par l'interface des parents.
|
||||
*/
|
||||
data class PairingInput(val serverUrl: String, val code: String) {
|
||||
companion object {
|
||||
const val DEFAULT_SERVER = "https://family.tuxtech.fr"
|
||||
private val CODE = Regex("^[A-HJ-NP-Z2-9]{8}$") // même alphabet que le serveur (sans I, O, 0, 1)
|
||||
|
||||
fun parse(input: String, defaultServer: String = DEFAULT_SERVER): PairingInput? {
|
||||
val text = input.trim()
|
||||
if (text.startsWith("familyd://", ignoreCase = true)) {
|
||||
val query = runCatching { URI(text).rawQuery }.getOrNull() ?: return null
|
||||
val params = query.split('&').mapNotNull {
|
||||
val (k, v) = it.split('=', limit = 2).takeIf { p -> p.size == 2 } ?: return@mapNotNull null
|
||||
k to URLDecoder.decode(v, "UTF-8")
|
||||
}.toMap()
|
||||
val url = params["url"]?.trimEnd('/') ?: return null
|
||||
val code = normalize(params["code"] ?: return null) ?: return null
|
||||
if (!url.startsWith("https://")) return null
|
||||
return PairingInput(url, code)
|
||||
}
|
||||
return normalize(text)?.let { PairingInput(defaultServer, it) }
|
||||
}
|
||||
|
||||
private fun normalize(code: String): String? =
|
||||
code.uppercase().replace(" ", "").replace("-", "").takeIf { CODE.matches(it) }
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package com.jttools.datacontrol.remote
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Identifiants d'un projet Firebase (ceux du bloc `firebaseConfig` de la console). */
|
||||
data class FirebaseConfig(val apiKey: String, val appId: String, val projectId: String) {
|
||||
companion object {
|
||||
/**
|
||||
* Lit ce que l'utilisateur colle : le bloc `firebaseConfig` de la console (JavaScript), un JSON,
|
||||
* le fichier google-services.json, ou une chaîne « fc1:… » partagée par FamilyControl. Renvoie null si apiKey, appId ou projectId manque.
|
||||
*/
|
||||
fun parse(input: String): FirebaseConfig? {
|
||||
val text = input.trim()
|
||||
if (text.startsWith("fc1:")) {
|
||||
return runCatching {
|
||||
val json = JSONObject(String(java.util.Base64.getMimeDecoder().decode(text.removePrefix("fc1:")), Charsets.UTF_8))
|
||||
build(json.optString("apiKey"), json.optString("appId"), json.optString("projectId"))
|
||||
}.getOrNull()
|
||||
}
|
||||
// Guillemets typographiques (souvent insérés par un copier-coller sur téléphone) → droits.
|
||||
val plain = text.replace(Regex("[“”„«»]"), "\"").replace(Regex("[‘’]"), "'")
|
||||
// Noms du bloc Web (apiKey…) ou du fichier google-services.json de l'application Android.
|
||||
fun pick(vararg names: String) = names.firstNotNullOfOrNull { name ->
|
||||
Regex("""["']?\b$name["']?\s*[:=]\s*["']\s*([^"'\s]+)\s*["']""").find(plain)?.groupValues?.get(1)
|
||||
}
|
||||
return build(
|
||||
pick("apiKey", "current_key"),
|
||||
pick("appId", "mobilesdk_app_id"),
|
||||
pick("projectId", "project_id")
|
||||
)
|
||||
}
|
||||
|
||||
private fun build(apiKey: String?, appId: String?, projectId: String?): FirebaseConfig? =
|
||||
if (apiKey.isNullOrBlank() || appId.isNullOrBlank() || projectId.isNullOrBlank()) null
|
||||
else FirebaseConfig(apiKey.trim(), appId.trim(), projectId.trim())
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
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 com.jttools.datacontrol.log.AppLog
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** État du lien Firebase : [generation] change à chaque (ré)initialisation, pour relancer les écoutes. */
|
||||
data class GatewayState(val generation: Int = 0, val enabled: Boolean = false, val projectId: String? = null)
|
||||
|
||||
/**
|
||||
* Accès à Firebase (Auth anonyme + Firestore). La configuration se saisit dans l'application
|
||||
* (Réglages → Contrôle parental) et peut changer à tout moment ; à défaut, on retombe sur
|
||||
* `firebase.properties` (compilation). Sans configuration, [enabled] est faux et tout reste inerte.
|
||||
*/
|
||||
@Singleton
|
||||
class FirebaseGateway @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
private val _state = MutableStateFlow(GatewayState())
|
||||
val state: StateFlow<GatewayState> = _state.asStateFlow()
|
||||
val enabled: Boolean get() = _state.value.enabled
|
||||
|
||||
@Volatile private var app: FirebaseApp? = null
|
||||
private var currentKey: String? = null
|
||||
|
||||
private fun buildConfigFallback(): FirebaseConfig? =
|
||||
if (BuildConfig.FIREBASE_API_KEY.isNotBlank() && BuildConfig.FIREBASE_APP_ID.isNotBlank() &&
|
||||
BuildConfig.FIREBASE_PROJECT_ID.isNotBlank()
|
||||
) FirebaseConfig(BuildConfig.FIREBASE_API_KEY, BuildConfig.FIREBASE_APP_ID, BuildConfig.FIREBASE_PROJECT_ID)
|
||||
else null
|
||||
|
||||
/** Applique la configuration saisie [saved] (ou, à défaut, celle de compilation). Sans effet si elle n'a pas changé. */
|
||||
@Synchronized
|
||||
fun apply(saved: FirebaseConfig?) {
|
||||
val cfg = saved ?: buildConfigFallback()
|
||||
val key = cfg?.let { "${it.apiKey}|${it.appId}|${it.projectId}" }
|
||||
if (key == currentKey && (key == null || app != null)) return
|
||||
|
||||
app?.let { runCatching { it.delete() } }
|
||||
app = null
|
||||
currentKey = key
|
||||
if (cfg != null) {
|
||||
app = runCatching {
|
||||
FirebaseApp.initializeApp(
|
||||
context,
|
||||
FirebaseOptions.Builder()
|
||||
.setApiKey(cfg.apiKey)
|
||||
.setApplicationId(cfg.appId)
|
||||
.setProjectId(cfg.projectId)
|
||||
.build()
|
||||
)
|
||||
}.onFailure { AppLog.e("Firebase", "Initialisation impossible", it) }.getOrNull()
|
||||
}
|
||||
val ok = app != null
|
||||
AppLog.i("Firebase", if (ok) "Configuré (projet ${cfg?.projectId})" else "Non configuré")
|
||||
_state.value = GatewayState(_state.value.generation + 1, ok, cfg?.projectId?.takeIf { ok })
|
||||
}
|
||||
|
||||
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 }
|
||||
.onFailure { AppLog.w("Firebase", "Connexion anonyme impossible : ${it.message}") }
|
||||
.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -1,237 +1,157 @@
|
||||
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.BuildConfig
|
||||
import com.jttools.datacontrol.data.InstalledAppsRepository
|
||||
import com.jttools.datacontrol.data.PendingEvent
|
||||
import com.jttools.datacontrol.data.SettingsRepository
|
||||
import com.jttools.datacontrol.data.TrafficDay
|
||||
import com.jttools.datacontrol.log.AppLog
|
||||
import com.jttools.datacontrol.vpn.TunnelManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.UUID
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Liaison avec FamilyControl via Firestore.
|
||||
* Liaison avec le serveur familyd : appairage, consignes (mode réseau, règles d'applications),
|
||||
* remontées (état, temps d'écran, applications installées, évènements).
|
||||
*
|
||||
* 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é.
|
||||
* Tout passe par le tunnel WireGuard, sauf l'appairage (HTTPS public). Les évènements sont d'abord
|
||||
* enregistrés sur le téléphone, puis envoyés dès que le serveur répond : aucun ne se perd quand le
|
||||
* tunnel est coupé.
|
||||
*/
|
||||
@Singleton
|
||||
class RemoteSync @Inject constructor(
|
||||
private val gateway: FirebaseGateway,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val installedApps: InstalledAppsRepository
|
||||
private val settings: SettingsRepository,
|
||||
private val installedApps: InstalledAppsRepository,
|
||||
private val tunnel: TunnelManager
|
||||
) {
|
||||
val available: Boolean get() = gateway.enabled
|
||||
val gatewayState = gateway.state
|
||||
private val flushLock = Mutex()
|
||||
private var started = false
|
||||
|
||||
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)
|
||||
private suspend fun api(): FamilyApi? {
|
||||
val link = settings.settingsFlow.first().link ?: return null
|
||||
val token = settings.deviceToken() ?: return null
|
||||
return FamilyApi(link.apiBase, token)
|
||||
}
|
||||
|
||||
/** 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()
|
||||
/** Associe le téléphone au serveur : clés WireGuard générées ici, la clé privée ne quitte jamais l'appareil. */
|
||||
suspend fun pair(input: PairingInput, deviceName: String): Result<FamilyLink> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val (privateKey, publicKey) = TunnelManager.newKeyPair()
|
||||
val raw = FamilyApi.pair(
|
||||
input.serverUrl, input.code, publicKey, deviceName.trim().ifBlank { Build.MODEL },
|
||||
"${Build.MANUFACTURER} ${Build.MODEL}", BuildConfig.VERSION_NAME
|
||||
)
|
||||
).await()
|
||||
// Code à usage unique : supprimé une fois consommé (sans effet si les règles Firestore déployées
|
||||
// ne l'autorisent pas encore — il expire de toute façon au bout de 10 min).
|
||||
runCatching { db.collection("pairings").document(clean).delete().await() }
|
||||
.onFailure { AppLog.w("FamilyControl", "Code d'appairage non supprimé : ${it.message}") }
|
||||
settingsRepository.setPairing(familyId, deviceId)
|
||||
val (link, token) = FamilyLink.fromPairResponse(input.serverUrl, raw)
|
||||
settings.savePairing(link, token, privateKey)
|
||||
AppLog.i("Liaison", "Associé à ${input.serverUrl} : appareil ${link.name} (${link.tunnelIp})")
|
||||
link
|
||||
}.onFailure { AppLog.w("Liaison", "Appairage refusé : ${it.message}") }
|
||||
}
|
||||
|
||||
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)
|
||||
/** Oubli local de la liaison (le parent retire l'appareil côté serveur). */
|
||||
suspend fun forget() {
|
||||
pushEvent("unpaired", "Liaison supprimée depuis DataControl")
|
||||
tunnel.down()
|
||||
settings.clearPairing()
|
||||
AppLog.w("Liaison", "Liaison avec le serveur supprimée sur le téléphone")
|
||||
}
|
||||
|
||||
/** Écoute les consignes du parent et les applique ; relancé à chaque (ré)initialisation de Firebase. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun start(scope: CoroutineScope) {
|
||||
scope.launch {
|
||||
gateway.state.collectLatest { st ->
|
||||
if (!st.enabled) return@collectLatest
|
||||
coroutineScope {
|
||||
launch {
|
||||
settingsRepository.settingsFlow
|
||||
.map { it.familyId to it.deviceId }
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { (fid, did) ->
|
||||
if (fid == null || did == null) flowOf(null) else configFlow(fid, did)
|
||||
}
|
||||
.collect { cfg -> if (cfg != null) apply(cfg) }
|
||||
}
|
||||
launch {
|
||||
// Si le parent supprime l'appareil de la famille, on se dissocie localement (plus de règles,
|
||||
// plus d'envois qui échoueraient en silence).
|
||||
settingsRepository.settingsFlow
|
||||
.map { it.familyId to it.deviceId }
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { (fid, did) -> if (fid == null || did == null) flowOf(null) else existenceFlow(fid, did) }
|
||||
.collect { exists ->
|
||||
if (exists == false) {
|
||||
AppLog.w("FamilyControl", "Appareil supprimé par le parent : dissociation locale")
|
||||
settingsRepository.setPairing(null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
launch {
|
||||
// Liste des applications installées, pour que le parent puisse choisir lesquelles autoriser.
|
||||
settingsRepository.settingsFlow.map { it.isPaired }.distinctUntilChanged().collect { paired ->
|
||||
if (paired) pushApps()
|
||||
}
|
||||
}
|
||||
if (started) return
|
||||
started = true
|
||||
scope.launch(Dispatchers.IO) {
|
||||
while (isActive) {
|
||||
runCatching { syncOnce() }
|
||||
delay(SYNC_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Récupère la consigne en cours et vide la file des évènements. Faux si le serveur est injoignable. */
|
||||
suspend fun syncOnce(): Boolean = withContext(Dispatchers.IO) {
|
||||
if (!tunnel.up.value) return@withContext false
|
||||
val api = api() ?: return@withContext false
|
||||
val p = try {
|
||||
api.policy()
|
||||
} catch (e: ApiException) {
|
||||
if (e.code == 401) AppLog.w("Liaison", "Le serveur ne reconnaît plus ce téléphone (dissocié par le parent ?)")
|
||||
return@withContext false
|
||||
}
|
||||
val current = settings.settingsFlow.first()
|
||||
val version = p.optLong("version")
|
||||
val mode = p.optString("mode", "open")
|
||||
val reason = p.optString("mode_reason")
|
||||
if (version != current.policyVersion || mode != current.serverMode || reason != current.serverModeReason) {
|
||||
val rules = p.optJSONObject("app_rules")?.takeIf { it.length() > 0 }?.toString()
|
||||
settings.applyServerPolicy(version, mode, reason, rules)
|
||||
if (mode != current.serverMode) AppLog.i("Liaison", "Mode réseau : $mode${if (reason.isNotBlank()) " ($reason)" else ""}")
|
||||
}
|
||||
flushEvents()
|
||||
true
|
||||
}
|
||||
|
||||
/** Enregistre l'évènement puis tente de l'envoyer (8 s au plus : appelable depuis un récepteur goAsync). */
|
||||
suspend fun pushEvent(type: String, detail: String) {
|
||||
if (!settings.settingsFlow.first().isPaired) return
|
||||
settings.queueEvent(PendingEvent(type, detail, System.currentTimeMillis()))
|
||||
withTimeoutOrNull(EVENT_TIMEOUT_MS) { flushEvents() }
|
||||
}
|
||||
|
||||
private suspend fun flushEvents() = withContext(Dispatchers.IO) {
|
||||
if (!tunnel.up.value) return@withContext
|
||||
flushLock.withLock {
|
||||
val api = api() ?: return@withLock
|
||||
val pending = settings.pendingEvents()
|
||||
var sent = 0
|
||||
for (e in pending) {
|
||||
try {
|
||||
api.event(e.type, e.detail, e.at)
|
||||
sent++
|
||||
} catch (_: ApiException) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (sent > 0) settings.dropEvents(sent)
|
||||
}
|
||||
}
|
||||
|
||||
/** Vrai/faux selon que le document de l'appareil existe côté serveur (ignore le cache local). */
|
||||
private fun existenceFlow(fid: String, did: String) = callbackFlow<Boolean?> {
|
||||
val db = gateway.db
|
||||
if (db == null || gateway.ensureSignedIn() == null) { trySend(null); close(); return@callbackFlow }
|
||||
val reg = db.collection("families").document(fid).collection("devices").document(did)
|
||||
.addSnapshotListener { snap, err ->
|
||||
if (err != null || snap == null || snap.metadata.isFromCache) return@addSnapshotListener
|
||||
trySend(snap.exists())
|
||||
}
|
||||
awaitClose { reg.remove() }
|
||||
suspend fun pushState(state: JSONObject): Boolean = call { it.state(state) }
|
||||
|
||||
suspend fun pushUsage(date: String, totalMs: Long, apps: Map<String, Long>): Boolean = call { it.usage(date, totalMs, apps) }
|
||||
|
||||
/** Liste des applications installées, pour que le parent choisisse lesquelles limiter. */
|
||||
suspend fun pushApps(): Boolean {
|
||||
val list = installedApps.listInstalledApps(withIcons = false)
|
||||
val arr = JSONArray(list.map { JSONObject().put("pkg", it.packageName).put("label", it.label.take(100)).put("system", it.isSystemApp) })
|
||||
return call { it.apps(arr) }
|
||||
}
|
||||
|
||||
private class Config(val map: Map<String, Any?>)
|
||||
|
||||
private fun configFlow(fid: String, did: String) = callbackFlow<Config?> {
|
||||
val db = gateway.db
|
||||
if (db == null || gateway.ensureSignedIn() == null) { trySend(null); close(); return@callbackFlow }
|
||||
val reg = db.collection("families").document(fid).collection("devices").document(did)
|
||||
.collection("config").document("remote")
|
||||
.addSnapshotListener { snap, err ->
|
||||
if (err != null) { Log.w(TAG, "config listener", err); return@addSnapshotListener }
|
||||
if (snap != null && snap.exists()) trySend(Config(snap.data.orEmpty()))
|
||||
}
|
||||
awaitClose { reg.remove() }
|
||||
}
|
||||
|
||||
private suspend fun apply(cfg: Config) {
|
||||
val m = cfg.map
|
||||
val version = (m["updatedAt"] as? Number)?.toLong() ?: return
|
||||
fun strings(key: String) = (m[key] as? List<*>)?.filterIsInstance<String>()?.toSet()
|
||||
settingsRepository.applyRemoteConfig(
|
||||
version = version,
|
||||
controlEnabled = m["controlEnabled"] as? Boolean,
|
||||
whitelistedPackages = strings("whitelistedPackages"),
|
||||
whitelistedPackagesWifi = strings("whitelistedPackagesWifi"),
|
||||
wifiFilterEnabled = m["wifiFilterEnabled"] as? Boolean,
|
||||
planLimitMo = (m["planLimitMo"] as? Number)?.toLong(),
|
||||
cutoffIntervalMinutes = (m["cutoffIntervalMinutes"] as? Number)?.toInt(),
|
||||
rulesJson = (m["rules"] as? Map<*, *>)?.let { org.json.JSONObject(it).toString() }
|
||||
)
|
||||
}
|
||||
|
||||
/** Envoie la liste des applications installées (au parent de choisir lesquelles autoriser). */
|
||||
suspend fun pushApps() {
|
||||
val s = settingsRepository.settingsFlow.first()
|
||||
val ref = deviceDoc(s) ?: return
|
||||
val apps = installedApps.listInstalledApps(withIcons = false).map {
|
||||
mapOf("pkg" to it.packageName, "label" to it.label, "system" to it.isSystemApp)
|
||||
}
|
||||
write(ref.collection("meta").document("apps"), mapOf("apps" to apps, "at" to System.currentTimeMillis()))
|
||||
}
|
||||
|
||||
/** Fusionne [data] dans le document de l'appareil (état, batterie, position…). */
|
||||
suspend fun pushDevice(data: Map<String, Any?>) {
|
||||
val s = settingsRepository.settingsFlow.first()
|
||||
val ref = deviceDoc(s) ?: return
|
||||
write(ref, data + ("lastSeen" to System.currentTimeMillis()))
|
||||
}
|
||||
|
||||
suspend fun pushTraffic(day: TrafficDay) {
|
||||
val s = settingsRepository.settingsFlow.first()
|
||||
val ref = deviceDoc(s) ?: return
|
||||
write(
|
||||
ref.collection("traffic").document(day.date),
|
||||
mapOf("date" to day.date, "mobileBytes" to day.mobileBytes, "wifiBytes" to day.wifiBytes,
|
||||
"at" to System.currentTimeMillis())
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun pushUsage(date: String, totalMs: Long, appMs: Map<String, Long>) {
|
||||
val s = settingsRepository.settingsFlow.first()
|
||||
val ref = deviceDoc(s) ?: return
|
||||
write(
|
||||
ref.collection("usage").document(date),
|
||||
mapOf("date" to date, "totalMs" to totalMs, "apps" to appMs, "at" to System.currentTimeMillis())
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun pushEvent(type: String, detail: String) {
|
||||
val s = settingsRepository.settingsFlow.first()
|
||||
val ref = deviceDoc(s) ?: return
|
||||
if (gateway.ensureSignedIn() == null) return
|
||||
// Attente bornée de l'accusé du serveur : appelé depuis un récepteur (goAsync), le processus
|
||||
// peut être arrêté juste après ; hors ligne, l'écriture reste en file dans le SDK.
|
||||
runCatching {
|
||||
withTimeoutOrNull(EVENT_TIMEOUT_MS) {
|
||||
ref.collection("events").add(
|
||||
mapOf("type" to type, "detail" to detail, "at" to System.currentTimeMillis())
|
||||
).await()
|
||||
}
|
||||
private suspend fun call(block: (FamilyApi) -> Unit): Boolean = withContext(Dispatchers.IO) {
|
||||
if (!tunnel.up.value) return@withContext false
|
||||
val api = api() ?: return@withContext false
|
||||
try {
|
||||
block(api)
|
||||
true
|
||||
} catch (e: ApiException) {
|
||||
AppLog.w("Liaison", "Envoi au serveur impossible : ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun write(ref: DocumentReference, data: Map<String, Any?>) {
|
||||
if (gateway.ensureSignedIn() == null) return
|
||||
// Les écritures sont mises en file par le SDK hors-ligne : pas d'attente bloquante du réseau.
|
||||
ref.set(data, SetOptions.merge())
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "RemoteSync"
|
||||
private const val EVENT_TIMEOUT_MS = 8_000L
|
||||
private companion object {
|
||||
const val SYNC_INTERVAL_MS = 30_000L
|
||||
const val EVENT_TIMEOUT_MS = 8_000L
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,8 @@ import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.Color
|
||||
import android.os.SystemClock
|
||||
import androidx.core.app.NotificationCompat
|
||||
@@ -16,6 +14,7 @@ import androidx.lifecycle.LifecycleService
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.jttools.datacontrol.MainActivity
|
||||
import com.jttools.datacontrol.R
|
||||
import com.jttools.datacontrol.admin.OwnerPolicy
|
||||
import com.jttools.datacontrol.data.AppSettings
|
||||
import com.jttools.datacontrol.data.DataState
|
||||
import com.jttools.datacontrol.data.SettingsRepository
|
||||
@@ -24,7 +23,10 @@ import com.jttools.datacontrol.guard.GuardEngine
|
||||
import com.jttools.datacontrol.log.AppLog
|
||||
import com.jttools.datacontrol.remote.DeviceReporter
|
||||
import com.jttools.datacontrol.remote.RemoteSync
|
||||
import com.jttools.datacontrol.vpn.TunnelManager
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDate
|
||||
@@ -32,15 +34,12 @@ import javax.inject.Inject
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* Service de premier plan qui surveille en continu la consommation de données mobiles,
|
||||
* pilote le pare-feu [FirewallVpnService] et met à jour la notification permanente.
|
||||
* Service de premier plan : garde le tunnel WireGuard vers le serveur familyd monté tant que le
|
||||
* contrôle est activé (le serveur décide de ce qui passe), suit la consommation de données mobiles
|
||||
* et met à jour la notification permanente.
|
||||
*
|
||||
* Règles appliquées (voir README) :
|
||||
* - Sur Wi-Fi : aucun filtrage par défaut (le pare-feu est arrêté), sauf si le filtrage Wi-Fi
|
||||
* est explicitement activé (Liste blanche Wi-Fi).
|
||||
* - Sur données mobiles : liste blanche active en permanence (seules les apps cochées ont accès),
|
||||
* ET une coupure totale cyclique (même liste blanche) se déclenche automatiquement toutes les
|
||||
* X minutes pendant une courte pulsation, que le forfait soit dépassé ou non.
|
||||
* Si le tunnel tombe (VPN retiré, autre VPN, réseau), le contrôle reste ACTIVÉ : la notification
|
||||
* passe en rouge, le parent est prévenu, et le tunnel est remonté dès que possible.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class DataMonitorService : LifecycleService() {
|
||||
@@ -52,80 +51,54 @@ class DataMonitorService : LifecycleService() {
|
||||
@Inject lateinit var deviceReporter: DeviceReporter
|
||||
@Inject lateinit var remoteSync: RemoteSync
|
||||
@Inject lateinit var guardEngine: GuardEngine
|
||||
@Inject lateinit var tunnel: TunnelManager
|
||||
|
||||
private var latestSettings: AppSettings = AppSettings()
|
||||
private var latestTransport: NetworkTransport = NetworkTransport.NONE
|
||||
/**
|
||||
* Instant (temps monotone) du dernier retrait du VPN. Le contrôle reste ACTIVÉ : retirer le VPN
|
||||
* ne doit pas suffire à lever la protection sans mot de passe. On laisse un délai avant de
|
||||
* remonter le tunnel, pour ne pas se battre en boucle avec un autre VPN.
|
||||
*/
|
||||
private var vpnRevokedAt: Long? = null
|
||||
private val vpnRevoked: Boolean
|
||||
get() = vpnRevokedAt?.let { SystemClock.elapsedRealtime() - it < VPN_RETRY_AFTER_REVOKE_MS } ?: false
|
||||
|
||||
private var lastAppliedMode: VpnMode? = null
|
||||
private var lastAppliedWhitelist: Set<String> = 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 var lastTunnelAttempt = 0L
|
||||
private var tunnelJob: Job? = null
|
||||
private var lastDiagnostic = ""
|
||||
|
||||
private val vpnRevokedReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
vpnRevokedAt = SystemClock.elapsedRealtime()
|
||||
AppLog.w("Pare-feu", "VPN retiré : contrôle maintenu, nouvelle tentative dans ${VPN_RETRY_AFTER_REVOKE_MS / 1000} s")
|
||||
lifecycleScope.launch {
|
||||
remoteSync.pushEvent("vpn_revoked", "Autorisation VPN retirée : données mobiles non filtrées tant qu'elle n'est pas rétablie")
|
||||
}
|
||||
tick()
|
||||
}
|
||||
}
|
||||
|
||||
private enum class VpnMode { FILTER, BLOCK_ALL, STOPPED }
|
||||
/** Dernière poignée de main récente avec le serveur (temps monotone), pour le garde-fou du mode propriétaire. */
|
||||
private var serverSeenAt = SystemClock.elapsedRealtime()
|
||||
private var lockdownRelaxed = false
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
// START_STICKY : si le système tue ce service (mémoire faible), il le relance
|
||||
// automatiquement (avec un Intent null) dès que les ressources redeviennent disponibles.
|
||||
// START_STICKY : si le système tue ce service, il le relance dès que possible (Intent null).
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ContextCompat.registerReceiver(
|
||||
this, vpnRevokedReceiver, IntentFilter(FirewallVpnService.ACTION_VPN_REVOKED),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED
|
||||
)
|
||||
startForeground(NOTIF_ID, buildNotification(stateHolder.snapshot.value))
|
||||
AppLog.i("Service", "DataMonitorService démarré")
|
||||
|
||||
lifecycleScope.launch {
|
||||
settingsRepository.settingsFlow.collect {
|
||||
// Ne recopier l'état persisté du minuteur de coupure qu'à la toute première valeur
|
||||
// réelle lue en base — pas question d'écraser avec les valeurs par défaut si un
|
||||
// tick() déclenché par une autre coroutine s'exécute avant cette première émission.
|
||||
if (!settingsLoaded) {
|
||||
cutoffCycleAnchorMillis = it.cutoffCycleAnchorMillis
|
||||
inPulse = it.cutoffInPulse
|
||||
pulseStartMillis = it.cutoffPulseStartMillis
|
||||
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é"
|
||||
if (it.controlEnabled) "Contrôle activé" else "Contrôle désactivé sur le téléphone"
|
||||
)
|
||||
}
|
||||
}
|
||||
lastControlEnabled = it.controlEnabled
|
||||
if (!it.controlEnabled) vpnRevokedAt = null
|
||||
tick()
|
||||
}
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
var wasUp = false
|
||||
tunnel.up.collect { up ->
|
||||
val s = latestSettings
|
||||
if (wasUp && !up && s.controlEnabled && s.isPaired) {
|
||||
// Retrait du VPN par l'utilisateur ou un autre VPN : le contrôle reste actif.
|
||||
lastTunnelAttempt = SystemClock.elapsedRealtime()
|
||||
remoteSync.pushEvent("vpn_revoked", "Tunnel coupé sur le téléphone : trafic non filtré tant qu'il n'est pas rétabli")
|
||||
}
|
||||
wasUp = up
|
||||
tick()
|
||||
}
|
||||
}
|
||||
@@ -140,7 +113,7 @@ class DataMonitorService : LifecycleService() {
|
||||
lifecycleScope.launch {
|
||||
while (isActive) {
|
||||
tick()
|
||||
kotlinx.coroutines.delay(TICK_INTERVAL_MS)
|
||||
delay(TICK_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,9 +121,7 @@ class DataMonitorService : LifecycleService() {
|
||||
private fun tick() {
|
||||
val settings = latestSettings
|
||||
|
||||
// Le calcul de la consommation doit rester à jour même quand le contrôle est désactivé
|
||||
// (ex: pour refléter immédiatement une valeur définie manuellement dans Réglages), il ne
|
||||
// doit donc pas dépendre de settings.controlEnabled.
|
||||
// Consommation de données mobiles (compteur local, pour le tableau de bord et le forfait affiché).
|
||||
val consumedBytes = usageRepository.consumedBytesSince(settings.baselineMobileBytesAtReset)
|
||||
if (usageRepository.hasCounterReset(settings.baselineMobileBytesAtReset)) {
|
||||
lifecycleScope.launch { settingsRepository.rebaseline(usageRepository.totalMobileBytes()) }
|
||||
@@ -159,97 +130,33 @@ class DataMonitorService : LifecycleService() {
|
||||
val overageMo = max(0L, consumedMo - settings.planLimitMo)
|
||||
val estimatedTotal = settings.planPriceEuro + overageMo * settings.overagePricePerMoEuro
|
||||
val percent = if (settings.planLimitMo > 0) consumedMo.toFloat() / settings.planLimitMo else 0f
|
||||
|
||||
if (!settings.controlEnabled) {
|
||||
applyVpnMode(VpnMode.STOPPED, emptySet())
|
||||
stateHolder.update {
|
||||
it.copy(
|
||||
consumedMo = consumedMo,
|
||||
planLimitMo = settings.planLimitMo,
|
||||
overageMo = overageMo,
|
||||
estimatedTotalPriceEuro = estimatedTotal,
|
||||
dataState = if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK,
|
||||
nextAutoResetDate = settings.nextAutoResetDate(),
|
||||
serviceRunning = false,
|
||||
firewallFault = false,
|
||||
transport = latestTransport
|
||||
)
|
||||
if (settings.autoResetEnabled && !LocalDate.now().isBefore(settings.nextAutoResetDate())) {
|
||||
lifecycleScope.launch {
|
||||
settingsRepository.resetPlan(usageRepository.totalMobileBytes(), settings.nextAutoResetDate())
|
||||
}
|
||||
updateNotification(stateHolder.snapshot.value)
|
||||
return
|
||||
}
|
||||
|
||||
maybeAutoReset(settings)
|
||||
|
||||
val dataState: DataState
|
||||
var nextCutoffAt: Long? = null
|
||||
var firewallFault = false
|
||||
|
||||
// Tunnel : monté si contrôle activé + appareil associé ; sinon arrêté.
|
||||
val wanted = settings.controlEnabled && settings.isPaired
|
||||
val up = tunnel.up.value
|
||||
when {
|
||||
latestTransport == NetworkTransport.WIFI -> {
|
||||
if (settings.wifiFilterEnabled && !vpnRevoked && FirewallVpnService.isPrepared(this)) {
|
||||
applyVpnMode(VpnMode.FILTER, settings.whitelistedPackagesWifi)
|
||||
} else {
|
||||
applyVpnMode(VpnMode.STOPPED, emptySet())
|
||||
}
|
||||
resetCutoffCycle()
|
||||
dataState = DataState.WIFI
|
||||
}
|
||||
latestTransport == NetworkTransport.CELLULAR && !vpnRevoked && FirewallVpnService.isPrepared(this) -> {
|
||||
if (guardEngine.cutsMobileNow()) {
|
||||
// Hors plage horaire autorisée : couper les données mobiles (règle du parent).
|
||||
applyVpnMode(VpnMode.BLOCK_ALL, emptySet())
|
||||
resetCutoffCycle()
|
||||
firewallFault = !FirewallVpnService.tunnelActive &&
|
||||
System.currentTimeMillis() - lastApplyMillis > TUNNEL_RETRY_DELAY_MS
|
||||
dataState = DataState.CUT
|
||||
} else {
|
||||
// La coupure cyclique tourne en continu sur données mobiles, indépendamment du
|
||||
// dépassement du forfait : elle ne doit pas attendre le hors-forfait pour se déclencher.
|
||||
val now = System.currentTimeMillis()
|
||||
val intervalMs = settings.cutoffIntervalMinutes * 60_000L
|
||||
if (cutoffCycleAnchorMillis == null) {
|
||||
cutoffCycleAnchorMillis = now
|
||||
persistCutoffState()
|
||||
}
|
||||
val anchor = cutoffCycleAnchorMillis!!
|
||||
|
||||
if (inPulse) {
|
||||
if (now - pulseStartMillis >= PULSE_DURATION_MS) {
|
||||
inPulse = false
|
||||
cutoffCycleAnchorMillis = now
|
||||
persistCutoffState()
|
||||
applyVpnMode(VpnMode.FILTER, settings.whitelistedPackages)
|
||||
} else {
|
||||
applyVpnMode(VpnMode.BLOCK_ALL, emptySet())
|
||||
}
|
||||
} else if (now - anchor >= intervalMs) {
|
||||
inPulse = true
|
||||
pulseStartMillis = now
|
||||
persistCutoffState()
|
||||
applyVpnMode(VpnMode.BLOCK_ALL, emptySet())
|
||||
} else {
|
||||
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
|
||||
wanted && !up -> maybeBringUp(settings)
|
||||
!wanted && up && tunnelJob?.isActive != true -> {
|
||||
tunnelJob = lifecycleScope.launch { tunnel.down() }
|
||||
}
|
||||
}
|
||||
val fault = wanted && !up
|
||||
applyOwnerPolicy(wanted, up)
|
||||
|
||||
logDiagnostic(settings)
|
||||
val dataState = when {
|
||||
!settings.controlEnabled -> if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK
|
||||
settings.serverMode != "open" -> DataState.CUT
|
||||
latestTransport == NetworkTransport.WIFI -> DataState.WIFI
|
||||
percent >= WARNING_THRESHOLD -> DataState.WARNING
|
||||
else -> DataState.OK
|
||||
}
|
||||
|
||||
logDiagnostic(settings, up)
|
||||
stateHolder.update {
|
||||
it.copy(
|
||||
consumedMo = consumedMo,
|
||||
@@ -258,83 +165,75 @@ class DataMonitorService : LifecycleService() {
|
||||
estimatedTotalPriceEuro = estimatedTotal,
|
||||
dataState = dataState,
|
||||
nextAutoResetDate = settings.nextAutoResetDate(),
|
||||
nextCutoffAtMillis = nextCutoffAt,
|
||||
serviceRunning = true,
|
||||
firewallFault = firewallFault,
|
||||
transport = latestTransport
|
||||
serviceRunning = settings.controlEnabled,
|
||||
firewallFault = fault,
|
||||
transport = latestTransport,
|
||||
paired = settings.isPaired,
|
||||
tunnelUp = up,
|
||||
lastHandshakeMs = if (up) tunnel.lastHandshakeMillis() else null,
|
||||
serverMode = settings.serverMode,
|
||||
serverModeReason = settings.serverModeReason,
|
||||
tunnelError = tunnel.lastError
|
||||
)
|
||||
}
|
||||
updateNotification(stateHolder.snapshot.value)
|
||||
}
|
||||
|
||||
/** Écrit dans le journal POURQUOI le pare-feu est (ou n'est pas) actif, à chaque changement de situation. */
|
||||
private fun logDiagnostic(settings: AppSettings) {
|
||||
val prepared = FirewallVpnService.isPrepared(this)
|
||||
/**
|
||||
* Mode propriétaire : VPN toujours actif et réglages verrouillés tant que la protection est voulue.
|
||||
* Garde-fou : sans poignée de main avec le serveur depuis [SERVER_LOST_MS], le blocage hors VPN est
|
||||
* levé (l'enfant garde Internet si le serveur tombe) ; il revient dès que le serveur répond.
|
||||
*/
|
||||
private fun applyOwnerPolicy(wanted: Boolean, up: Boolean) {
|
||||
if (!OwnerPolicy.isDeviceOwner(this)) return
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val hs = if (up) tunnel.lastHandshakeMillis() else null
|
||||
if (hs != null && System.currentTimeMillis() - hs < HANDSHAKE_FRESH_MS) serverSeenAt = now
|
||||
val serverLost = now - serverSeenAt > SERVER_LOST_MS
|
||||
if (wanted && serverLost != lockdownRelaxed) {
|
||||
lockdownRelaxed = serverLost
|
||||
lifecycleScope.launch {
|
||||
if (serverLost) remoteSync.pushEvent("lockdown_relaxed", "Serveur injoignable depuis 15 min : blocage hors VPN levé")
|
||||
else remoteSync.pushEvent("lockdown_restored", "Serveur de nouveau joignable : blocage hors VPN rétabli")
|
||||
}
|
||||
}
|
||||
OwnerPolicy.sync(this, OwnerPolicy.Target(protect = wanted, lockdown = wanted && !serverLost))
|
||||
}
|
||||
|
||||
/** Monte le tunnel, au plus une tentative toutes les [TUNNEL_RETRY_MS] (évite de boucler contre un autre VPN). */
|
||||
private fun maybeBringUp(settings: AppSettings) {
|
||||
val link = settings.link ?: return
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (tunnelJob?.isActive == true || (lastTunnelAttempt != 0L && now - lastTunnelAttempt < TUNNEL_RETRY_MS)) return
|
||||
if (!TunnelManager.isPrepared(this)) return // autorisation VPN à redonner à l'ouverture de l'appli
|
||||
lastTunnelAttempt = now
|
||||
tunnelJob = lifecycleScope.launch {
|
||||
val key = settingsRepository.wgPrivateKey()
|
||||
if (key == null) {
|
||||
AppLog.e("Tunnel", "Clé WireGuard illisible (Keystore réinitialisé ?) : associer de nouveau le téléphone")
|
||||
return@launch
|
||||
}
|
||||
if (tunnel.ensureUp(link, key)) remoteSync.syncOnce()
|
||||
tick()
|
||||
}
|
||||
}
|
||||
|
||||
/** Écrit dans le journal POURQUOI le tunnel est (ou n'est pas) monté, à chaque changement de situation. */
|
||||
private fun logDiagnostic(settings: AppSettings, up: Boolean) {
|
||||
val prepared = TunnelManager.isPrepared(this)
|
||||
val expected = when {
|
||||
!settings.isPaired -> "appareil non associé au serveur"
|
||||
!settings.controlEnabled -> "contrôle désactivé"
|
||||
latestTransport == NetworkTransport.WIFI ->
|
||||
if (settings.wifiFilterEnabled && prepared) "Wi-Fi filtré" else "Wi-Fi : pas de filtrage (normal, le VPN ne se monte que sur données mobiles)"
|
||||
latestTransport == NetworkTransport.CELLULAR ->
|
||||
if (!prepared) "données mobiles mais autorisation VPN absente" else if (vpnRevoked) "données mobiles mais VPN révoqué" else "données mobiles : pare-feu attendu"
|
||||
else -> "aucun réseau actif (${latestTransport})"
|
||||
!prepared -> "autorisation VPN absente"
|
||||
up -> "tunnel monté, filtrage par le serveur (mode ${settings.serverMode})"
|
||||
else -> "tunnel attendu mais non monté${tunnel.lastError?.let { " : $it" } ?: ""}"
|
||||
}
|
||||
val guard = com.jttools.datacontrol.guard.AppGuardService.isEnabled(this)
|
||||
val diag = "réseau=$latestTransport contrôle=${settings.controlEnabled} vpnAutorisé=$prepared " +
|
||||
"tunnelMonté=${FirewallVpnService.tunnelActive} accessibilité=$guard révoqué=$vpnRevoked " +
|
||||
"mode=${lastAppliedMode ?: "—"} → $expected"
|
||||
val diag = "réseau=$latestTransport contrôle=${settings.controlEnabled} associé=${settings.isPaired} " +
|
||||
"vpnAutorisé=$prepared tunnel=$up accessibilité=$guard → $expected"
|
||||
if (diag != lastDiagnostic) {
|
||||
lastDiagnostic = diag
|
||||
AppLog.i("Pare-feu", diag)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetCutoffCycle() {
|
||||
if (cutoffCycleAnchorMillis == null && !inPulse) return
|
||||
cutoffCycleAnchorMillis = null
|
||||
inPulse = false
|
||||
persistCutoffState()
|
||||
}
|
||||
|
||||
private fun persistCutoffState() {
|
||||
val anchor = cutoffCycleAnchorMillis
|
||||
val pulse = inPulse
|
||||
val pulseStart = pulseStartMillis
|
||||
lifecycleScope.launch { settingsRepository.setCutoffCycleState(anchor, pulse, pulseStart) }
|
||||
}
|
||||
|
||||
private fun maybeAutoReset(settings: AppSettings) {
|
||||
if (settings.autoResetEnabled && !LocalDate.now().isBefore(settings.nextAutoResetDate())) {
|
||||
lifecycleScope.launch {
|
||||
settingsRepository.resetPlan(usageRepository.totalMobileBytes(), settings.nextAutoResetDate())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyVpnMode(mode: VpnMode, whitelist: Set<String>) {
|
||||
// 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()
|
||||
AppLog.i("Pare-feu", "→ mode $mode (${whitelist.size} app(s) autorisée(s))")
|
||||
stateHolder.update { it.copy(appliedMode = mode.name, effectiveWhitelist = whitelist) }
|
||||
// Une exception ici (démarrage de service de premier plan refusé, par ex.) ne doit ni faire
|
||||
// planter l'application ni rester invisible : elle est tracée dans le journal.
|
||||
try {
|
||||
when (mode) {
|
||||
VpnMode.STOPPED -> ContextCompat.startForegroundService(this, FirewallVpnService.stopIntent(this))
|
||||
VpnMode.FILTER -> ContextCompat.startForegroundService(
|
||||
this, FirewallVpnService.startFilterIntent(this, whitelist)
|
||||
)
|
||||
VpnMode.BLOCK_ALL -> ContextCompat.startForegroundService(
|
||||
this, FirewallVpnService.startBlockAllIntent(this)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AppLog.e("Pare-feu", "Impossible de démarrer FirewallVpnService pour le mode $mode", e)
|
||||
AppLog.i("Tunnel", diag)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,13 +246,18 @@ class DataMonitorService : LifecycleService() {
|
||||
).apply { description = getString(R.string.notification_channel_description) }
|
||||
nm.createNotificationChannel(channel)
|
||||
|
||||
val modeLabel = when (snapshot.serverMode) {
|
||||
"cut" -> getString(R.string.mode_cut)
|
||||
"allowlist" -> getString(R.string.mode_allowlist)
|
||||
else -> getString(R.string.mode_open)
|
||||
}
|
||||
val (emoji, stateLabel, color) = when {
|
||||
snapshot.firewallFault -> Triple("⚠️", getString(R.string.dashboard_state_warning), Color.parseColor("#C62828"))
|
||||
snapshot.firewallFault -> Triple("⚠️", getString(R.string.dashboard_tunnel_down), 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.CUT -> Triple("🔴", modeLabel, Color.parseColor("#C62828"))
|
||||
snapshot.dataState == DataState.WARNING -> Triple("🟠", getString(R.string.dashboard_state_warning), Color.parseColor("#EF6C00"))
|
||||
snapshot.dataState == DataState.CUT -> Triple("🔴", getString(R.string.dashboard_state_cut), Color.parseColor("#C62828"))
|
||||
else -> Triple("🔵", getString(R.string.dashboard_state_wifi), Color.parseColor("#1565C0"))
|
||||
snapshot.dataState == DataState.WIFI -> Triple("🔵", getString(R.string.dashboard_state_wifi), Color.parseColor("#1565C0"))
|
||||
else -> Triple("🟢", getString(R.string.dashboard_state_ok), Color.parseColor("#2E7D32"))
|
||||
}
|
||||
|
||||
val percentText = if (snapshot.planLimitMo > 0) {
|
||||
@@ -361,12 +265,11 @@ class DataMonitorService : LifecycleService() {
|
||||
} else "—"
|
||||
val priceText = String.format(java.util.Locale.FRENCH, "%.2f", snapshot.estimatedTotalPriceEuro)
|
||||
|
||||
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 usageText = "$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 €"
|
||||
val bigText = "$usageText\n$costText"
|
||||
val reason = snapshot.serverModeReason.takeIf { it.isNotBlank() && snapshot.serverMode != "open" }
|
||||
val bigText = listOfNotNull(usageText, reason?.let { "Raison : $it" }, costText).joinToString("\n")
|
||||
|
||||
val openApp = PendingIntent.getActivity(
|
||||
this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE
|
||||
@@ -391,19 +294,15 @@ class DataMonitorService : LifecycleService() {
|
||||
nm.notify(NOTIF_ID, buildNotification(snapshot))
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
runCatching { unregisterReceiver(vpnRevokedReceiver) }
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "datacontrol_monitor"
|
||||
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 VPN_RETRY_AFTER_REVOKE_MS = 30_000L
|
||||
private const val TUNNEL_RETRY_MS = 30_000L
|
||||
private const val WARNING_THRESHOLD = 0.8f
|
||||
/** WireGuard renouvelle la poignée de main toutes les 2 min tant que du trafic passe (keepalive compris). */
|
||||
private const val HANDSHAKE_FRESH_MS = 3 * 60_000L
|
||||
private const val SERVER_LOST_MS = 15 * 60_000L
|
||||
|
||||
/**
|
||||
* Démarre le service. Renvoie faux si Android refuse (Android 12+ interdit de démarrer un service
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
package com.jttools.datacontrol.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.VpnService
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.jttools.datacontrol.MainActivity
|
||||
import com.jttools.datacontrol.R
|
||||
import com.jttools.datacontrol.log.AppLog
|
||||
import java.io.FileInputStream
|
||||
|
||||
/**
|
||||
* Pare-feu local basé sur [VpnService], sans root.
|
||||
*
|
||||
* Ce service ne route ni ne transmet aucun paquet : les applications "autorisées" sont
|
||||
* exclues du tunnel via [Builder.addDisallowedApplication] (leur trafic continue de
|
||||
* transiter normalement par la vraie interface réseau). Toutes les autres applications
|
||||
* voient leur trafic capturé dans le tunnel VPN puis silencieusement ignoré ("trou noir"),
|
||||
* ce qui coupe leur accès Internet sans jamais toucher au Wi-Fi : ce service n'est démarré
|
||||
* par [DataMonitorService] que lorsque le réseau actif est la donnée mobile.
|
||||
*/
|
||||
class FirewallVpnService : VpnService() {
|
||||
|
||||
private var tunInterface: ParcelFileDescriptor? = null
|
||||
private var readerThread: Thread? = null
|
||||
@Volatile private var running = false
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
// Le service est toujours lancé via startForegroundService() (y compris pour ACTION_STOP,
|
||||
// cf. DataMonitorService.applyVpnMode) : le système impose un appel à startForeground()
|
||||
// dans les 5 secondes qui suivent, sous peine de crash (ForegroundServiceDidNotStartInTimeException
|
||||
// sur Android 12+), même si le service s'arrête immédiatement après.
|
||||
try {
|
||||
startForeground(NOTIF_ID, buildNotification())
|
||||
} catch (e: Exception) {
|
||||
AppLog.e(TAG, "startForeground() refusé", e)
|
||||
}
|
||||
AppLog.i(TAG, "onStartCommand action=${intent?.action?.substringAfterLast('.') ?: "null (relance système)"}")
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> {
|
||||
AppLog.i(TAG, "Arrêt du tunnel demandé")
|
||||
stopTunnel()
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
ACTION_START_FILTER, ACTION_START_BLOCK_ALL -> {
|
||||
val allowed = intent.getStringArrayListExtra(EXTRA_ALLOWED_PACKAGES)?.toSet() ?: emptySet()
|
||||
establishTunnel(allowed)
|
||||
}
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun establishTunnel(allowedPackages: Set<String>) {
|
||||
stopTunnel(keepForeground = true)
|
||||
AppLog.i(TAG, "Établissement du tunnel : ${allowedPackages.size} application(s) exclue(s) du filtrage")
|
||||
|
||||
val builder = Builder()
|
||||
.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.
|
||||
addDisallowed(builder, packageName)
|
||||
allowedPackages.forEach { addDisallowed(builder, it) }
|
||||
|
||||
tunInterface = runCatching { builder.establish() }.getOrElse {
|
||||
Log.e(TAG, "Échec établissement du tunnel VPN", it)
|
||||
AppLog.e(TAG, "establish() a échoué", it)
|
||||
null
|
||||
}
|
||||
|
||||
val fd = tunInterface ?: run {
|
||||
AppLog.e(TAG, "Tunnel NON monté : establish() a renvoyé null (autorisation VPN retirée ou autre VPN prioritaire)")
|
||||
return
|
||||
}
|
||||
AppLog.i(TAG, "Tunnel VPN monté (l'icône clé doit apparaître)")
|
||||
running = true
|
||||
tunnelActive = true
|
||||
readerThread = Thread({ blackhole(fd) }, "datacontrol-vpn-blackhole").apply {
|
||||
isDaemon = true
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun addDisallowed(builder: Builder, packageName: String) {
|
||||
try {
|
||||
builder.addDisallowedApplication(packageName)
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
// Application désinstallée entre-temps : rien à exclure.
|
||||
}
|
||||
}
|
||||
|
||||
/** Lit et jette tout paquet appartenant aux applications non-autorisées : leur trafic n'aboutit jamais. */
|
||||
private fun blackhole(fd: ParcelFileDescriptor) {
|
||||
val input = FileInputStream(fd.fileDescriptor)
|
||||
val buffer = ByteArray(32_767)
|
||||
try {
|
||||
while (running) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
}
|
||||
} catch (_ : Exception) {
|
||||
// Interface fermée depuis stopTunnel() : fin normale du thread.
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopTunnel(keepForeground: Boolean = false) {
|
||||
running = false
|
||||
tunnelActive = false
|
||||
readerThread?.interrupt()
|
||||
readerThread = null
|
||||
tunInterface?.let { runCatching { it.close() } }
|
||||
tunInterface = null
|
||||
if (!keepForeground) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRevoke() {
|
||||
AppLog.w(TAG, "onRevoke : le système ou un autre VPN a repris la main")
|
||||
stopTunnel()
|
||||
sendBroadcast(Intent(ACTION_VPN_REVOKED).setPackage(packageName))
|
||||
super.onRevoke()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stopTunnel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
val nm = getSystemService(NotificationManager::class.java)
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
getString(R.string.notification_channel_name),
|
||||
NotificationManager.IMPORTANCE_MIN
|
||||
)
|
||||
nm.createNotificationChannel(channel)
|
||||
val openApp = PendingIntent.getActivity(
|
||||
this, 0, Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.notification_title))
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setContentIntent(openApp)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FirewallVpnService"
|
||||
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"
|
||||
const val ACTION_STOP = "com.jttools.datacontrol.action.VPN_STOP"
|
||||
const val ACTION_VPN_REVOKED = "com.jttools.datacontrol.action.VPN_REVOKED"
|
||||
const val EXTRA_ALLOWED_PACKAGES = "allowed_packages"
|
||||
|
||||
fun isPrepared(context: Context): Boolean = prepare(context) == null
|
||||
|
||||
fun startFilterIntent(context: Context, allowedPackages: Collection<String>): Intent =
|
||||
Intent(context, FirewallVpnService::class.java)
|
||||
.setAction(ACTION_START_FILTER)
|
||||
.putStringArrayListExtra(EXTRA_ALLOWED_PACKAGES, ArrayList(allowedPackages))
|
||||
|
||||
fun startBlockAllIntent(context: Context): Intent =
|
||||
Intent(context, FirewallVpnService::class.java)
|
||||
.setAction(ACTION_START_BLOCK_ALL)
|
||||
.putStringArrayListExtra(EXTRA_ALLOWED_PACKAGES, ArrayList())
|
||||
|
||||
fun stopIntent(context: Context): Intent =
|
||||
Intent(context, FirewallVpnService::class.java).setAction(ACTION_STOP)
|
||||
}
|
||||
}
|
||||
@@ -14,14 +14,19 @@ data class MonitorSnapshot(
|
||||
val estimatedTotalPriceEuro: Double = 0.0,
|
||||
val dataState: DataState = DataState.OK,
|
||||
val nextAutoResetDate: LocalDate = LocalDate.now(),
|
||||
val nextCutoffAtMillis: Long? = null,
|
||||
/** Contrôle activé (tunnel attendu). */
|
||||
val serviceRunning: Boolean = false,
|
||||
/** Contrôle activé sur données mobiles mais tunnel VPN non monté : les données ne sont PAS filtrées. */
|
||||
/** Contrôle activé et appareil associé, mais tunnel non monté : le trafic n'est PAS filtré. */
|
||||
val firewallFault: Boolean = false,
|
||||
val transport: NetworkTransport = NetworkTransport.NONE,
|
||||
/** Mode réellement appliqué au pare-feu : FILTER, BLOCK_ALL ou STOPPED. */
|
||||
val appliedMode: String = "STOPPED",
|
||||
val effectiveWhitelist: Set<String> = emptySet()
|
||||
val paired: Boolean = false,
|
||||
val tunnelUp: Boolean = false,
|
||||
/** Dernière poignée de main avec le serveur (ms), null si aucune. */
|
||||
val lastHandshakeMs: Long? = null,
|
||||
/** Mode appliqué par le serveur : open, allowlist ou cut. */
|
||||
val serverMode: String = "open",
|
||||
val serverModeReason: String = "",
|
||||
val tunnelError: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,7 +13,7 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.jttools.datacontrol.service.FirewallVpnService
|
||||
import com.jttools.datacontrol.vpn.TunnelManager
|
||||
|
||||
/**
|
||||
* Regroupe les autorisations système nécessaires (VPN, notifications, batterie) pour que
|
||||
@@ -88,7 +88,7 @@ class SystemActions(private val activity: ComponentActivity) {
|
||||
|
||||
fun deactivateAdmin() = com.jttools.datacontrol.admin.AdminController.deactivate(activity)
|
||||
|
||||
fun isVpnPrepared(): Boolean = FirewallVpnService.isPrepared(activity)
|
||||
fun isVpnPrepared(): Boolean = TunnelManager.isPrepared(activity)
|
||||
|
||||
fun requestVpnPermission(onResult: (Boolean) -> Unit) {
|
||||
val intent = VpnService.prepare(activity)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.jttools.datacontrol.ui.about
|
||||
|
||||
/**
|
||||
* Contenu des sous-onglets de « À propos ». Chaque affirmation a été relevée dans le code, le README
|
||||
* ou le CHANGELOG du dépôt (voir le compte rendu de la refonte) : ne rien ajouter ici qui ne s'y vérifie pas.
|
||||
* Contenu des sous-onglets de « À propos ». Chaque affirmation doit se vérifier dans le code, le README
|
||||
* ou le CHANGELOG du dépôt : ne rien ajouter ici qui ne s'y vérifie pas.
|
||||
* Le journal, lui, n'est pas dans ce fichier : il est lu depuis CHANGELOG.md (cf. ChangelogParser).
|
||||
*/
|
||||
data class AboutEntry(val title: String, val body: String)
|
||||
@@ -11,105 +11,104 @@ data class AboutSection(val heading: String?, val entries: List<AboutEntry>)
|
||||
object AboutContent {
|
||||
|
||||
val features = listOf(
|
||||
AboutSection("Protection par le serveur", listOf(
|
||||
AboutEntry("Tunnel WireGuard permanent", "Une fois le téléphone associé et la protection activée, tout son trafic (Wi-Fi comme données mobiles) passe par le serveur du parent, qui le filtre."),
|
||||
AboutEntry("Trois modes décidés par le parent", "Accès libre, liste blanche seulement (par exemple Pronote), ou Internet coupé — à la main, ou selon des plages horaires, un forfait de données mobiles ou une coupure cyclique."),
|
||||
AboutEntry("Services bloqués", "Le parent bloque des services (réseaux sociaux, jeux, vidéo…) par le DNS filtrant du serveur."),
|
||||
AboutEntry("Notification permanente", "Mode en cours et sa raison, consommation et total estimé. « PROTECTION INACTIVE » s'affiche si le tunnel est coupé.")
|
||||
)),
|
||||
AboutSection("Applications et temps d'écran", listOf(
|
||||
AboutEntry("Plages horaires, temps d'écran, applications interdites", "Appliqués sur le téléphone par un service d'accessibilité : verrouillage hors plage, limite quotidienne globale ou par application, ajout de temps, lancement refusé."),
|
||||
AboutEntry("Date et heure", "Changer à la main la date, l'heure ou le fuseau ne contourne pas les plages horaires ni le temps d'écran : le changement est ignoré et signalé au parent.")
|
||||
)),
|
||||
AboutSection("Données mobiles", listOf(
|
||||
AboutEntry("Suivi de la consommation", "Mesure les Mo consommés depuis le début du cycle, sans permission spéciale (compteur système TrafficStats)."),
|
||||
AboutEntry("Forfait et coût estimé", "Volume, prix, prix par Mo hors-forfait et date de début se règlent ; le total estimé se met à jour en direct, notification comprise."),
|
||||
AboutEntry("Réinitialisation", "Automatique chaque mois à la date anniversaire, ou manuelle depuis le tableau de bord. La consommation peut aussi être recalée à la main sur le décompte de l'opérateur."),
|
||||
AboutEntry("Liste blanche", "Sur données mobiles, seules les applications cochées ont accès à Internet."),
|
||||
AboutEntry("Filtrage Wi-Fi optionnel", "Le Wi-Fi n'est jamais filtré par défaut ; une liste blanche Wi-Fi distincte peut être activée."),
|
||||
AboutEntry("Coupure cyclique", "Toutes les X minutes (réglable), une coupure totale de 30 secondes s'applique aux données mobiles, liste blanche comprise."),
|
||||
AboutEntry("Notification permanente", "Consommation, état en couleur et total estimé. Une alerte « PARE-FEU INACTIF » s'affiche si les données mobiles ne sont plus filtrées."),
|
||||
AboutEntry("Reprise automatique", "Le suivi redémarre après un redémarrage de l'appareil et est relancé périodiquement si le système l'a arrêté.")
|
||||
AboutEntry("Forfait et coût estimé", "Volume, prix, prix par Mo hors-forfait et date de début se règlent ; le total estimé se met à jour en direct."),
|
||||
AboutEntry("Réinitialisation", "Automatique chaque mois à la date anniversaire, ou manuelle depuis le tableau de bord.")
|
||||
)),
|
||||
AboutSection("Protection", listOf(
|
||||
AboutEntry("Mot de passe de verrouillage", "Protège les Réglages, la Liste blanche, l'effacement du journal et la désactivation du contrôle."),
|
||||
AboutEntry("Date et heure", "Changer à la main la date, l'heure ou le fuseau ne contourne pas les plages horaires ni le temps d'écran : le changement est ignoré et signalé au parent. Laissez l'heure automatique activée."),
|
||||
AboutSection("Sur le téléphone", listOf(
|
||||
AboutEntry("Mot de passe de verrouillage", "Protège les Réglages, l'effacement du journal et la désactivation de la protection."),
|
||||
AboutEntry("Anti-désinstallation", "DataControl peut devenir administrateur de l'appareil : Android refuse alors de le désinstaller tant que la protection est active."),
|
||||
AboutEntry("Journal de diagnostic", "L'onglet Journal montre l'état du VPN, les décisions du pare-feu et, si le service d'accessibilité est activé, chaque application utilisée avec son verdict réseau et lancement. Conservé 15 jours, avec copie et partage."),
|
||||
AboutEntry("Mise à jour intégrée", "Recherche et installation de la dernière version publiée, depuis l'onglet Journal de cette page.")
|
||||
)),
|
||||
AboutSection("Contrôle parental (avec FamilyControl)", listOf(
|
||||
AboutEntry("Appairage par code", "L'appareil s'associe à l'application parent FamilyControl avec un code d'appairage."),
|
||||
AboutEntry("Suivi à distance", "Batterie, position (arrière-plan compris si autorisé), état du pare-feu, trafic quotidien mobile/Wi-Fi et journal d'événements."),
|
||||
AboutEntry("Accès réseau par application", "Le parent autorise ou bloque Internet application par application, sur données mobiles comme sur Wi-Fi."),
|
||||
AboutEntry("Plages horaires, temps d'écran, applications interdites", "Appliqués par un service d'accessibilité : verrouillage hors plage, limite quotidienne globale ou par application, ajout de temps, lancement refusé."),
|
||||
AboutEntry("Condition", "Ces fonctions demandent une configuration Firebase saisie dans Réglages → Contrôle parental, puis l'association à un compte parent.")
|
||||
AboutEntry("Journal de diagnostic", "État du tunnel, mode appliqué et, si le service d'accessibilité est activé, chaque application utilisée. Conservé 15 jours, avec copie et partage."),
|
||||
AboutEntry("Mise à jour intégrée", "Recherche et installation de la dernière version publiée, depuis l'onglet Journal de cette page."),
|
||||
AboutEntry("Reprise automatique", "La surveillance et le tunnel redémarrent après un redémarrage de l'appareil, et sont relancés périodiquement si le système les a arrêtés.")
|
||||
))
|
||||
)
|
||||
|
||||
val howTo = listOf(
|
||||
"Ouvrez « Réglages » et renseignez le forfait : volume (Mo), prix, prix par Mo hors-forfait, intervalle de coupure. Touchez « Enregistrer ».",
|
||||
"Ouvrez « Liste blanche » et cochez les applications qui doivent garder Internet en données mobiles.",
|
||||
"Dans « Réglages » → « Autorisations », accordez « Pare-feu local (VPN) », « Notifications » et « Optimisation de la batterie ».",
|
||||
"Sur le « Tableau de bord », activez « Activer le contrôle » et acceptez la demande VPN d'Android.",
|
||||
"Coupez le Wi-Fi : l'icône clé du VPN apparaît en données mobiles et la notification affiche l'état.",
|
||||
"Recommandé : définissez un mot de passe dans « Réglages » → « Sécurité »."
|
||||
"Sur l'espace parents (family.tuxtech.fr), touchez « Associer un téléphone » : un code de 8 caractères s'affiche pendant 10 minutes.",
|
||||
"Dans DataControl, ouvrez « Réglages » → « Contrôle parental », saisissez le code et touchez « Associer ».",
|
||||
"Sur le « Tableau de bord », activez la protection et acceptez la demande VPN d'Android : l'icône clé apparaît.",
|
||||
"Dans « Réglages » → « Autorisations », accordez « Notifications » et « Optimisation de la batterie » ; dans « Contrôle parental », activez le service d'accessibilité et la position si besoin.",
|
||||
"Recommandé : définissez un mot de passe dans « Réglages » → « Sécurité », puis activez la protection anti-désinstallation."
|
||||
)
|
||||
|
||||
val everyday = listOf(
|
||||
AboutEntry("Consulter la consommation", "Le Tableau de bord affiche les Mo consommés, le restant, le dépassement et le total estimé."),
|
||||
AboutEntry("Repartir à zéro", "« Réinitialiser le forfait maintenant » sur le Tableau de bord, ou « Consommation actuelle » dans les Réglages pour recaler sur l'opérateur."),
|
||||
AboutEntry("Changer une application autorisée", "Onglet « Liste blanche » : onglets « Données mobiles » et « Wi-Fi », recherche et sélection multiple.")
|
||||
AboutEntry("Changer l'accès à Internet", "Depuis l'espace parents : Libre, Liste blanche ou Coupé, pour une durée ou sans limite ; le téléphone l'applique en quelques secondes."),
|
||||
AboutEntry("Consulter la consommation", "Le Tableau de bord affiche les Mo consommés, le restant et le total estimé ; l'espace parents affiche le trafic mesuré par le serveur."),
|
||||
AboutEntry("Repartir à zéro", "« Réinitialiser le forfait maintenant » sur le Tableau de bord, ou « Consommation actuelle » dans les Réglages.")
|
||||
)
|
||||
|
||||
val troubleshooting = listOf(
|
||||
AboutEntry("Je ne vois pas l'icône VPN", "Le pare-feu ne se monte que sur données mobiles, contrôle activé. Sur Wi-Fi, il n'y en a jamais (sauf filtrage Wi-Fi activé). Consultez l'onglet Journal : il donne la raison exacte."),
|
||||
AboutEntry("« PARE-FEU INACTIF » dans la notification", "Les données mobiles ne sont pas filtrées : autorisation VPN retirée ou tunnel non monté. Rouvrez DataControl pour redonner l'autorisation VPN, et lisez le Journal."),
|
||||
AboutEntry("Un autre VPN a pris la main", "Un seul VPN peut être actif sous Android. Le contrôle reste activé mais ne filtre plus : la notification passe en rouge, le parent est alerté et DataControl retente toutes les 30 s ; à l'ouverture, l'autorisation VPN est redemandée."),
|
||||
AboutEntry("« PROTECTION INACTIVE » dans la notification", "Le tunnel est coupé : autorisation VPN retirée, autre VPN actif, ou serveur injoignable. Rouvrez DataControl pour redonner l'autorisation VPN ; le Journal donne la raison exacte."),
|
||||
AboutEntry("Un autre VPN a pris la main", "Un seul VPN peut être actif sous Android. La protection reste activée mais ne filtre plus : la notification passe en rouge, le parent est alerté et DataControl retente toutes les 30 s."),
|
||||
AboutEntry("« Code inconnu, déjà utilisé ou expiré »", "Un code ne sert qu'une fois et vit 10 minutes : créez-en un nouveau dans l'espace parents."),
|
||||
AboutEntry("Le service d'accessibilité est grisé (Android 13+)", "Ouvrez Infos sur l'application → ⋮ → « Autoriser les paramètres restreints », puis réessayez.")
|
||||
)
|
||||
|
||||
val prerequisites = listOf(
|
||||
AboutSection("Système", listOf(
|
||||
AboutEntry("Android 8.0 ou plus", "Version minimale déclarée par l'application (API 26) ; cible : Android 14 (API 34)."),
|
||||
AboutEntry("Ni root, ni Shizuku", "Le filtrage repose sur le VPN local d'Android."),
|
||||
AboutEntry("Un seul VPN à la fois", "Le pare-feu est incompatible avec un autre VPN actif en même temps.")
|
||||
AboutEntry("Ni root, ni Shizuku", "Le tunnel repose sur le VPN standard d'Android."),
|
||||
AboutEntry("Un seul VPN à la fois", "DataControl est incompatible avec un autre VPN actif en même temps.")
|
||||
)),
|
||||
AboutSection("Autorisations", listOf(
|
||||
AboutEntry("VPN (obligatoire)", "Consentement système demandé à l'activation du contrôle."),
|
||||
AboutEntry("VPN (obligatoire)", "Consentement système demandé à l'activation de la protection."),
|
||||
AboutEntry("Notifications (Android 13+)", "Pour l'état permanent."),
|
||||
AboutEntry("Optimisation de la batterie (recommandé)", "Évite que le système n'arrête la surveillance."),
|
||||
AboutEntry("Liste des applications", "Permission QUERY_ALL_PACKAGES, pour afficher toutes les applications dans la Liste blanche."),
|
||||
AboutEntry("Contrôle parental (facultatif)", "Position (avec « Toujours autoriser » pour l'arrière-plan), administrateur de l'appareil, service d'accessibilité.")
|
||||
AboutEntry("Liste des applications", "Permission QUERY_ALL_PACKAGES, pour que le parent choisisse les applications à limiter."),
|
||||
AboutEntry("Facultatif", "Position (avec « Toujours autoriser » pour l'arrière-plan), administrateur de l'appareil, service d'accessibilité.")
|
||||
)),
|
||||
AboutSection("Réseau", listOf(
|
||||
AboutEntry("Sortant, HTTPS (TCP 443)", "Recherche de mise à jour (git.tuxtech.fr) et, si l'appareil est associé, échanges avec Firebase. Aucun port entrant, aucune redirection de port.")
|
||||
AboutEntry("Sortant, UDP 51721", "Tunnel WireGuard vers le serveur du parent (fam-vpn.tuxtech.fr)."),
|
||||
AboutEntry("Sortant, HTTPS (TCP 443)", "Appairage (family.tuxtech.fr) et recherche de mise à jour (git.tuxtech.fr). Aucun port entrant.")
|
||||
))
|
||||
)
|
||||
|
||||
val security = listOf(
|
||||
AboutSection("Pare-feu", listOf(
|
||||
AboutEntry("Tunnel local sans transfert", "Le VPN ne route ni ne transmet aucun paquet : le trafic des applications non autorisées entre dans le tunnel et y est abandonné."),
|
||||
AboutEntry("IPv4 et IPv6", "Les deux familles d'adresses sont capturées, pour qu'une application bloquée ne contourne pas le pare-feu en IPv6."),
|
||||
AboutEntry("Reprise si le tunnel disparaît", "Si le service VPN est arrêté par le système, le tunnel est remonté automatiquement ; sinon la notification le signale."),
|
||||
AboutEntry("DataControl jamais bloqué", "L'application est toujours exclue du tunnel : la liaison avec le parent ne peut pas être coupée par le pare-feu.")
|
||||
AboutSection("Tunnel", listOf(
|
||||
AboutEntry("WireGuard officiel", "Bibliothèque com.wireguard.android (wireguard-go) : chiffrement moderne, aucune donnée en clair entre le téléphone et le serveur."),
|
||||
AboutEntry("Clé privée sur le téléphone", "La paire de clés est générée sur le téléphone à l'appairage ; la clé privée ne le quitte jamais et est chiffrée par l'Android Keystore."),
|
||||
AboutEntry("Tout le trafic, IPv4 et IPv6", "Le tunnel couvre 0.0.0.0/0 et ::/0 : aucune application ne le contourne en IPv6."),
|
||||
AboutEntry("Reprise si le tunnel tombe", "La protection reste activée, le parent est prévenu et le tunnel est remonté dès que possible.")
|
||||
)),
|
||||
AboutSection("Liaison avec le serveur", listOf(
|
||||
AboutEntry("Appairage", "Code aléatoire à usage unique, valable 10 minutes, créé par le parent ; l'appairage passe en HTTPS."),
|
||||
AboutEntry("Jeton lié au tunnel", "Le jeton de l'appareil, chiffré par l'Android Keystore, n'est accepté par le serveur que depuis l'adresse du téléphone dans le tunnel."),
|
||||
AboutEntry("Filtrage côté serveur", "Le téléphone ne peut pas lever lui-même une coupure décidée par le parent : elle s'applique sur le serveur.")
|
||||
)),
|
||||
AboutSection("Accès à l'application", listOf(
|
||||
AboutEntry("Mot de passe", "Haché avec PBKDF2-HMAC-SHA256 (100 000 itérations) et un sel aléatoire de 16 octets, jamais stocké en clair. Après 3 essais ratés, attente croissante de 30 s à 15 min."),
|
||||
AboutEntry("Anti-désinstallation", "Administrateur d'appareil. Limite : la protection peut être retirée depuis les Réglages Android ; si l'appareil est associé, le parent en est alerté. La demande de mot de passe ne s'applique qu'au retrait fait depuis DataControl.")
|
||||
)),
|
||||
AboutSection("Liaison FamilyControl", listOf(
|
||||
AboutEntry("Appairage", "Code aléatoire à usage unique et à durée limitée (10 minutes) créé côté parent. Connexion anonyme Firebase côté appareil, compte e-mail côté parent."),
|
||||
AboutEntry("Règles Firestore", "Fournies avec FamilyControl (firebase/firestore.rules) : l'appareil n'écrit que dans son propre espace et ne peut pas modifier les consignes du parent."),
|
||||
AboutEntry("Anti-désinstallation", "Administrateur d'appareil. Limite : la protection peut être retirée depuis les Réglages Android ; le parent en est alerté."),
|
||||
AboutEntry("Service d'accessibilité", "Détecte uniquement l'application au premier plan : la lecture du contenu des écrans est désactivée (canRetrieveWindowContent = false)."),
|
||||
AboutEntry("Applications exemptées", "Lanceur, téléphone, clavier et DataControl ne sont jamais bloqués, pour que l'appareil reste utilisable.")
|
||||
)),
|
||||
AboutSection("Limites connues", listOf(
|
||||
AboutEntry("Signature de l'APK", "Le build release est actuellement signé avec la clé de debug d'Android. Android n'accepte une mise à jour que si elle porte la même signature que l'application installée."),
|
||||
AboutEntry("Téléchargement des mises à jour", "L'APK est récupéré en HTTPS depuis la release Gitea ; aucune empreinte supplémentaire n'est vérifiée par l'application.")
|
||||
AboutEntry("Retrait du VPN", "Sans le mode « propriétaire de l'appareil » (prévu plus tard), le VPN peut être coupé dans les réglages Android : le serveur le voit et alerte le parent, sans pouvoir l'empêcher."),
|
||||
AboutEntry("Téléchargement des mises à jour", "L'APK est récupéré en HTTPS depuis la release Gitea ; Android vérifie qu'il porte la même signature que l'application installée.")
|
||||
))
|
||||
)
|
||||
|
||||
val guarantees = listOf(
|
||||
AboutSection("Vos données", listOf(
|
||||
AboutEntry("Aucune télémétrie", "Le code ne contient ni analytique ni publicité. Les seuls accès réseau sont la recherche de mise à jour et, si l'appareil est associé, Firebase."),
|
||||
AboutEntry("Aucune télémétrie", "Le code ne contient ni analytique ni publicité. Les seuls échanges sont avec le serveur du parent et la recherche de mise à jour."),
|
||||
AboutEntry("Sans appairage, rien ne sort", "Non associé, l'appareil n'envoie rien ; la recherche de mise à jour ne part que lorsque vous touchez le bouton."),
|
||||
AboutEntry("Le mot de passe reste sur l'appareil", "Il n'est jamais transmis, pas même haché."),
|
||||
AboutEntry("Appareil associé : ce qui est envoyé", "Nom de l'appareil, batterie, position, état du pare-feu et du contrôle, listes d'applications autorisées, consommation du forfait, liste des applications installées, trafic quotidien mobile/Wi-Fi, temps d'écran par application, événements.")
|
||||
AboutEntry("Appareil associé : ce qui est envoyé", "Nom et modèle, batterie, position (si autorisée), type de réseau, état des protections, liste des applications installées, temps d'écran par application, évènements.")
|
||||
)),
|
||||
AboutSection("Ce qui n'est jamais lu", listOf(
|
||||
AboutEntry("Contenu des écrans et messages", "Le service d'accessibilité ne lit pas le contenu des fenêtres."),
|
||||
AboutEntry("Contenu du trafic", "Le VPN ne route rien et n'inspecte aucun paquet.")
|
||||
AboutSection("Ce que voit le serveur", listOf(
|
||||
AboutEntry("Noms de domaine et volume", "Comme tout le trafic passe par le serveur du parent, celui-ci voit les sites demandés (journal DNS conservé 7 jours) et le volume échangé."),
|
||||
AboutEntry("Pas le contenu", "Les échanges chiffrés des applications (HTTPS) restent illisibles pour le serveur ; le service d'accessibilité ne lit pas le contenu des écrans.")
|
||||
)),
|
||||
AboutSection("Privilèges et licence", listOf(
|
||||
AboutEntry("Rien d'activé à votre insu", "VPN, position, administrateur d'appareil et accessibilité passent chacun par une autorisation explicite d'Android."),
|
||||
@@ -118,15 +117,14 @@ object AboutContent {
|
||||
)
|
||||
|
||||
val choices = listOf(
|
||||
AboutEntry("VpnService plutôt qu'une coupure des données", "Android interdit aux applications normales de couper les données mobiles depuis Android 5. Le VPN local fonctionne sans root ni Shizuku, sur le principe de NetGuard."),
|
||||
AboutEntry("Exclusion d'applications plutôt que relais de paquets", "Les applications autorisées sont exclues du tunnel (addDisallowedApplication) : leur trafic passe normalement, sans passer par l'application."),
|
||||
AboutEntry("Filtrage sur le serveur plutôt que sur le téléphone", "Le téléphone ne peut pas contourner une règle appliquée ailleurs ; le filtrage couvre aussi le Wi-Fi et se pilote depuis l'espace parents."),
|
||||
AboutEntry("WireGuard plutôt qu'un autre VPN", "Protocole léger et sobre en batterie, bibliothèque officielle ; un seul VPN sur le téléphone, donc aucun conflit."),
|
||||
AboutEntry("Serveur auto-hébergé plutôt que Firebase", "Aucune dépendance à un service tiers : les données restent sur le serveur du parent."),
|
||||
AboutEntry("TrafficStats plutôt que NetworkStatsManager", "Le compteur système cumulé ne demande aucune permission spéciale. Contrepartie : il repart de zéro au redémarrage, l'application recale alors sa référence."),
|
||||
AboutEntry("Service de premier plan + relance périodique", "Un service permanent et une tâche WorkManager toutes les 15 minutes, car certains systèmes arrêtent agressivement les applications en arrière-plan."),
|
||||
AboutEntry("Kotlin, Jetpack Compose (Material 3), Hilt, DataStore", "Interface déclarative et thème dynamique (Android 12+) ; réglages dans DataStore Preferences."),
|
||||
AboutEntry("HttpURLConnection plutôt qu'un client HTTP tiers", "Seule la mise à jour fait un appel HTTP : pas de dépendance réseau supplémentaire pour cela."),
|
||||
AboutEntry("Firebase sans plugin google-services", "La configuration se saisit dans l'application (Réglages → Contrôle parental) et peut changer sans recompiler ; sans elle, le contrôle à distance est simplement désactivé. Un fichier firebase.properties à la compilation reste possible comme valeur par défaut."),
|
||||
AboutEntry("HttpURLConnection plutôt qu'un client HTTP tiers", "Quelques appels simples vers le serveur et Gitea : pas de dépendance réseau supplémentaire."),
|
||||
AboutEntry("Service d'accessibilité plutôt que statistiques d'usage", "Permet de renvoyer à l'accueil et d'afficher un écran de blocage dès qu'une application interdite passe au premier plan."),
|
||||
AboutEntry("Administrateur d'appareil plutôt que Device Owner", "Pas de réinitialisation d'usine ni de branchement à un PC ; en contrepartie, la protection est moins robuste."),
|
||||
AboutEntry("Journal embarqué", "Le journal de diagnostic est conservé 15 jours (20 000 lignes au plus) dans un fichier local ; le changelog de cette page est le CHANGELOG.md du dépôt, embarqué dans l'APK.")
|
||||
)
|
||||
|
||||
|
||||
@@ -70,10 +70,12 @@ fun DashboardScreen(systemActions: SystemActions) {
|
||||
else -> StateCut
|
||||
}
|
||||
val stateLabel = when {
|
||||
snapshot.firewallFault -> stringResource(R.string.dashboard_tunnel_down)
|
||||
!snapshot.serviceRunning -> stringResource(R.string.dashboard_control_disabled)
|
||||
snapshot.dataState == DataState.OK -> stringResource(R.string.dashboard_state_ok)
|
||||
snapshot.dataState == DataState.WARNING -> stringResource(R.string.dashboard_state_warning)
|
||||
snapshot.dataState == DataState.CUT -> stringResource(R.string.dashboard_state_cut)
|
||||
snapshot.dataState == DataState.CUT ->
|
||||
stringResource(if (snapshot.serverMode == "cut") R.string.mode_cut else R.string.mode_allowlist)
|
||||
else -> stringResource(R.string.dashboard_state_wifi)
|
||||
}
|
||||
|
||||
@@ -161,7 +163,9 @@ fun DashboardScreen(systemActions: SystemActions) {
|
||||
Switch(
|
||||
checked = settings.controlEnabled,
|
||||
onCheckedChange = { checked ->
|
||||
if (checked) {
|
||||
if (checked && !settings.isPaired) {
|
||||
android.widget.Toast.makeText(context, context.getString(R.string.dashboard_pair_first), android.widget.Toast.LENGTH_LONG).show()
|
||||
} else if (checked) {
|
||||
systemActions.requestNotificationPermission { _ ->
|
||||
systemActions.requestVpnPermission { granted ->
|
||||
com.jttools.datacontrol.log.AppLog.i("Tableau de bord", "Activation du contrôle : autorisation VPN ${if (granted) "accordée" else "REFUSÉE"}")
|
||||
@@ -192,24 +196,29 @@ fun DashboardScreen(systemActions: SystemActions) {
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
val nextCutoffText = when {
|
||||
!settings.controlEnabled -> stringResource(R.string.dashboard_control_disabled)
|
||||
snapshot.dataState == DataState.WIFI -> stringResource(R.string.dashboard_cutoff_wifi)
|
||||
snapshot.dataState == DataState.CUT -> stringResource(R.string.dashboard_cutoff_in_progress)
|
||||
snapshot.nextCutoffAtMillis != null -> {
|
||||
val remainingMs = (snapshot.nextCutoffAtMillis!! - System.currentTimeMillis()).coerceAtLeast(0L)
|
||||
stringResource(
|
||||
R.string.dashboard_cutoff_countdown,
|
||||
remainingMs / 60_000,
|
||||
(remainingMs / 1000) % 60
|
||||
)
|
||||
}
|
||||
else -> stringResource(R.string.dashboard_cutoff_unavailable)
|
||||
// Mode décidé par le serveur familyd et état du tunnel.
|
||||
val modeText = when (snapshot.serverMode) {
|
||||
"cut" -> stringResource(R.string.mode_cut)
|
||||
"allowlist" -> stringResource(R.string.mode_allowlist)
|
||||
else -> stringResource(R.string.mode_open)
|
||||
}
|
||||
val tunnelText = when {
|
||||
!snapshot.paired -> stringResource(R.string.dashboard_not_paired)
|
||||
!settings.controlEnabled -> stringResource(R.string.dashboard_control_disabled)
|
||||
snapshot.tunnelUp -> stringResource(R.string.dashboard_tunnel_up) +
|
||||
(snapshot.lastHandshakeMs?.let { " · " + DateTimeFormatter.ofPattern("HH:mm:ss")
|
||||
.format(java.time.Instant.ofEpochMilli(it).atZone(java.time.ZoneId.systemDefault())) } ?: "")
|
||||
else -> stringResource(R.string.dashboard_tunnel_down) + (snapshot.tunnelError?.let { " ($it)" } ?: "")
|
||||
}
|
||||
Text("${stringResource(R.string.dashboard_tunnel)} : $tunnelText", style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (snapshot.firewallFault) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface)
|
||||
if (snapshot.paired) {
|
||||
Text(
|
||||
"${stringResource(R.string.dashboard_server_mode)} : $modeText" +
|
||||
(snapshot.serverModeReason.takeIf { it.isNotBlank() && snapshot.serverMode != "open" }?.let { " ($it)" } ?: ""),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"${stringResource(R.string.dashboard_next_cutoff)}: $nextCutoffText",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
OutlinedButton(onClick = { showResetDialog = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.dashboard_reset_now))
|
||||
|
||||
@@ -32,20 +32,17 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.jttools.datacontrol.R
|
||||
import com.jttools.datacontrol.admin.OwnerPolicy
|
||||
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. */
|
||||
/** Carte « Contrôle parental » des réglages : liaison avec le serveur familyd, 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 gw by vm.gatewayState.collectAsStateWithLifecycle()
|
||||
var firebaseText by remember { mutableStateOf("") }
|
||||
var firebaseError by remember { mutableStateOf<String?>(null) }
|
||||
var editFirebase by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var adminActive by remember { mutableStateOf(systemActions.isAdminActive()) }
|
||||
@@ -61,7 +58,12 @@ fun FamilySection(systemActions: SystemActions) {
|
||||
var pwdError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Au retour des écrans système (accessibilité, permissions, admin), on relit l'état réel.
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
var owner by remember { mutableStateOf(OwnerPolicy.isDeviceOwner(context)) }
|
||||
var ownerError by remember { mutableStateOf(false) }
|
||||
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||
owner = OwnerPolicy.isDeviceOwner(context)
|
||||
adminActive = systemActions.isAdminActive()
|
||||
locationGranted = systemActions.hasLocationPermission()
|
||||
backgroundGranted = systemActions.hasBackgroundLocationPermission()
|
||||
@@ -76,6 +78,11 @@ fun FamilySection(systemActions: SystemActions) {
|
||||
when (action) {
|
||||
"admin" -> { systemActions.deactivateAdmin(); adminActive = systemActions.isAdminActive() }
|
||||
"unpair" -> vm.unpair()
|
||||
"owner" -> {
|
||||
ownerError = !OwnerPolicy.release(context)
|
||||
owner = OwnerPolicy.isDeviceOwner(context)
|
||||
if (!owner) vm.reportOwnerRemoved()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,69 +94,69 @@ fun FamilySection(systemActions: SystemActions) {
|
||||
)
|
||||
HorizontalDivider()
|
||||
|
||||
// --- Configuration Firebase (saisie ici, sans recompiler) ---
|
||||
Text(stringResource(R.string.family_firebase_title), fontWeight = FontWeight.Medium)
|
||||
if (gw.enabled && !editFirebase) {
|
||||
// --- Liaison avec le serveur familyd ---
|
||||
val link = settings.link
|
||||
if (link != null) {
|
||||
Text(
|
||||
stringResource(R.string.family_firebase_ok, gw.projectId ?: ""),
|
||||
stringResource(R.string.family_paired, link.serverUrl.removePrefix("https://"), link.tunnelIp),
|
||||
color = StateOk, style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
OutlinedButton(onClick = { editFirebase = true }) { Text(stringResource(R.string.family_firebase_edit)) }
|
||||
} else {
|
||||
Text(
|
||||
stringResource(R.string.family_firebase_help),
|
||||
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
"${stringResource(R.string.family_device_name)} : ${link.name}",
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
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.take(300) },
|
||||
label = { Text(stringResource(R.string.family_code_or_link)) },
|
||||
singleLine = true, modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = firebaseText, onValueChange = { firebaseText = it; firebaseError = null },
|
||||
label = { Text(stringResource(R.string.family_firebase_field)) },
|
||||
minLines = 3, maxLines = 6, modifier = Modifier.fillMaxWidth()
|
||||
value = deviceName, onValueChange = { deviceName = it.take(40) },
|
||||
label = { Text(stringResource(R.string.family_device_name)) },
|
||||
singleLine = true, modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
firebaseError?.let {
|
||||
pairing.error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
if (pairing.busy) Text(stringResource(R.string.family_pairing), style = MaterialTheme.typography.bodySmall)
|
||||
Button(
|
||||
enabled = firebaseText.isNotBlank(),
|
||||
onClick = {
|
||||
firebaseError = vm.saveFirebaseConfig(firebaseText)
|
||||
if (firebaseError == null) { firebaseText = ""; editFirebase = false }
|
||||
}
|
||||
) { Text(stringResource(R.string.family_firebase_save)) }
|
||||
enabled = code.isNotBlank() && !pairing.busy,
|
||||
onClick = { vm.pair(code, deviceName) }
|
||||
) { Text(stringResource(R.string.family_pair)) }
|
||||
}
|
||||
pairing.done?.let {
|
||||
Text(stringResource(R.string.family_paired_now), color = StateOk, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// --- Appairage ---
|
||||
when {
|
||||
!gw.enabled -> Text(
|
||||
stringResource(R.string.family_unavailable),
|
||||
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
// --- Mode propriétaire de l'appareil (Device Owner) ---
|
||||
Text(stringResource(R.string.family_owner_title), fontWeight = FontWeight.Medium)
|
||||
if (owner) {
|
||||
Text(stringResource(R.string.family_owner_on), color = StateOk, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
stringResource(if (settings.controlEnabled && settings.isPaired) R.string.family_owner_rules_active
|
||||
else R.string.family_owner_rules_idle),
|
||||
style = MaterialTheme.typography.bodySmall, 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))
|
||||
}
|
||||
OutlinedButton(onClick = { runProtected("owner") { execute("owner") } }) {
|
||||
Text(stringResource(R.string.family_owner_release))
|
||||
}
|
||||
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()
|
||||
if (ownerError) Text(stringResource(R.string.family_owner_release_error),
|
||||
color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
} else {
|
||||
Text(stringResource(R.string.family_owner_off), style = MaterialTheme.typography.bodyMedium)
|
||||
androidx.compose.foundation.text.selection.SelectionContainer {
|
||||
Text(
|
||||
"adb shell dpm set-device-owner com.jttools.datacontrol/.admin.AppDeviceAdminReceiver",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
|
||||
)
|
||||
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)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +169,9 @@ fun FamilySection(systemActions: SystemActions) {
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (adminActive) StateOk else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (adminActive) {
|
||||
if (owner) {
|
||||
// Le propriétaire de l'appareil ne peut pas être désinstallé : rien à retirer ici.
|
||||
} else if (adminActive) {
|
||||
OutlinedButton(onClick = { runProtected("admin") { execute("admin") } }) {
|
||||
Text(stringResource(R.string.family_admin_disable))
|
||||
}
|
||||
|
||||
@@ -4,19 +4,19 @@ 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.FirebaseConfig
|
||||
import com.jttools.datacontrol.remote.PairingInput
|
||||
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.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
|
||||
data class PairingUiState(val busy: Boolean = false, val error: String? = null)
|
||||
data class PairingUiState(val busy: Boolean = false, val error: String? = null, val done: String? = null)
|
||||
|
||||
@HiltViewModel
|
||||
class FamilyViewModel @Inject constructor(
|
||||
@@ -28,37 +28,39 @@ class FamilyViewModel @Inject constructor(
|
||||
viewModelScope, SharingStarted.WhileSubscribed(5_000), AppSettings()
|
||||
)
|
||||
|
||||
val gatewayState = remoteSync.gatewayState.stateIn(
|
||||
viewModelScope, SharingStarted.WhileSubscribed(5_000), remoteSync.gatewayState.value
|
||||
)
|
||||
|
||||
/** Enregistre la configuration Firebase collée ; renvoie un message d'erreur, ou null si elle est acceptée. */
|
||||
fun saveFirebaseConfig(text: String): String? {
|
||||
val cfg = FirebaseConfig.parse(text)
|
||||
?: return "Configuration non reconnue : il faut au minimum apiKey, appId et projectId (collez le bloc « firebaseConfig » complet, ou la chaîne « fc1:… » de FamilyControl)."
|
||||
viewModelScope.launch { settingsRepository.setFirebaseConfig(cfg) }
|
||||
return null
|
||||
}
|
||||
|
||||
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() }
|
||||
}
|
||||
|
||||
/** null tant que les réglages ne sont pas chargés, sinon vrai si un mot de passe est défini. */
|
||||
val passwordSet: StateFlow<Boolean?> = settingsRepository.settingsFlow.map<AppSettings, Boolean?> { it.hasPassword }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
private val _pairing = MutableStateFlow(PairingUiState())
|
||||
val pairing: StateFlow<PairingUiState> = _pairing.asStateFlow()
|
||||
|
||||
fun pair(input: String, deviceName: String) {
|
||||
val parsed = PairingInput.parse(input)
|
||||
if (parsed == null) {
|
||||
_pairing.value = PairingUiState(error = "Code non reconnu : 8 caractères (lettres et chiffres), ou un lien familyd://…")
|
||||
return
|
||||
}
|
||||
_pairing.value = PairingUiState(busy = true)
|
||||
viewModelScope.launch {
|
||||
val result = remoteSync.pair(parsed, deviceName)
|
||||
_pairing.value = result.fold(
|
||||
onSuccess = { PairingUiState(done = it.name) },
|
||||
onFailure = { PairingUiState(error = it.message ?: "Association impossible.") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun unpair() {
|
||||
viewModelScope.launch { remoteSync.forget() }
|
||||
}
|
||||
|
||||
fun reportOwnerRemoved() {
|
||||
viewModelScope.launch {
|
||||
remoteSync.pushEvent("device_owner_removed", "Mode propriétaire de l'appareil retiré depuis DataControl")
|
||||
}
|
||||
}
|
||||
|
||||
/** null si le mot de passe est bon, sinon le message d'erreur. */
|
||||
suspend fun checkPassword(password: String): String? = settingsRepository.checkPassword(password)
|
||||
}
|
||||
@@ -32,7 +32,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.jttools.datacontrol.log.AppLog
|
||||
import com.jttools.datacontrol.service.FirewallVpnService
|
||||
import com.jttools.datacontrol.vpn.TunnelManager
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.jttools.datacontrol.ui.components.PasswordPromptDialog
|
||||
@@ -42,17 +42,17 @@ import kotlinx.coroutines.launch
|
||||
@Composable
|
||||
fun JournalScreen() {
|
||||
val context = LocalContext.current
|
||||
val entries by AppLog.entries.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
val viewModel: JournalViewModel = hiltViewModel()
|
||||
val entries by AppLog.entries.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
val passwordSet by viewModel.passwordSet.collectAsStateWithLifecycle()
|
||||
var askPassword by remember { mutableStateOf(false) }
|
||||
var passwordError by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// État en direct du pare-feu, rafraîchi chaque seconde : première chose à regarder si le VPN n'apparaît pas.
|
||||
var live by remember { mutableStateOf(liveStatus(context)) }
|
||||
var live by remember { mutableStateOf(liveStatus(context, viewModel.tunnelUp())) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) { live = liveStatus(context); delay(1000) }
|
||||
while (true) { live = liveStatus(context, viewModel.tunnelUp()); delay(1000) }
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
@@ -110,10 +110,10 @@ private fun ClearPasswordDialog(
|
||||
onConfirm: (String) -> Unit
|
||||
) = PasswordPromptDialog(title = "Effacer le journal", error = error, onDismiss = onDismiss, onConfirm = onConfirm)
|
||||
|
||||
private fun liveStatus(context: Context): String = buildString {
|
||||
appendLine("VPN autorisé (prepare) : ${FirewallVpnService.isPrepared(context)}")
|
||||
appendLine("Tunnel VPN monté : ${FirewallVpnService.tunnelActive}")
|
||||
private fun liveStatus(context: Context, tunnelUp: Boolean): String = buildString {
|
||||
appendLine("VPN autorisé (prepare) : ${TunnelManager.isPrepared(context)}")
|
||||
appendLine("Tunnel WireGuard : ${if (tunnelUp) "monté" else "non monté"}")
|
||||
appendLine("Suivi des applis (accessibilité) : ${com.jttools.datacontrol.guard.AppGuardService.isEnabled(context)}")
|
||||
appendLine("Conservation du journal : ${AppLog.RETENTION_DAYS} jours")
|
||||
append("Attendu : le tunnel n'est monté QUE sur données mobiles, contrôle activé.")
|
||||
append("Attendu : tunnel monté en permanence (Wi-Fi et données mobiles) dès que le contrôle est activé.")
|
||||
}
|
||||
@@ -14,9 +14,12 @@ import javax.inject.Inject
|
||||
/** Effacer le journal fait disparaître la trace des usages : protégé par le mot de passe. */
|
||||
@HiltViewModel
|
||||
class JournalViewModel @Inject constructor(
|
||||
private val settingsRepository: SettingsRepository
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val tunnel: com.jttools.datacontrol.vpn.TunnelManager
|
||||
) : ViewModel() {
|
||||
|
||||
fun tunnelUp(): Boolean = tunnel.up.value
|
||||
|
||||
val passwordSet: StateFlow<Boolean?> = settingsRepository.settingsFlow.map<AppSettings, Boolean?> { it.hasPassword }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.jttools.datacontrol.ui.navigation
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.List
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.filled.Dashboard
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
@@ -28,17 +27,15 @@ import com.jttools.datacontrol.ui.about.AboutScreen
|
||||
import com.jttools.datacontrol.ui.dashboard.DashboardScreen
|
||||
import com.jttools.datacontrol.ui.journal.JournalScreen
|
||||
import com.jttools.datacontrol.ui.settings.SettingsScreen
|
||||
import com.jttools.datacontrol.ui.whitelist.WhitelistScreen
|
||||
|
||||
private sealed class Destination(val route: String, val labelRes: Int, val icon: androidx.compose.ui.graphics.vector.ImageVector) {
|
||||
data object Dashboard : Destination("dashboard", R.string.nav_dashboard, Icons.Filled.Dashboard)
|
||||
data object Settings : Destination("settings", R.string.nav_settings, Icons.Filled.Settings)
|
||||
data object Whitelist : Destination("whitelist", R.string.nav_whitelist, Icons.AutoMirrored.Filled.List)
|
||||
data object Journal : Destination("journal", R.string.nav_journal, Icons.Filled.BugReport)
|
||||
data object About : Destination("about", R.string.nav_about, Icons.Filled.Info)
|
||||
}
|
||||
|
||||
private val destinations = listOf(Destination.Dashboard, Destination.Settings, Destination.Whitelist, Destination.Journal, Destination.About)
|
||||
private val destinations = listOf(Destination.Dashboard, Destination.Settings, Destination.Journal, Destination.About)
|
||||
|
||||
@Composable
|
||||
fun DataControlNavHost(systemActions: SystemActions) {
|
||||
@@ -74,7 +71,6 @@ fun DataControlNavHost(systemActions: SystemActions) {
|
||||
) {
|
||||
composable(Destination.Dashboard.route) { DashboardScreen(systemActions) }
|
||||
composable(Destination.Settings.route) { SettingsScreen(systemActions) }
|
||||
composable(Destination.Whitelist.route) { WhitelistScreen() }
|
||||
composable(Destination.Journal.route) { JournalScreen() }
|
||||
composable(Destination.About.route) { AboutScreen() }
|
||||
}
|
||||
|
||||
@@ -70,7 +70,6 @@ private fun SettingsContent(
|
||||
var planLimitText by remember(settings.planLimitMo) { mutableStateOf(settings.planLimitMo.toString()) }
|
||||
var planPriceText by remember(settings.planPriceEuro) { mutableStateOf(settings.planPriceEuro.toString()) }
|
||||
var overagePriceText by remember(settings.overagePricePerMoEuro) { mutableStateOf(settings.overagePricePerMoEuro.toString()) }
|
||||
var cutoffText by remember(settings.cutoffIntervalMinutes) { mutableStateOf(settings.cutoffIntervalMinutes.toString()) }
|
||||
var autoReset by remember(settings.autoResetEnabled) { mutableStateOf(settings.autoResetEnabled) }
|
||||
var showDatePicker by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -178,24 +177,6 @@ private fun SettingsContent(
|
||||
}
|
||||
}
|
||||
|
||||
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_cutoff), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
OutlinedTextField(
|
||||
value = cutoffText,
|
||||
onValueChange = { cutoffText = it.filter(Char::isDigit) },
|
||||
label = { Text(stringResource(R.string.settings_cutoff_interval)) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.settings_cutoff_help),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
|
||||
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(stringResource(R.string.settings_section_permissions), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
@@ -293,7 +274,6 @@ private fun SettingsContent(
|
||||
planLimitMo = planLimitText.toLongOrNull() ?: settings.planLimitMo,
|
||||
planPriceEuro = planPriceText.toDoubleOrNull() ?: settings.planPriceEuro,
|
||||
overagePricePerMoEuro = overagePriceText.toDoubleOrNull() ?: settings.overagePricePerMoEuro,
|
||||
cutoffIntervalMinutes = cutoffText.toIntOrNull()?.coerceAtLeast(1) ?: settings.cutoffIntervalMinutes,
|
||||
autoResetEnabled = autoReset
|
||||
)
|
||||
},
|
||||
|
||||
@@ -34,12 +34,11 @@ class SettingsViewModel @Inject constructor(
|
||||
planLimitMo: Long,
|
||||
planPriceEuro: Double,
|
||||
overagePricePerMoEuro: Double,
|
||||
cutoffIntervalMinutes: Int,
|
||||
autoResetEnabled: Boolean
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.updatePlan(
|
||||
planLimitMo, planPriceEuro, overagePricePerMoEuro, cutoffIntervalMinutes, autoResetEnabled
|
||||
planLimitMo, planPriceEuro, overagePricePerMoEuro, autoResetEnabled
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
package com.jttools.datacontrol.ui.whitelist
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.jttools.datacontrol.R
|
||||
import com.jttools.datacontrol.data.AppInfo
|
||||
import com.jttools.datacontrol.ui.components.PasswordGate
|
||||
|
||||
@Composable
|
||||
fun WhitelistScreen() {
|
||||
val viewModel: WhitelistViewModel = hiltViewModel()
|
||||
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
||||
val passwordSet by viewModel.passwordSet.collectAsStateWithLifecycle()
|
||||
|
||||
PasswordGate(passwordSet = passwordSet, verify = { viewModel.checkPassword(it) }) {
|
||||
WhitelistContent(viewModel, settings)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WhitelistContent(viewModel: WhitelistViewModel, settings: com.jttools.datacontrol.data.AppSettings) {
|
||||
val apps by viewModel.apps.collectAsStateWithLifecycle()
|
||||
val loading by viewModel.loading.collectAsStateWithLifecycle()
|
||||
var query by remember { mutableStateOf("") }
|
||||
var tabIndex by remember { mutableIntStateOf(0) }
|
||||
val forWifi = tabIndex == 1
|
||||
|
||||
val filtered = apps
|
||||
.filter { settings.showSystemApps || !it.isSystemApp }
|
||||
.filter { it.label.contains(query, ignoreCase = true) || it.packageName.contains(query, ignoreCase = true) }
|
||||
|
||||
val activeSet = if (forWifi) settings.whitelistedPackagesWifi else settings.whitelistedPackages
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(20.dp)) {
|
||||
Text(stringResource(R.string.whitelist_title), style = MaterialTheme.typography.headlineMedium)
|
||||
Text(
|
||||
stringResource(R.string.whitelist_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp, bottom = 14.dp)
|
||||
)
|
||||
|
||||
TabRow(selectedTabIndex = tabIndex) {
|
||||
Tab(
|
||||
selected = tabIndex == 0,
|
||||
onClick = { tabIndex = 0 },
|
||||
text = { Text(stringResource(R.string.whitelist_tab_mobile)) }
|
||||
)
|
||||
Tab(
|
||||
selected = tabIndex == 1,
|
||||
onClick = { tabIndex = 1 },
|
||||
text = { Text(stringResource(R.string.whitelist_tab_wifi)) }
|
||||
)
|
||||
}
|
||||
|
||||
if (forWifi) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 10.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.whitelist_wifi_filter_toggle), style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
stringResource(R.string.whitelist_wifi_filter_help),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(checked = settings.wifiFilterEnabled, onCheckedChange = { viewModel.setWifiFilterEnabled(it) })
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
|
||||
label = { Text(stringResource(R.string.whitelist_search_hint)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 10.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(stringResource(R.string.whitelist_system_apps_toggle), style = MaterialTheme.typography.bodyMedium)
|
||||
Switch(checked = settings.showSystemApps, onCheckedChange = { viewModel.setShowSystemApps(it) })
|
||||
}
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(bottom = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.setWhitelistedBulk(filtered.map { it.packageName }, whitelisted = true, forWifi = forWifi) },
|
||||
modifier = Modifier.weight(1f)
|
||||
) { Text(stringResource(R.string.whitelist_select_all)) }
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.setWhitelistedBulk(filtered.map { it.packageName }, whitelisted = false, forWifi = forWifi) },
|
||||
modifier = Modifier.weight(1f)
|
||||
) { Text(stringResource(R.string.whitelist_select_none)) }
|
||||
}
|
||||
HorizontalDivider()
|
||||
|
||||
if (loading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(filtered, key = { it.packageName }) { app ->
|
||||
AppRow(
|
||||
app = app,
|
||||
checked = activeSet.contains(app.packageName),
|
||||
onCheckedChange = { viewModel.setWhitelisted(app.packageName, it, forWifi) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppRow(app: AppInfo, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
AppIcon(app)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(app.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
|
||||
Text(app.packageName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Checkbox(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppIcon(app: AppInfo) {
|
||||
val bitmap = remember(app.packageName) {
|
||||
app.icon?.let { runCatching { it.toBitmap(width = 96, height = 96).asImageBitmap() }.getOrNull() }
|
||||
}
|
||||
if (bitmap != null) {
|
||||
Image(bitmap = bitmap, contentDescription = null, modifier = Modifier.size(40.dp))
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant, CircleShape)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package com.jttools.datacontrol.ui.whitelist
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.jttools.datacontrol.data.AppInfo
|
||||
import com.jttools.datacontrol.data.AppSettings
|
||||
import com.jttools.datacontrol.data.InstalledAppsRepository
|
||||
import com.jttools.datacontrol.data.SettingsRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class WhitelistViewModel @Inject constructor(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val installedAppsRepository: InstalledAppsRepository
|
||||
) : ViewModel() {
|
||||
|
||||
val settings = settingsRepository.settingsFlow.stateIn(
|
||||
viewModelScope, SharingStarted.WhileSubscribed(5_000), AppSettings()
|
||||
)
|
||||
|
||||
private val _apps = MutableStateFlow<List<AppInfo>>(emptyList())
|
||||
val apps = _apps.asStateFlow()
|
||||
|
||||
private val _loading = MutableStateFlow(true)
|
||||
val loading = _loading.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
_apps.value = installedAppsRepository.listInstalledApps()
|
||||
_loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun setWhitelisted(packageName: String, whitelisted: Boolean, forWifi: Boolean) {
|
||||
viewModelScope.launch {
|
||||
val current = if (forWifi) settings.value.whitelistedPackagesWifi else settings.value.whitelistedPackages
|
||||
val updated = if (whitelisted) current + packageName else current - packageName
|
||||
if (forWifi) settingsRepository.setWhitelistedPackagesWifi(updated) else settingsRepository.setWhitelistedPackages(updated)
|
||||
}
|
||||
}
|
||||
|
||||
/** Sélectionne ou désélectionne en bloc les paquets [packageNames] (ex : la liste actuellement filtrée à l'écran). */
|
||||
fun setWhitelistedBulk(packageNames: List<String>, whitelisted: Boolean, forWifi: Boolean) {
|
||||
viewModelScope.launch {
|
||||
val current = if (forWifi) settings.value.whitelistedPackagesWifi else settings.value.whitelistedPackages
|
||||
val updated = if (whitelisted) current + packageNames else current - packageNames.toSet()
|
||||
if (forWifi) settingsRepository.setWhitelistedPackagesWifi(updated) else settingsRepository.setWhitelistedPackages(updated)
|
||||
}
|
||||
}
|
||||
|
||||
fun setWifiFilterEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch { settingsRepository.setWifiFilterEnabled(enabled) }
|
||||
}
|
||||
|
||||
fun setShowSystemApps(show: Boolean) {
|
||||
viewModelScope.launch { settingsRepository.setShowSystemApps(show) }
|
||||
}
|
||||
|
||||
/** null tant que les réglages ne sont pas chargés, sinon vrai si un mot de passe est défini. */
|
||||
val passwordSet: StateFlow<Boolean?> = settingsRepository.settingsFlow.map<AppSettings, Boolean?> { it.hasPassword }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
/** null si le mot de passe est bon, sinon le message d'erreur. */
|
||||
suspend fun checkPassword(password: String): String? = settingsRepository.checkPassword(password)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.jttools.datacontrol.vpn
|
||||
|
||||
import android.content.Context
|
||||
import android.net.VpnService
|
||||
import com.jttools.datacontrol.log.AppLog
|
||||
import com.jttools.datacontrol.remote.FamilyLink
|
||||
import com.wireguard.android.backend.BackendException
|
||||
import com.wireguard.android.backend.GoBackend
|
||||
import com.wireguard.android.backend.Tunnel
|
||||
import com.wireguard.config.Config
|
||||
import com.wireguard.crypto.KeyPair
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Tunnel WireGuard vers le serveur familyd, par la bibliothèque officielle (wireguard-go embarqué).
|
||||
* Un seul VPN sur l'appareil : tout le trafic du téléphone passe par le serveur, qui le filtre.
|
||||
*/
|
||||
@Singleton
|
||||
class TunnelManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
private val backend by lazy { GoBackend(context) }
|
||||
private val mutex = Mutex()
|
||||
private var appliedConfig: String? = null
|
||||
|
||||
private val _up = MutableStateFlow(false)
|
||||
/** Vrai tant que le tunnel est monté (faux après un retrait du VPN par l'utilisateur ou un autre VPN). */
|
||||
val up: StateFlow<Boolean> = _up.asStateFlow()
|
||||
|
||||
@Volatile var lastError: String? = null
|
||||
private set
|
||||
|
||||
private val tunnel = object : Tunnel {
|
||||
override fun getName() = "familyd"
|
||||
override fun onStateChange(newState: Tunnel.State) {
|
||||
val isUp = newState == Tunnel.State.UP
|
||||
if (!isUp && _up.value) AppLog.w("Tunnel", "Tunnel arrêté (VPN retiré ou autre VPN prioritaire)")
|
||||
_up.value = isUp
|
||||
if (!isUp) appliedConfig = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Monte (ou reconfigure) le tunnel. Sans effet si la configuration n'a pas changé et que le tunnel tourne. */
|
||||
suspend fun ensureUp(link: FamilyLink, privateKey: String): Boolean = withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
val text = link.wgQuickConfig(privateKey)
|
||||
if (text == appliedConfig && backend.getState(tunnel) == Tunnel.State.UP) return@withLock true
|
||||
try {
|
||||
val config = Config.parse(text.byteInputStream())
|
||||
backend.setState(tunnel, Tunnel.State.UP, config)
|
||||
appliedConfig = text
|
||||
lastError = null
|
||||
_up.value = true
|
||||
AppLog.i("Tunnel", "Tunnel monté vers ${link.endpoint} (adresse ${link.tunnelIp})")
|
||||
true
|
||||
} catch (e: BackendException) {
|
||||
lastError = describe(e.reason)
|
||||
AppLog.e("Tunnel", "Tunnel non monté : $lastError")
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
lastError = e.message ?: e.javaClass.simpleName
|
||||
AppLog.e("Tunnel", "Tunnel non monté", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun down() = withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
runCatching { backend.setState(tunnel, Tunnel.State.DOWN, null) }
|
||||
.onFailure { AppLog.w("Tunnel", "Arrêt du tunnel : ${it.message}") }
|
||||
appliedConfig = null
|
||||
_up.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Dernière poignée de main avec le serveur (ms), ou null si aucune. */
|
||||
fun lastHandshakeMillis(): Long? = runCatching {
|
||||
val stats = backend.getStatistics(tunnel)
|
||||
stats.peers().firstOrNull()?.let { stats.peer(it)?.latestHandshakeEpochMillis() }?.takeIf { it > 0 }
|
||||
}.getOrNull()
|
||||
|
||||
private fun describe(reason: BackendException.Reason): String = when (reason) {
|
||||
BackendException.Reason.VPN_NOT_AUTHORIZED -> "autorisation VPN absente (à redonner dans DataControl)"
|
||||
BackendException.Reason.DNS_RESOLUTION_FAILURE -> "adresse du serveur introuvable (DNS)"
|
||||
BackendException.Reason.UNABLE_TO_START_VPN -> "Android refuse de démarrer le VPN"
|
||||
BackendException.Reason.TUN_CREATION_ERROR -> "création de l'interface VPN impossible"
|
||||
else -> reason.name
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun isPrepared(context: Context): Boolean = VpnService.prepare(context) == null
|
||||
|
||||
/** Nouvelle paire de clés WireGuard : (clé privée, clé publique) en base64. */
|
||||
fun newKeyPair(): Pair<String, String> = KeyPair().let { it.privateKey.toBase64() to it.publicKey.toBase64() }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user