Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6086e8aeff | ||
|
|
82d47fcd72 |
No files matched your search
@@ -16,3 +16,6 @@ local.properties
|
||||
# Jeton Gitea (lecture/écriture) pour "make publish"/"make deploy" : local,
|
||||
# jamais versionné.
|
||||
gitea-token.properties
|
||||
|
||||
# Configuration Firebase locale (clés du projet).
|
||||
firebase.properties
|
||||
@@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## v0.1.4 – 2026-09-20
|
||||
|
||||
- Nouvel onglet **Journal** : état du VPN en direct (autorisé / tunnel monté) et historique horodaté des décisions du pare-feu (réseau détecté, mode appliqué, échec d'établissement du tunnel…), avec Copier / Partager / Effacer.
|
||||
- Limitation des applications (plages horaires, temps d'écran, applications interdites) : moteur et service d'accessibilité côté appareil, pilotés par FamilyControl.
|
||||
|
||||
## v0.1.3 – 2026-09-20
|
||||
|
||||
- **Correctif VPN** : le suivi du réseau ignore désormais le tunnel VPN lui-même (le pare-feu ne s'arrête plus tout seul), le trafic IPv6 est aussi filtré, un tunnel perdu est remonté automatiquement, et la notification signale « PARE-FEU INACTIF » si les données mobiles ne sont pas filtrées.
|
||||
- Forfait par défaut : 1 Go (1000 Mo).
|
||||
- Contrôle parental : appairage avec **FamilyControl** (Firebase), envoi de la batterie, de la position, de l'état du pare-feu et de l'historique de trafic ; les accès réseau par application se pilotent à distance.
|
||||
- Protection anti-désinstallation (administrateur d'appareil) ; sa désactivation depuis l'app demande le mot de passe.
|
||||
|
||||
## v0.1.2 – 2026-09-20
|
||||
|
||||
- Mise à jour depuis les Releases Gitea : bouton « Rechercher une mise à jour » dans l'onglet À propos (téléchargement + installation de l'APK).
|
||||
|
||||
@@ -9,6 +9,13 @@ plugins {
|
||||
alias(libs.plugins.kotlin.kapt)
|
||||
}
|
||||
|
||||
// Configuration Firebase (liaison distante avec FamilyControl) : fichier local non versionné.
|
||||
// Sans ce fichier l'app se compile normalement, mais l'appairage/le contrôle à distance sont désactivés.
|
||||
val firebaseProps = Properties().apply {
|
||||
val f = rootProject.file("firebase.properties")
|
||||
if (f.exists()) f.inputStream().use { load(it) }
|
||||
}
|
||||
|
||||
val versionProps = Properties().apply {
|
||||
load(rootProject.file("version.properties").inputStream())
|
||||
}
|
||||
@@ -29,6 +36,9 @@ android {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
|
||||
buildConfigField("String", "FIREBASE_API_KEY", "\"${firebaseProps.getProperty("apiKey", "")}\"")
|
||||
buildConfigField("String", "FIREBASE_APP_ID", "\"${firebaseProps.getProperty("appId", "")}\"")
|
||||
buildConfigField("String", "FIREBASE_PROJECT_ID", "\"${firebaseProps.getProperty("projectId", "")}\"")
|
||||
buildConfigField("String", "BUILD_DATE", "\"${SimpleDateFormat("yyyy-MM-dd").format(Date())}\"")
|
||||
}
|
||||
|
||||
@@ -87,6 +97,10 @@ dependencies {
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
implementation(platform(libs.firebase.bom))
|
||||
implementation(libs.firebase.auth)
|
||||
implementation(libs.firebase.firestore)
|
||||
implementation(libs.kotlinx.coroutines.play.services)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<!-- Position de l'appareil, transmise au parent via FamilyControl (arrière-plan compris). -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
<!-- Nécessaire pour lister TOUTES les apps installées dans l'écran "Liste blanche" -->
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
@@ -61,6 +65,40 @@
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- Limitation du lancement des applications (détection de l'application au premier plan). -->
|
||||
<service
|
||||
android:name=".guard.AppGuardService"
|
||||
android:exported="true"
|
||||
android:label="@string/guard_service_label"
|
||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accessibilityservice"
|
||||
android:resource="@xml/accessibility_guard" />
|
||||
</service>
|
||||
|
||||
<activity
|
||||
android:name=".guard.BlockedActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.DataControl" />
|
||||
|
||||
<!-- Administrateur d'appareil : bloque la désinstallation tant qu'il est actif. -->
|
||||
<receiver
|
||||
android:name=".admin.AppDeviceAdminReceiver"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_DEVICE_ADMIN">
|
||||
<meta-data
|
||||
android:name="android.app.device_admin"
|
||||
android:resource="@xml/device_admin" />
|
||||
<intent-filter>
|
||||
<action android:name="android.app.action.DEVICE_ADMIN_ENABLED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".service.BootReceiver"
|
||||
android:exported="true"
|
||||
|
||||
@@ -20,6 +20,7 @@ class DataControlApp : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
com.jttools.datacontrol.log.AppLog.init(this)
|
||||
CoroutineScope(Dispatchers.IO).launch { settingsRepository.resetPasswordOnce() }
|
||||
// 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.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.jttools.datacontrol.admin
|
||||
|
||||
import android.app.admin.DevicePolicyManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
/** Protection anti-désinstallation : tant que DataControl est administrateur de l'appareil, Android refuse de le désinstaller. */
|
||||
object AdminController {
|
||||
fun component(context: Context) = ComponentName(context, AppDeviceAdminReceiver::class.java)
|
||||
|
||||
fun isActive(context: Context): Boolean =
|
||||
context.getSystemService(DevicePolicyManager::class.java).isAdminActive(component(context))
|
||||
|
||||
fun requestIntent(context: Context): Intent =
|
||||
Intent(DevicePolicyManager.ACTION_ADD_DEVICE_ADMIN)
|
||||
.putExtra(DevicePolicyManager.EXTRA_DEVICE_ADMIN, component(context))
|
||||
.putExtra(
|
||||
DevicePolicyManager.EXTRA_ADD_EXPLANATION,
|
||||
"Empêche la désinstallation de DataControl sans mot de passe."
|
||||
)
|
||||
|
||||
/** À n'appeler qu'après vérification du mot de passe. */
|
||||
fun deactivate(context: Context) {
|
||||
context.getSystemService(DevicePolicyManager::class.java).removeActiveAdmin(component(context))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.jttools.datacontrol.admin
|
||||
|
||||
import android.app.admin.DeviceAdminReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.jttools.datacontrol.remote.RemoteSync
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AppDeviceAdminReceiver : DeviceAdminReceiver() {
|
||||
|
||||
@Inject lateinit var remoteSync: RemoteSync
|
||||
|
||||
override fun onDisableRequested(context: Context, intent: Intent): CharSequence =
|
||||
"Désactiver la protection permet de désinstaller DataControl. Cette action est signalée à FamilyControl."
|
||||
|
||||
override fun onDisabled(context: Context, intent: Intent) {
|
||||
// Prévient le parent : la protection anti-désinstallation vient d'être retirée.
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
remoteSync.pushEvent("admin_disabled", "Protection anti-désinstallation désactivée")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import java.time.LocalDate
|
||||
*/
|
||||
data class AppSettings(
|
||||
val controlEnabled: Boolean = false,
|
||||
val planLimitMo: Long = 5000L,
|
||||
val planLimitMo: Long = 1000L,
|
||||
val planPriceEuro: Double = 19.99,
|
||||
val overagePricePerMoEuro: Double = 0.01,
|
||||
val planStartDateEpochDay: Long = LocalDate.now().toEpochDay(),
|
||||
@@ -22,8 +22,17 @@ data class AppSettings(
|
||||
val passwordSalt: String? = null,
|
||||
val cutoffCycleAnchorMillis: Long? = null,
|
||||
val cutoffInPulse: Boolean = false,
|
||||
val cutoffPulseStartMillis: Long = 0L
|
||||
val cutoffPulseStartMillis: Long = 0L,
|
||||
/** Appairage avec FamilyControl (null tant que l'appareil n'est pas associé). */
|
||||
val familyId: String? = null,
|
||||
val deviceId: String? = null,
|
||||
/** Horodatage (ms) de la dernière configuration distante appliquée. */
|
||||
val remoteConfigVersion: Long = 0L,
|
||||
/** Règles d'usage (plages horaires, limites de temps, applications interdites), au format JSON. */
|
||||
val rulesJson: String? = null
|
||||
) {
|
||||
val isPaired: Boolean get() = !familyId.isNullOrEmpty() && !deviceId.isNullOrEmpty()
|
||||
|
||||
val planStartDate: LocalDate get() = LocalDate.ofEpochDay(planStartDateEpochDay)
|
||||
|
||||
/** Un mot de passe de verrouillage protège la désactivation du contrôle et l'accès aux réglages. */
|
||||
@@ -59,6 +68,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
|
||||
|
||||
@@ -14,7 +14,7 @@ class InstalledAppsRepository @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
@Suppress("DEPRECATION")
|
||||
suspend fun listInstalledApps(): List<AppInfo> = withContext(Dispatchers.Default) {
|
||||
suspend fun listInstalledApps(withIcons: Boolean = true): List<AppInfo> = withContext(Dispatchers.Default) {
|
||||
val pm = context.packageManager
|
||||
val ownPackage = context.packageName
|
||||
pm.getInstalledApplications(PackageManager.GET_META_DATA)
|
||||
@@ -24,7 +24,7 @@ class InstalledAppsRepository @Inject constructor(
|
||||
AppInfo(
|
||||
packageName = appInfo.packageName,
|
||||
label = pm.getApplicationLabel(appInfo).toString(),
|
||||
icon = runCatching { pm.getApplicationIcon(appInfo) }.getOrNull(),
|
||||
icon = if (withIcons) runCatching { pm.getApplicationIcon(appInfo) }.getOrNull() else null,
|
||||
isSystemApp = (appInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0
|
||||
)
|
||||
}
|
||||
|
||||
@@ -42,6 +42,17 @@ class SettingsRepository @Inject constructor(
|
||||
val CUTOFF_ANCHOR_MILLIS = longPreferencesKey("cutoff_anchor_millis")
|
||||
val CUTOFF_IN_PULSE = booleanPreferencesKey("cutoff_in_pulse")
|
||||
val CUTOFF_PULSE_START_MILLIS = longPreferencesKey("cutoff_pulse_start_millis")
|
||||
val FAMILY_ID = stringPreferencesKey("family_id")
|
||||
val DEVICE_ID = stringPreferencesKey("device_id")
|
||||
val REMOTE_CONFIG_VERSION = longPreferencesKey("remote_config_version")
|
||||
val RULES_JSON = stringPreferencesKey("rules_json")
|
||||
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 settingsFlow: Flow<AppSettings> = context.dataStore.data.map { prefs ->
|
||||
@@ -63,7 +74,11 @@ class SettingsRepository @Inject constructor(
|
||||
passwordSalt = prefs[Keys.PASSWORD_SALT],
|
||||
cutoffCycleAnchorMillis = prefs[Keys.CUTOFF_ANCHOR_MILLIS],
|
||||
cutoffInPulse = prefs[Keys.CUTOFF_IN_PULSE] ?: defaults.cutoffInPulse,
|
||||
cutoffPulseStartMillis = prefs[Keys.CUTOFF_PULSE_START_MILLIS] ?: defaults.cutoffPulseStartMillis
|
||||
cutoffPulseStartMillis = prefs[Keys.CUTOFF_PULSE_START_MILLIS] ?: defaults.cutoffPulseStartMillis,
|
||||
familyId = prefs[Keys.FAMILY_ID],
|
||||
deviceId = prefs[Keys.DEVICE_ID],
|
||||
remoteConfigVersion = prefs[Keys.REMOTE_CONFIG_VERSION] ?: defaults.remoteConfigVersion,
|
||||
rulesJson = prefs[Keys.RULES_JSON]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -187,4 +202,88 @@ class SettingsRepository @Inject constructor(
|
||||
prefs[Keys.CUTOFF_PULSE_START_MILLIS] = pulseStartMillis
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setPairing(familyId: String?, deviceId: String?) {
|
||||
context.dataStore.edit {
|
||||
if (familyId == null || deviceId == null) {
|
||||
it.remove(Keys.FAMILY_ID)
|
||||
it.remove(Keys.DEVICE_ID)
|
||||
it.remove(Keys.REMOTE_CONFIG_VERSION)
|
||||
it.remove(Keys.RULES_JSON)
|
||||
} else {
|
||||
it[Keys.FAMILY_ID] = familyId
|
||||
it[Keys.DEVICE_ID] = deviceId
|
||||
it[Keys.REMOTE_CONFIG_VERSION] = 0L
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique une configuration envoyée par FamilyControl. Chaque champ null est ignoré.
|
||||
* [version] (horodatage de l'écriture côté parent) évite de ré-appliquer une ancienne
|
||||
* configuration au redémarrage et d'écraser un réglage local plus récent.
|
||||
*/
|
||||
suspend fun applyRemoteConfig(
|
||||
version: Long,
|
||||
controlEnabled: Boolean?,
|
||||
whitelistedPackages: Set<String>?,
|
||||
whitelistedPackagesWifi: Set<String>?,
|
||||
wifiFilterEnabled: Boolean?,
|
||||
planLimitMo: Long?,
|
||||
cutoffIntervalMinutes: Int?,
|
||||
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
|
||||
val json = prefs[Keys.USAGE_JSON] ?: return null
|
||||
return day to json
|
||||
}
|
||||
|
||||
suspend fun saveUsage(day: String, json: String) {
|
||||
context.dataStore.edit {
|
||||
it[Keys.USAGE_DAY] = day
|
||||
it[Keys.USAGE_JSON] = json
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.jttools.datacontrol.guard
|
||||
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.telecom.TelecomManager
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Détecte l'application au premier plan (service d'accessibilité, sans lire le contenu des écrans)
|
||||
* pour appliquer les règles de [GuardEngine] : interdiction, plages horaires, limites de temps.
|
||||
* Un blocage renvoie à l'accueil et affiche [BlockedActivity].
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class AppGuardService : AccessibilityService() {
|
||||
|
||||
@Inject lateinit var engine: GuardEngine
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var exempt: Set<String> = emptySet()
|
||||
private var exemptComputedAt = 0L
|
||||
private var currentPkg: String? = null
|
||||
private var lastBlockAt = 0L
|
||||
|
||||
private val screenReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
engine.onScreen(intent?.action != Intent.ACTION_SCREEN_OFF)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceConnected() {
|
||||
super.onServiceConnected()
|
||||
engine.start()
|
||||
engine.onScreen(getSystemService(PowerManager::class.java).isInteractive)
|
||||
registerReceiver(screenReceiver, IntentFilter().apply {
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
})
|
||||
scope.launch {
|
||||
// Réévalue l'application courante : la limite peut être atteinte pendant l'utilisation.
|
||||
while (isActive) {
|
||||
delay(CHECK_INTERVAL_MS)
|
||||
engine.tick()
|
||||
currentPkg?.let { enforce(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
|
||||
if (event?.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) return
|
||||
val pkg = event.packageName?.toString() ?: return
|
||||
if (pkg in exemptPackages()) {
|
||||
currentPkg = null
|
||||
engine.onForeground(null)
|
||||
return
|
||||
}
|
||||
currentPkg = pkg
|
||||
engine.onForeground(pkg)
|
||||
enforce(pkg)
|
||||
}
|
||||
|
||||
private fun enforce(pkg: String) {
|
||||
val block = engine.check(pkg) ?: return
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastBlockAt < 2_000) return
|
||||
lastBlockAt = now
|
||||
performGlobalAction(GLOBAL_ACTION_HOME)
|
||||
val label = runCatching {
|
||||
packageManager.getApplicationLabel(packageManager.getApplicationInfo(pkg, 0)).toString()
|
||||
}.getOrDefault(pkg)
|
||||
startActivity(BlockedActivity.intent(this, label, block.message))
|
||||
}
|
||||
|
||||
/** Applications jamais bloquées ni comptées : sans elles l'appareil deviendrait inutilisable. */
|
||||
private fun exemptPackages(): Set<String> {
|
||||
val now = System.currentTimeMillis()
|
||||
if (exempt.isNotEmpty() && now - exemptComputedAt < EXEMPT_TTL_MS) return exempt
|
||||
val set = mutableSetOf(packageName, "android", "com.android.systemui", "com.android.phone", "com.android.emergency")
|
||||
runCatching {
|
||||
val home = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
|
||||
packageManager.queryIntentActivities(home, PackageManager.MATCH_DEFAULT_ONLY)
|
||||
.forEach { set += it.activityInfo.packageName }
|
||||
}
|
||||
runCatching { getSystemService(TelecomManager::class.java).defaultDialerPackage?.let { set += it } }
|
||||
runCatching {
|
||||
getSystemService(InputMethodManager::class.java).enabledInputMethodList.forEach { set += it.packageName }
|
||||
}
|
||||
exempt = set
|
||||
exemptComputedAt = now
|
||||
return set
|
||||
}
|
||||
|
||||
override fun onInterrupt() = Unit
|
||||
|
||||
override fun onDestroy() {
|
||||
runCatching { unregisterReceiver(screenReceiver) }
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHECK_INTERVAL_MS = 10_000L
|
||||
private const val EXEMPT_TTL_MS = 5 * 60_000L
|
||||
|
||||
fun isEnabled(context: Context): Boolean {
|
||||
val enabled = Settings.Secure.getString(
|
||||
context.contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES
|
||||
) ?: return false
|
||||
val me = ComponentName(context, AppGuardService::class.java)
|
||||
return enabled.split(':').any {
|
||||
it.equals(me.flattenToString(), true) || it.equals(me.flattenToShortString(), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.jttools.datacontrol.guard
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.jttools.datacontrol.ui.theme.DataControlTheme
|
||||
|
||||
/** Écran affiché par-dessus une application bloquée : explique pourquoi et renvoie à l'accueil. */
|
||||
class BlockedActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
val label = intent.getStringExtra(EXTRA_LABEL).orEmpty()
|
||||
val message = intent.getStringExtra(EXTRA_MESSAGE).orEmpty()
|
||||
setContent {
|
||||
DataControlTheme {
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.errorContainer) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(Icons.Filled.Lock, contentDescription = null, modifier = Modifier.size(72.dp))
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(label, style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(message, style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
Button(onClick = { goHome() }) { Text("Retour à l'accueil") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onBackPressed() = goHome()
|
||||
|
||||
private fun goHome() {
|
||||
startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
finish()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_LABEL = "label"
|
||||
private const val EXTRA_MESSAGE = "message"
|
||||
|
||||
fun intent(context: Context, label: String, message: String): Intent =
|
||||
Intent(context, BlockedActivity::class.java)
|
||||
.putExtra(EXTRA_LABEL, label)
|
||||
.putExtra(EXTRA_MESSAGE, message)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package com.jttools.datacontrol.guard
|
||||
|
||||
import android.os.SystemClock
|
||||
import com.jttools.datacontrol.data.SettingsRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
data class Block(val message: String)
|
||||
|
||||
data class UsageSnapshot(val date: String, val totalMs: Long, val appMs: Map<String, Long>)
|
||||
|
||||
/**
|
||||
* Cœur de la limitation d'usage : cumule le temps d'écran (total et par application) à partir des
|
||||
* changements d'application au premier plan, et décide si une application doit être bloquée
|
||||
* (interdite, hors plage horaire, temps quotidien ou temps d'application épuisé).
|
||||
*
|
||||
* L'appelant ([AppGuardService]) ne signale JAMAIS les applications exemptées (lanceur, téléphone,
|
||||
* clavier, DataControl) : elles ne comptent pas et ne sont jamais bloquées.
|
||||
*/
|
||||
@Singleton
|
||||
class GuardEngine @Inject constructor(private val settings: SettingsRepository) {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val lock = Any()
|
||||
|
||||
@Volatile var rules: Rules = Rules.NONE
|
||||
private set
|
||||
|
||||
private var started = false
|
||||
private var day = LocalDate.now().toString()
|
||||
private var totalMs = 0L
|
||||
private val appMs = HashMap<String, Long>()
|
||||
private var currentPkg: String? = null
|
||||
private var lastMark = SystemClock.elapsedRealtime()
|
||||
private var screenOn = true
|
||||
private var dirty = false
|
||||
|
||||
fun start() {
|
||||
synchronized(lock) { if (started) return; started = true }
|
||||
scope.launch {
|
||||
settings.loadUsage()?.let { (savedDay, json) -> restore(savedDay, json) }
|
||||
settings.settingsFlow.map { it.rulesJson }.distinctUntilChanged().collect { rules = Rules.parse(it) }
|
||||
}
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
delay(PERSIST_INTERVAL_MS)
|
||||
tick()
|
||||
persist()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun restore(savedDay: String, json: String) = synchronized(lock) {
|
||||
if (savedDay != LocalDate.now().toString()) return@synchronized
|
||||
runCatching {
|
||||
val o = JSONObject(json)
|
||||
day = savedDay
|
||||
totalMs = o.optLong("total")
|
||||
appMs.clear()
|
||||
o.optJSONObject("apps")?.let { a -> a.keys().forEach { k -> appMs[k] = a.optLong(k) } }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun persist() {
|
||||
val snap = synchronized(lock) { if (!dirty) return; dirty = false; usageLocked() }
|
||||
val json = JSONObject().put("total", snap.totalMs).put("apps", JSONObject(snap.appMs as Map<*, *>)).toString()
|
||||
settings.saveUsage(snap.date, json)
|
||||
}
|
||||
|
||||
fun usage(): UsageSnapshot = synchronized(lock) { flush(); usageLocked() }
|
||||
|
||||
private fun usageLocked() = UsageSnapshot(day, totalMs, HashMap(appMs))
|
||||
|
||||
/** [pkg] = application désormais au premier plan, ou null si exemptée / aucune. */
|
||||
fun onForeground(pkg: String?) = synchronized(lock) { flush(); currentPkg = pkg }
|
||||
|
||||
fun onScreen(on: Boolean) = synchronized(lock) { flush(); screenOn = on }
|
||||
|
||||
fun tick() = synchronized(lock) { flush() }
|
||||
|
||||
private fun flush() {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val today = LocalDate.now().toString()
|
||||
if (today != day) {
|
||||
day = today; totalMs = 0; appMs.clear(); dirty = true
|
||||
}
|
||||
val pkg = currentPkg
|
||||
if (pkg != null && screenOn) {
|
||||
// Plafonné : si le service a été gelé longtemps, on ne comptabilise pas tout l'intervalle.
|
||||
val delta = (now - lastMark).coerceIn(0, MAX_DELTA_MS)
|
||||
if (delta > 0) {
|
||||
totalMs += delta
|
||||
appMs[pkg] = (appMs[pkg] ?: 0L) + delta
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
lastMark = now
|
||||
}
|
||||
|
||||
/** Vrai si l'appareil est hors de toutes les plages horaires autorisées. */
|
||||
fun scheduleLocked(): Boolean = synchronized(lock) {
|
||||
val r = rules
|
||||
r.scheduleEnabled && !inWindow(r, LocalDateTime.now())
|
||||
}
|
||||
|
||||
fun cutsMobileNow(): Boolean = synchronized(lock) {
|
||||
val r = rules
|
||||
r.scheduleEnabled && r.scheduleCutsMobile && !inWindow(r, LocalDateTime.now())
|
||||
}
|
||||
|
||||
fun check(pkg: String): Block? = synchronized(lock) {
|
||||
flush()
|
||||
val r = rules
|
||||
if (r.isEmpty) return null
|
||||
val now = LocalDateTime.now()
|
||||
if (pkg in r.blocked) return Block("Cette application est bloquée par vos parents.")
|
||||
if (r.scheduleEnabled && !inWindow(r, now)) {
|
||||
return Block("L'appareil est verrouillé en dehors des plages horaires. ${nextOpening(r, now)}")
|
||||
}
|
||||
val bonusToday = r.bonusDate == day
|
||||
if (r.dailyLimitMin > 0) {
|
||||
val allowedMs = (r.dailyLimitMin + if (bonusToday) r.bonusGlobalMin else 0) * MIN_MS
|
||||
if (totalMs >= allowedMs) return Block("Le temps d'écran de la journée est écoulé.")
|
||||
}
|
||||
r.appLimits.firstOrNull { it.pkg == pkg }?.let { lim ->
|
||||
val bonus = if (bonusToday) r.bonusApps.firstOrNull { it.pkg == pkg }?.minutes ?: 0 else 0
|
||||
if ((appMs[pkg] ?: 0L) >= (lim.minutes + bonus) * MIN_MS) {
|
||||
return Block("Le temps alloué à cette application est écoulé.")
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
private fun inWindow(r: Rules, now: LocalDateTime): Boolean {
|
||||
val dow = now.dayOfWeek.value
|
||||
val prev = if (dow == 1) 7 else dow - 1
|
||||
val minute = now.hour * 60 + now.minute
|
||||
return r.windows.any { w ->
|
||||
if (w.startMin <= w.endMin) dow in w.days && minute >= w.startMin && minute < w.endMin
|
||||
else (dow in w.days && minute >= w.startMin) || (prev in w.days && minute < w.endMin)
|
||||
}
|
||||
}
|
||||
|
||||
private fun nextOpening(r: Rules, now: LocalDateTime): String {
|
||||
val minute = now.hour * 60 + now.minute
|
||||
for (offset in 0..7) {
|
||||
val dow = ((now.dayOfWeek.value - 1 + offset) % 7) + 1
|
||||
val start = r.windows.filter { dow in it.days && (offset > 0 || it.startMin > minute) }
|
||||
.minOfOrNull { it.startMin } ?: continue
|
||||
val label = "%02d:%02d".format(start / 60, start % 60)
|
||||
return if (offset == 0) "Reprise à $label." else "Reprise à $label (dans $offset j)."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MIN_MS = 60_000L
|
||||
const val MAX_DELTA_MS = 120_000L
|
||||
const val PERSIST_INTERVAL_MS = 30_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.jttools.datacontrol.guard
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Plage pendant laquelle l'appareil est utilisable. Minutes depuis minuit ; jours ISO (1 = lundi … 7 = dimanche). */
|
||||
data class TimeWindow(val days: Set<Int>, val startMin: Int, val endMin: Int)
|
||||
|
||||
data class AppLimit(val pkg: String, val minutes: Int)
|
||||
|
||||
/**
|
||||
* Règles d'usage envoyées par FamilyControl (champ `rules` de la config distante).
|
||||
* Les temps s'entendent par jour ; le bonus n'est valable que le jour [bonusDate].
|
||||
*/
|
||||
data class Rules(
|
||||
val scheduleEnabled: Boolean = false,
|
||||
val scheduleCutsMobile: Boolean = true,
|
||||
val windows: List<TimeWindow> = emptyList(),
|
||||
val dailyLimitMin: Int = 0,
|
||||
val appLimits: List<AppLimit> = emptyList(),
|
||||
val blocked: Set<String> = emptySet(),
|
||||
val bonusDate: String? = null,
|
||||
val bonusGlobalMin: Int = 0,
|
||||
val bonusApps: List<AppLimit> = emptyList()
|
||||
) {
|
||||
val isEmpty: Boolean
|
||||
get() = !scheduleEnabled && dailyLimitMin <= 0 && appLimits.isEmpty() && blocked.isEmpty()
|
||||
|
||||
companion object {
|
||||
val NONE = Rules()
|
||||
|
||||
fun parse(json: String?): Rules {
|
||||
if (json.isNullOrBlank()) return NONE
|
||||
return runCatching {
|
||||
val o = JSONObject(json)
|
||||
val sch = o.optJSONObject("schedule")
|
||||
val bonus = o.optJSONObject("bonus")
|
||||
Rules(
|
||||
scheduleEnabled = sch?.optBoolean("enabled", false) ?: false,
|
||||
scheduleCutsMobile = sch?.optBoolean("cutsMobile", true) ?: true,
|
||||
windows = sch?.optJSONArray("windows").objects().map { w ->
|
||||
TimeWindow(
|
||||
days = w.optJSONArray("days").ints().toSet(),
|
||||
startMin = w.optInt("start", 0),
|
||||
endMin = w.optInt("end", 0)
|
||||
)
|
||||
},
|
||||
dailyLimitMin = o.optInt("dailyLimitMin", 0),
|
||||
appLimits = o.optJSONArray("appLimits").objects().map { AppLimit(it.optString("pkg"), it.optInt("min")) },
|
||||
blocked = o.optJSONArray("blocked").strings().toSet(),
|
||||
bonusDate = bonus?.optString("date")?.takeIf { it.isNotEmpty() },
|
||||
bonusGlobalMin = bonus?.optInt("globalMin", 0) ?: 0,
|
||||
bonusApps = bonus?.optJSONArray("apps").objects().map { AppLimit(it.optString("pkg"), it.optInt("min")) }
|
||||
)
|
||||
}.getOrDefault(NONE)
|
||||
}
|
||||
|
||||
private fun JSONArray?.objects(): List<JSONObject> =
|
||||
if (this == null) emptyList() else (0 until length()).mapNotNull { optJSONObject(it) }
|
||||
|
||||
private fun JSONArray?.ints(): List<Int> =
|
||||
if (this == null) emptyList() else (0 until length()).map { optInt(it) }
|
||||
|
||||
private fun JSONArray?.strings(): List<String> =
|
||||
if (this == null) emptyList() else (0 until length()).map { optString(it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.jttools.datacontrol.log
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Journal de diagnostic : garde les [MAX_LINES] dernières lignes en mémoire et dans un fichier
|
||||
* (survit au redémarrage de l'app), consultable dans l'onglet « Journal ».
|
||||
*/
|
||||
object AppLog {
|
||||
private const val MAX_LINES = 400
|
||||
private const val FILE_NAME = "journal.txt"
|
||||
|
||||
private val io = Executors.newSingleThreadExecutor()
|
||||
private val fmt = SimpleDateFormat("MM-dd HH:mm:ss", Locale.FRANCE)
|
||||
private val lines = ArrayDeque<String>()
|
||||
private val _entries = MutableStateFlow<List<String>>(emptyList())
|
||||
val entries: StateFlow<List<String>> = _entries.asStateFlow()
|
||||
|
||||
@Volatile private var file: File? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
if (file != null) return
|
||||
val f = File(context.filesDir, FILE_NAME)
|
||||
file = f
|
||||
synchronized(lines) {
|
||||
runCatching { if (f.exists()) lines.addAll(f.readLines().takeLast(MAX_LINES)) }
|
||||
_entries.value = lines.toList()
|
||||
}
|
||||
i("App", "Journal ouvert")
|
||||
}
|
||||
|
||||
fun i(tag: String, message: String) = add("I", tag, message)
|
||||
fun w(tag: String, message: String) = add("W", tag, message)
|
||||
fun e(tag: String, message: String, t: Throwable? = null) =
|
||||
add("E", tag, if (t == null) message else "$message — ${t.javaClass.simpleName}: ${t.message}")
|
||||
|
||||
private fun add(level: String, tag: String, message: String) {
|
||||
Log.println(when (level) { "E" -> Log.ERROR; "W" -> Log.WARN; else -> Log.INFO }, "DataControl/$tag", message)
|
||||
val line = "${fmt.format(Date())} $level $tag: $message"
|
||||
synchronized(lines) {
|
||||
lines.addLast(line)
|
||||
while (lines.size > MAX_LINES) lines.removeFirst()
|
||||
_entries.value = lines.toList()
|
||||
val snapshot = lines.toList()
|
||||
io.execute { runCatching { file?.writeText(snapshot.joinToString("\n")) } }
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
synchronized(lines) {
|
||||
lines.clear()
|
||||
_entries.value = emptyList()
|
||||
io.execute { runCatching { file?.writeText("") } }
|
||||
}
|
||||
}
|
||||
|
||||
fun asText(): String = synchronized(lines) { lines.joinToString("\n") }
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.jttools.datacontrol.remote
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import android.net.TrafficStats
|
||||
import android.os.BatteryManager
|
||||
import android.os.Looper
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.jttools.datacontrol.BuildConfig
|
||||
import com.jttools.datacontrol.admin.AdminController
|
||||
import com.jttools.datacontrol.data.SettingsRepository
|
||||
import com.jttools.datacontrol.guard.AppGuardService
|
||||
import com.jttools.datacontrol.guard.GuardEngine
|
||||
import com.jttools.datacontrol.service.FirewallVpnService
|
||||
import com.jttools.datacontrol.service.MonitorStateHolder
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDate
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Envoie à FamilyControl (via [RemoteSync]) l'état de l'appareil : batterie, position,
|
||||
* état du pare-feu, historique de trafic. Ne fait rien tant que l'appareil n'est pas associé.
|
||||
*/
|
||||
@Singleton
|
||||
class DeviceReporter @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val remote: RemoteSync,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val stateHolder: MonitorStateHolder,
|
||||
private val guardEngine: GuardEngine
|
||||
) {
|
||||
private var started = false
|
||||
private var lastBatterySent = -1 to false
|
||||
private var lastBatterySentAt = 0L
|
||||
private var lastLocationSentAt = 0L
|
||||
private var lastStateSignature = ""
|
||||
private var lastStateSentAt = 0L
|
||||
private var locationRegistered = false
|
||||
private var lastGuardEnabled: Boolean? = null
|
||||
private var lastUsageSentTotal = -1L
|
||||
|
||||
fun start(scope: CoroutineScope) {
|
||||
if (started || !remote.available) return
|
||||
started = true
|
||||
remote.start(scope)
|
||||
|
||||
val batteryReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, intent: Intent?) {
|
||||
if (intent != null) scope.launch { pushBattery(intent) }
|
||||
}
|
||||
}
|
||||
// Récepteur enregistré dynamiquement : ACTION_BATTERY_CHANGED n'est pas livrable via le manifeste.
|
||||
ContextCompat.registerReceiver(
|
||||
context, batteryReceiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED), ContextCompat.RECEIVER_NOT_EXPORTED
|
||||
)
|
||||
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
val s = settingsRepository.settingsFlow.first()
|
||||
if (s.isPaired) {
|
||||
registerLocationUpdates(scope)
|
||||
pushState()
|
||||
pushTraffic()
|
||||
pushUsage()
|
||||
}
|
||||
delay(REPORT_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pushBattery(intent: Intent) {
|
||||
if (!settingsRepository.settingsFlow.first().isPaired) return
|
||||
val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
|
||||
val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, 100)
|
||||
if (level < 0 || scale <= 0) return
|
||||
val percent = level * 100 / scale
|
||||
val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
|
||||
val charging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL
|
||||
val now = System.currentTimeMillis()
|
||||
val changed = (percent to charging) != lastBatterySent
|
||||
if (!changed || now - lastBatterySentAt < BATTERY_MIN_INTERVAL_MS && percent > LOW_BATTERY) return
|
||||
lastBatterySent = percent to charging
|
||||
lastBatterySentAt = now
|
||||
remote.pushDevice(mapOf("battery" to mapOf("level" to percent, "charging" to charging, "at" to now)))
|
||||
}
|
||||
|
||||
private suspend fun pushState() {
|
||||
val s = settingsRepository.settingsFlow.first()
|
||||
val snap = stateHolder.snapshot.value
|
||||
val 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,
|
||||
"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 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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun registerLocationUpdates(scope: CoroutineScope) {
|
||||
if (locationRegistered) return
|
||||
val fine = ContextCompat.checkSelfPermission(context, android.Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
val coarse = ContextCompat.checkSelfPermission(context, android.Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!fine && !coarse) return
|
||||
val lm = context.getSystemService(LocationManager::class.java)
|
||||
val listener = LocationListener { loc -> scope.launch { pushLocation(loc) } }
|
||||
var registered = false
|
||||
for (provider in listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)) {
|
||||
if (provider == LocationManager.GPS_PROVIDER && !fine) continue
|
||||
if (!lm.isProviderEnabled(provider)) continue
|
||||
runCatching {
|
||||
lm.requestLocationUpdates(provider, LOCATION_INTERVAL_MS, LOCATION_MIN_DISTANCE_M, listener, Looper.getMainLooper())
|
||||
registered = true
|
||||
}
|
||||
}
|
||||
locationRegistered = registered
|
||||
}
|
||||
|
||||
private suspend fun pushLocation(loc: Location) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastLocationSentAt < LOCATION_MIN_SEND_MS) return
|
||||
lastLocationSentAt = now
|
||||
remote.pushDevice(
|
||||
mapOf(
|
||||
"location" to mapOf(
|
||||
"lat" to loc.latitude, "lng" to loc.longitude,
|
||||
"accuracy" to loc.accuracy.toDouble(), "at" to now
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val REPORT_INTERVAL_MS = 60_000L
|
||||
const val HEARTBEAT_MS = 5 * 60_000L
|
||||
const val BATTERY_MIN_INTERVAL_MS = 2 * 60_000L
|
||||
const val LOW_BATTERY = 20
|
||||
const val LOCATION_INTERVAL_MS = 5 * 60_000L
|
||||
const val LOCATION_MIN_DISTANCE_M = 50f
|
||||
const val LOCATION_MIN_SEND_MS = 60_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.jttools.datacontrol.remote
|
||||
|
||||
import android.content.Context
|
||||
import com.google.firebase.FirebaseApp
|
||||
import com.google.firebase.FirebaseOptions
|
||||
import com.google.firebase.auth.FirebaseAuth
|
||||
import com.google.firebase.firestore.FirebaseFirestore
|
||||
import com.jttools.datacontrol.BuildConfig
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Accès à Firebase (Auth anonyme + Firestore) initialisé depuis `firebase.properties`
|
||||
* (voir build.gradle.kts), sans plugin google-services : si la config est absente,
|
||||
* [enabled] est faux et toutes les fonctions distantes restent inertes.
|
||||
*/
|
||||
@Singleton
|
||||
class FirebaseGateway @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
val enabled: Boolean = BuildConfig.FIREBASE_API_KEY.isNotBlank() &&
|
||||
BuildConfig.FIREBASE_APP_ID.isNotBlank() &&
|
||||
BuildConfig.FIREBASE_PROJECT_ID.isNotBlank()
|
||||
|
||||
private val app: FirebaseApp? by lazy {
|
||||
if (!enabled) return@lazy null
|
||||
runCatching {
|
||||
FirebaseApp.getApps(context).firstOrNull()
|
||||
?: FirebaseApp.initializeApp(
|
||||
context,
|
||||
FirebaseOptions.Builder()
|
||||
.setApiKey(BuildConfig.FIREBASE_API_KEY)
|
||||
.setApplicationId(BuildConfig.FIREBASE_APP_ID)
|
||||
.setProjectId(BuildConfig.FIREBASE_PROJECT_ID)
|
||||
.build()
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
val db: FirebaseFirestore? get() = app?.let { FirebaseFirestore.getInstance(it) }
|
||||
private val auth: FirebaseAuth? get() = app?.let { FirebaseAuth.getInstance(it) }
|
||||
|
||||
/** Identifiant de l'appareil côté Firebase (connexion anonyme, persistante). Null si indisponible. */
|
||||
suspend fun ensureSignedIn(): String? {
|
||||
val a = auth ?: return null
|
||||
a.currentUser?.let { return it.uid }
|
||||
return runCatching { a.signInAnonymously().await().user?.uid }.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package com.jttools.datacontrol.remote
|
||||
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.google.firebase.firestore.DocumentReference
|
||||
import com.google.firebase.firestore.SetOptions
|
||||
import com.jttools.datacontrol.data.AppSettings
|
||||
import com.jttools.datacontrol.data.InstalledAppsRepository
|
||||
import com.jttools.datacontrol.data.SettingsRepository
|
||||
import com.jttools.datacontrol.data.TrafficDay
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Liaison avec FamilyControl via Firestore.
|
||||
*
|
||||
* Modèle (voir FamilyControl/firebase/firestore.rules) :
|
||||
* - pairings/{code} : créé par le parent, lu une fois par l'enfant
|
||||
* - families/{fid}/devices/{did} : état, batterie, position (écrit par l'appareil)
|
||||
* - families/{fid}/devices/{did}/config/remote : consignes du parent (lues par l'appareil)
|
||||
* - families/{fid}/devices/{did}/traffic/{yyyy-MM-dd}: historique de trafic
|
||||
* - families/{fid}/devices/{did}/events/{auto} : journal d'évènements
|
||||
*
|
||||
* L'appareil ne s'auto-bloque jamais : l'app DataControl est toujours exclue du tunnel VPN
|
||||
* (cf. FirewallVpnService), donc ces échanges passent même quand le réseau est coupé.
|
||||
*/
|
||||
@Singleton
|
||||
class RemoteSync @Inject constructor(
|
||||
private val gateway: FirebaseGateway,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val installedApps: InstalledAppsRepository
|
||||
) {
|
||||
val available: Boolean get() = gateway.enabled
|
||||
|
||||
private fun deviceDoc(s: AppSettings): DocumentReference? {
|
||||
val db = gateway.db ?: return null
|
||||
val fid = s.familyId ?: return null
|
||||
val did = s.deviceId ?: return null
|
||||
return db.collection("families").document(fid).collection("devices").document(did)
|
||||
}
|
||||
|
||||
/** Associe cet appareil à la famille correspondant au [code] affiché dans FamilyControl. */
|
||||
suspend fun pair(code: String, deviceName: String): Result<Unit> = runCatching {
|
||||
check(gateway.enabled) { "Firebase non configuré dans cette version" }
|
||||
val db = gateway.db!!
|
||||
val uid = gateway.ensureSignedIn() ?: error("Connexion Firebase impossible (réseau ?)")
|
||||
val clean = code.trim().uppercase()
|
||||
val pairing = db.collection("pairings").document(clean).get().await()
|
||||
check(pairing.exists()) { "Code inconnu ou expiré" }
|
||||
val familyId = pairing.getString("familyId") ?: error("Code invalide")
|
||||
check((pairing.getLong("expiresAt") ?: 0L) > System.currentTimeMillis()) { "Code expiré" }
|
||||
val deviceId = UUID.randomUUID().toString()
|
||||
db.collection("families").document(familyId).collection("devices").document(deviceId).set(
|
||||
mapOf(
|
||||
"deviceUid" to uid,
|
||||
"name" to deviceName.ifBlank { Build.MODEL },
|
||||
"model" to "${Build.MANUFACTURER} ${Build.MODEL}",
|
||||
"pairingCode" to clean,
|
||||
"pairedAt" to System.currentTimeMillis()
|
||||
)
|
||||
).await()
|
||||
settingsRepository.setPairing(familyId, deviceId)
|
||||
}
|
||||
|
||||
suspend fun unpair() {
|
||||
val s = settingsRepository.settingsFlow.first()
|
||||
pushEvent("unpaired", "Appareil dissocié depuis DataControl")
|
||||
deviceDoc(s)?.let { runCatching { it.update("unpairedAt", System.currentTimeMillis()) } }
|
||||
settingsRepository.setPairing(null, null)
|
||||
}
|
||||
|
||||
/** Écoute les consignes du parent et les applique aux réglages locaux. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun start(scope: CoroutineScope) {
|
||||
if (!gateway.enabled) return
|
||||
scope.launch {
|
||||
settingsRepository.settingsFlow
|
||||
.map { it.familyId to it.deviceId }
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { (fid, did) ->
|
||||
if (fid == null || did == null) flowOf(null) else configFlow(fid, did)
|
||||
}
|
||||
.collect { cfg -> if (cfg != null) apply(cfg) }
|
||||
}
|
||||
scope.launch {
|
||||
// Liste des applications installées, pour que le parent puisse choisir lesquelles autoriser.
|
||||
settingsRepository.settingsFlow.map { it.isPaired }.distinctUntilChanged().collect { paired ->
|
||||
if (paired) pushApps()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class Config(val map: Map<String, Any?>)
|
||||
|
||||
private fun configFlow(fid: String, did: String) = callbackFlow<Config?> {
|
||||
val db = gateway.db
|
||||
if (db == null || gateway.ensureSignedIn() == null) { trySend(null); close(); return@callbackFlow }
|
||||
val reg = db.collection("families").document(fid).collection("devices").document(did)
|
||||
.collection("config").document("remote")
|
||||
.addSnapshotListener { snap, err ->
|
||||
if (err != null) { Log.w(TAG, "config listener", err); return@addSnapshotListener }
|
||||
if (snap != null && snap.exists()) trySend(Config(snap.data.orEmpty()))
|
||||
}
|
||||
awaitClose { reg.remove() }
|
||||
}
|
||||
|
||||
private suspend fun apply(cfg: Config) {
|
||||
val m = cfg.map
|
||||
val version = (m["updatedAt"] as? Number)?.toLong() ?: return
|
||||
fun strings(key: String) = (m[key] as? List<*>)?.filterIsInstance<String>()?.toSet()
|
||||
settingsRepository.applyRemoteConfig(
|
||||
version = version,
|
||||
controlEnabled = m["controlEnabled"] as? Boolean,
|
||||
whitelistedPackages = strings("whitelistedPackages"),
|
||||
whitelistedPackagesWifi = strings("whitelistedPackagesWifi"),
|
||||
wifiFilterEnabled = m["wifiFilterEnabled"] as? Boolean,
|
||||
planLimitMo = (m["planLimitMo"] as? Number)?.toLong(),
|
||||
cutoffIntervalMinutes = (m["cutoffIntervalMinutes"] as? Number)?.toInt(),
|
||||
rulesJson = (m["rules"] as? Map<*, *>)?.let { org.json.JSONObject(it).toString() }
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun pushApps() {
|
||||
val s = settingsRepository.settingsFlow.first()
|
||||
val ref = deviceDoc(s) ?: return
|
||||
val apps = installedApps.listInstalledApps(withIcons = false).map {
|
||||
mapOf("pkg" to it.packageName, "label" to it.label, "system" to it.isSystemApp)
|
||||
}
|
||||
write(ref.collection("meta").document("apps"), mapOf("apps" to apps, "at" to System.currentTimeMillis()))
|
||||
}
|
||||
|
||||
/** Fusionne [data] dans le document de l'appareil (état, batterie, position…). */
|
||||
suspend fun pushDevice(data: Map<String, Any?>) {
|
||||
val s = settingsRepository.settingsFlow.first()
|
||||
val ref = deviceDoc(s) ?: return
|
||||
write(ref, data + ("lastSeen" to System.currentTimeMillis()))
|
||||
}
|
||||
|
||||
suspend fun pushTraffic(day: TrafficDay) {
|
||||
val s = settingsRepository.settingsFlow.first()
|
||||
val ref = deviceDoc(s) ?: return
|
||||
write(
|
||||
ref.collection("traffic").document(day.date),
|
||||
mapOf("date" to day.date, "mobileBytes" to day.mobileBytes, "wifiBytes" to day.wifiBytes,
|
||||
"at" to System.currentTimeMillis())
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun 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
|
||||
runCatching {
|
||||
ref.collection("events").add(
|
||||
mapOf("type" to type, "detail" to detail, "at" to System.currentTimeMillis())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun write(ref: DocumentReference, data: Map<String, Any?>) {
|
||||
if (gateway.ensureSignedIn() == null) return
|
||||
// Les écritures sont mises en file par le SDK hors-ligne : pas d'attente bloquante du réseau.
|
||||
ref.set(data, SetOptions.merge())
|
||||
}
|
||||
|
||||
companion object { private const val TAG = "RemoteSync" }
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import com.jttools.datacontrol.log.AppLog
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -34,13 +35,36 @@ class ConnectivityObserver @Inject constructor(
|
||||
else -> NetworkTransport.OTHER
|
||||
}
|
||||
|
||||
// Réseaux PHYSIQUES connectés. Le tunnel VPN du pare-feu est lui-même un réseau : s'il était
|
||||
// pris en compte, son apparition (transport VPN ou hérité) ferait croire à un changement de
|
||||
// réseau, le pare-feu s'arrêterait aussitôt et le VPN ne resterait jamais monté.
|
||||
val physical = mutableMapOf<Network, NetworkTransport>()
|
||||
|
||||
fun publish() {
|
||||
val values = synchronized(physical) { physical.values.toList() }
|
||||
// Android privilégie le Wi-Fi quand les deux sont connectés.
|
||||
val result = (
|
||||
when {
|
||||
NetworkTransport.WIFI in values -> NetworkTransport.WIFI
|
||||
NetworkTransport.CELLULAR in values -> NetworkTransport.CELLULAR
|
||||
values.isNotEmpty() -> NetworkTransport.OTHER
|
||||
else -> NetworkTransport.NONE
|
||||
}
|
||||
)
|
||||
trySend(result)
|
||||
}
|
||||
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
|
||||
trySend(transportOf(caps))
|
||||
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return // notre propre tunnel : ignoré
|
||||
synchronized(physical) { physical[network] = transportOf(caps) }
|
||||
AppLog.i("Réseau", "réseau physique ${transportOf(caps)} (caps mis à jour)")
|
||||
publish()
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
trySend(NetworkTransport.NONE)
|
||||
val removed = synchronized(physical) { physical.remove(network) != null }
|
||||
if (removed) publish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +72,6 @@ class ConnectivityObserver @Inject constructor(
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
|
||||
trySend(transportOf(cm.getNetworkCapabilities(cm.activeNetwork)))
|
||||
cm.registerNetworkCallback(request, callback)
|
||||
|
||||
awaitClose { cm.unregisterNetworkCallback(callback) }
|
||||
|
||||
@@ -20,6 +20,10 @@ import com.jttools.datacontrol.data.AppSettings
|
||||
import com.jttools.datacontrol.data.DataState
|
||||
import com.jttools.datacontrol.data.SettingsRepository
|
||||
import com.jttools.datacontrol.data.UsageRepository
|
||||
import com.jttools.datacontrol.guard.GuardEngine
|
||||
import com.jttools.datacontrol.log.AppLog
|
||||
import com.jttools.datacontrol.remote.DeviceReporter
|
||||
import com.jttools.datacontrol.remote.RemoteSync
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -45,6 +49,9 @@ class DataMonitorService : LifecycleService() {
|
||||
@Inject lateinit var usageRepository: UsageRepository
|
||||
@Inject lateinit var connectivityObserver: ConnectivityObserver
|
||||
@Inject lateinit var stateHolder: MonitorStateHolder
|
||||
@Inject lateinit var deviceReporter: DeviceReporter
|
||||
@Inject lateinit var remoteSync: RemoteSync
|
||||
@Inject lateinit var guardEngine: GuardEngine
|
||||
|
||||
private var latestSettings: AppSettings = AppSettings()
|
||||
private var latestTransport: NetworkTransport = NetworkTransport.NONE
|
||||
@@ -52,16 +59,22 @@ class DataMonitorService : LifecycleService() {
|
||||
|
||||
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 lastDiagnostic = ""
|
||||
|
||||
private val vpnRevokedReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
vpnRevoked = true
|
||||
lifecycleScope.launch { settingsRepository.setControlEnabled(false) }
|
||||
lifecycleScope.launch {
|
||||
settingsRepository.setControlEnabled(false)
|
||||
remoteSync.pushEvent("vpn_revoked", "Autorisation VPN retirée : le filtrage est désactivé")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +94,7 @@ class DataMonitorService : LifecycleService() {
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED
|
||||
)
|
||||
startForeground(NOTIF_ID, buildNotification(stateHolder.snapshot.value))
|
||||
AppLog.i("Service", "DataMonitorService démarré")
|
||||
|
||||
lifecycleScope.launch {
|
||||
settingsRepository.settingsFlow.collect {
|
||||
@@ -94,10 +108,21 @@ class DataMonitorService : LifecycleService() {
|
||||
settingsLoaded = true
|
||||
}
|
||||
latestSettings = it
|
||||
if (lastControlEnabled != null && lastControlEnabled != it.controlEnabled && it.isPaired) {
|
||||
launch {
|
||||
remoteSync.pushEvent(
|
||||
"control_changed",
|
||||
if (it.controlEnabled) "Contrôle activé" else "Contrôle désactivé"
|
||||
)
|
||||
}
|
||||
}
|
||||
lastControlEnabled = it.controlEnabled
|
||||
if (!it.controlEnabled) vpnRevoked = false
|
||||
tick()
|
||||
}
|
||||
}
|
||||
guardEngine.start()
|
||||
deviceReporter.start(lifecycleScope)
|
||||
lifecycleScope.launch {
|
||||
connectivityObserver.observe().collect {
|
||||
latestTransport = it
|
||||
@@ -137,7 +162,9 @@ class DataMonitorService : LifecycleService() {
|
||||
estimatedTotalPriceEuro = estimatedTotal,
|
||||
dataState = if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK,
|
||||
nextAutoResetDate = settings.nextAutoResetDate(),
|
||||
serviceRunning = false
|
||||
serviceRunning = false,
|
||||
firewallFault = false,
|
||||
transport = latestTransport
|
||||
)
|
||||
}
|
||||
updateNotification(stateHolder.snapshot.value)
|
||||
@@ -148,6 +175,7 @@ class DataMonitorService : LifecycleService() {
|
||||
|
||||
val dataState: DataState
|
||||
var nextCutoffAt: Long? = null
|
||||
var firewallFault = false
|
||||
|
||||
when {
|
||||
latestTransport == NetworkTransport.WIFI -> {
|
||||
@@ -160,44 +188,60 @@ class DataMonitorService : LifecycleService() {
|
||||
dataState = DataState.WIFI
|
||||
}
|
||||
latestTransport == NetworkTransport.CELLULAR && !vpnRevoked && FirewallVpnService.isPrepared(this) -> {
|
||||
// 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
|
||||
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()
|
||||
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)
|
||||
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
|
||||
|
||||
}
|
||||
nextCutoffAt = if (inPulse) pulseStartMillis + PULSE_DURATION_MS else anchor + intervalMs
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
logDiagnostic(settings)
|
||||
|
||||
stateHolder.update {
|
||||
it.copy(
|
||||
consumedMo = consumedMo,
|
||||
@@ -207,12 +251,32 @@ class DataMonitorService : LifecycleService() {
|
||||
dataState = dataState,
|
||||
nextAutoResetDate = settings.nextAutoResetDate(),
|
||||
nextCutoffAtMillis = nextCutoffAt,
|
||||
serviceRunning = true
|
||||
serviceRunning = true,
|
||||
firewallFault = firewallFault,
|
||||
transport = latestTransport
|
||||
)
|
||||
}
|
||||
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)
|
||||
val expected = when {
|
||||
!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})"
|
||||
}
|
||||
val diag = "réseau=$latestTransport contrôle=${settings.controlEnabled} vpnAutorisé=$prepared révoqué=$vpnRevoked mode=${lastAppliedMode ?: "—"} → $expected"
|
||||
if (diag != lastDiagnostic) {
|
||||
lastDiagnostic = diag
|
||||
AppLog.i("Pare-feu", diag)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetCutoffCycle() {
|
||||
if (cutoffCycleAnchorMillis == null && !inPulse) return
|
||||
cutoffCycleAnchorMillis = null
|
||||
@@ -236,9 +300,15 @@ class DataMonitorService : LifecycleService() {
|
||||
}
|
||||
|
||||
private fun applyVpnMode(mode: VpnMode, whitelist: Set<String>) {
|
||||
if (mode == lastAppliedMode && (mode != VpnMode.FILTER || whitelist == lastAppliedWhitelist)) return
|
||||
// Si le tunnel a disparu (service VPN tué/relancé, établissement échoué), on le remonte au
|
||||
// tick suivant au lieu de croire qu'il est toujours actif.
|
||||
val tunnelLost = mode != VpnMode.STOPPED && !FirewallVpnService.tunnelActive &&
|
||||
System.currentTimeMillis() - lastApplyMillis > TUNNEL_RETRY_DELAY_MS
|
||||
if (!tunnelLost && mode == lastAppliedMode && (mode != VpnMode.FILTER || whitelist == lastAppliedWhitelist)) return
|
||||
lastAppliedMode = mode
|
||||
lastAppliedWhitelist = whitelist
|
||||
lastApplyMillis = System.currentTimeMillis()
|
||||
AppLog.i("Pare-feu", "→ mode $mode (${whitelist.size} app(s) autorisée(s))")
|
||||
when (mode) {
|
||||
VpnMode.STOPPED -> ContextCompat.startForegroundService(this, FirewallVpnService.stopIntent(this))
|
||||
VpnMode.FILTER -> ContextCompat.startForegroundService(
|
||||
@@ -262,6 +332,7 @@ class DataMonitorService : LifecycleService() {
|
||||
}
|
||||
|
||||
val (emoji, stateLabel, color) = when {
|
||||
snapshot.firewallFault -> Triple("⚠️", getString(R.string.dashboard_state_warning), Color.parseColor("#C62828"))
|
||||
!snapshot.serviceRunning -> Triple("⚪", getString(R.string.dashboard_control_disabled), Color.parseColor("#757575"))
|
||||
snapshot.dataState == DataState.OK -> Triple("🟢", getString(R.string.dashboard_state_ok), Color.parseColor("#2E7D32"))
|
||||
snapshot.dataState == DataState.WARNING -> Triple("🟠", getString(R.string.dashboard_state_warning), Color.parseColor("#EF6C00"))
|
||||
@@ -274,7 +345,8 @@ class DataMonitorService : LifecycleService() {
|
||||
} else "—"
|
||||
val priceText = String.format("%.2f", snapshot.estimatedTotalPriceEuro)
|
||||
|
||||
val usageText = "$emoji $stateLabel · ${snapshot.consumedMo}/${snapshot.planLimitMo} Mo ($percentText)"
|
||||
val warn = if (snapshot.firewallFault) "⚠️ PARE-FEU INACTIF — données non filtrées · " else ""
|
||||
val usageText = "$warn$emoji $stateLabel · ${snapshot.consumedMo}/${snapshot.planLimitMo} Mo ($percentText)"
|
||||
val costText = "${getString(R.string.dashboard_cost_total)} : $priceText €"
|
||||
// Le total est aussi dans la vue repliée : sinon il n'est visible qu'une fois la notification dépliée.
|
||||
val contentText = "$usageText · $priceText €"
|
||||
@@ -313,6 +385,7 @@ class DataMonitorService : LifecycleService() {
|
||||
private const val NOTIF_ID = 1001
|
||||
private const val TICK_INTERVAL_MS = 20_000L
|
||||
private const val PULSE_DURATION_MS = 30_000L
|
||||
private const val TUNNEL_RETRY_DELAY_MS = 5_000L
|
||||
private const val WARNING_THRESHOLD = 0.8f
|
||||
|
||||
fun start(context: Context) {
|
||||
|
||||
@@ -14,6 +14,7 @@ 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
|
||||
|
||||
/**
|
||||
@@ -38,8 +39,10 @@ class FirewallVpnService : VpnService() {
|
||||
// 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.
|
||||
startForeground(NOTIF_ID, buildNotification())
|
||||
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
|
||||
@@ -54,11 +57,15 @@ class FirewallVpnService : VpnService() {
|
||||
|
||||
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.
|
||||
@@ -67,11 +74,17 @@ class FirewallVpnService : VpnService() {
|
||||
|
||||
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 ?: return
|
||||
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()
|
||||
@@ -102,6 +115,7 @@ class FirewallVpnService : VpnService() {
|
||||
|
||||
private fun stopTunnel(keepForeground: Boolean = false) {
|
||||
running = false
|
||||
tunnelActive = false
|
||||
readerThread?.interrupt()
|
||||
readerThread = null
|
||||
tunInterface?.let { runCatching { it.close() } }
|
||||
@@ -112,6 +126,7 @@ class FirewallVpnService : VpnService() {
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -150,6 +165,11 @@ class FirewallVpnService : VpnService() {
|
||||
private const val CHANNEL_ID = "datacontrol_vpn"
|
||||
private const val NOTIF_ID = 2001
|
||||
private const val TUNNEL_ADDRESS = "10.10.10.1"
|
||||
private const val TUNNEL_ADDRESS_V6 = "fd00:10:10::1"
|
||||
|
||||
/** Vrai tant que le tunnel est réellement établi (faux si le service a été tué/relancé sans intent). */
|
||||
@Volatile var tunnelActive = false
|
||||
private set
|
||||
|
||||
const val ACTION_START_FILTER = "com.jttools.datacontrol.action.VPN_START_FILTER"
|
||||
const val ACTION_START_BLOCK_ALL = "com.jttools.datacontrol.action.VPN_START_BLOCK_ALL"
|
||||
|
||||
@@ -15,7 +15,10 @@ data class MonitorSnapshot(
|
||||
val dataState: DataState = DataState.OK,
|
||||
val nextAutoResetDate: LocalDate = LocalDate.now(),
|
||||
val nextCutoffAtMillis: Long? = null,
|
||||
val serviceRunning: Boolean = false
|
||||
val serviceRunning: Boolean = false,
|
||||
/** Contrôle activé sur données mobiles mais tunnel VPN non monté : les données ne sont PAS filtrées. */
|
||||
val firewallFault: Boolean = false,
|
||||
val transport: NetworkTransport = NetworkTransport.NONE
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,6 +34,60 @@ class SystemActions(private val activity: ComponentActivity) {
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted -> onNotificationResult?.invoke(granted) }
|
||||
|
||||
private var onLocationResult: ((Boolean) -> Unit)? = null
|
||||
private var onBackgroundLocationResult: ((Boolean) -> Unit)? = null
|
||||
private var onAdminResult: ((Boolean) -> Unit)? = null
|
||||
|
||||
private val locationLauncher: ActivityResultLauncher<Array<String>> = activity.registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { granted -> onLocationResult?.invoke(granted.values.any { it }) }
|
||||
|
||||
private val backgroundLocationLauncher: ActivityResultLauncher<String> = activity.registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted -> onBackgroundLocationResult?.invoke(granted) }
|
||||
|
||||
private val adminLauncher: ActivityResultLauncher<Intent> = activity.registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result -> onAdminResult?.invoke(result.resultCode == android.app.Activity.RESULT_OK) }
|
||||
|
||||
fun hasLocationPermission(): Boolean = ContextCompat.checkSelfPermission(
|
||||
activity, android.Manifest.permission.ACCESS_FINE_LOCATION
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
|
||||
fun hasBackgroundLocationPermission(): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || ContextCompat.checkSelfPermission(
|
||||
activity, android.Manifest.permission.ACCESS_BACKGROUND_LOCATION
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
|
||||
fun requestLocationPermission(onResult: (Boolean) -> Unit) {
|
||||
onLocationResult = onResult
|
||||
locationLauncher.launch(
|
||||
arrayOf(android.Manifest.permission.ACCESS_FINE_LOCATION, android.Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
)
|
||||
}
|
||||
|
||||
/** À demander APRÈS la position au premier plan (exigence Android 11+). */
|
||||
fun requestBackgroundLocationPermission(onResult: (Boolean) -> Unit) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { onResult(true); return }
|
||||
onBackgroundLocationResult = onResult
|
||||
backgroundLocationLauncher.launch(android.Manifest.permission.ACCESS_BACKGROUND_LOCATION)
|
||||
}
|
||||
|
||||
fun isGuardEnabled(): Boolean = com.jttools.datacontrol.guard.AppGuardService.isEnabled(activity)
|
||||
|
||||
fun openAccessibilitySettings() {
|
||||
activity.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
||||
}
|
||||
|
||||
fun isAdminActive(): Boolean = com.jttools.datacontrol.admin.AdminController.isActive(activity)
|
||||
|
||||
fun requestAdmin(onResult: (Boolean) -> Unit) {
|
||||
onAdminResult = onResult
|
||||
adminLauncher.launch(com.jttools.datacontrol.admin.AdminController.requestIntent(activity))
|
||||
}
|
||||
|
||||
fun deactivateAdmin() = com.jttools.datacontrol.admin.AdminController.deactivate(activity)
|
||||
|
||||
fun isVpnPrepared(): Boolean = FirewallVpnService.isPrepared(activity)
|
||||
|
||||
fun requestVpnPermission(onResult: (Boolean) -> Unit) {
|
||||
|
||||
@@ -163,6 +163,7 @@ fun DashboardScreen(systemActions: SystemActions) {
|
||||
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"}")
|
||||
if (granted) {
|
||||
viewModel.setControlEnabled(true)
|
||||
DataMonitorService.start(context)
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
package com.jttools.datacontrol.ui.family
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.jttools.datacontrol.R
|
||||
import com.jttools.datacontrol.ui.SystemActions
|
||||
import com.jttools.datacontrol.ui.theme.StateOk
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Carte « Contrôle parental » des réglages : appairage FamilyControl, anti-désinstallation, position. */
|
||||
@Composable
|
||||
fun FamilySection(systemActions: SystemActions) {
|
||||
val vm: FamilyViewModel = hiltViewModel()
|
||||
val settings by vm.settings.collectAsStateWithLifecycle()
|
||||
val pairing by vm.pairing.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var adminActive by remember { mutableStateOf(systemActions.isAdminActive()) }
|
||||
var locationGranted by remember { mutableStateOf(systemActions.hasLocationPermission()) }
|
||||
var backgroundGranted by remember { mutableStateOf(systemActions.hasBackgroundLocationPermission()) }
|
||||
var guardEnabled by remember { mutableStateOf(systemActions.isGuardEnabled()) }
|
||||
var code by remember { mutableStateOf("") }
|
||||
var deviceName by remember { mutableStateOf(android.os.Build.MODEL) }
|
||||
|
||||
// Action sensible en attente de confirmation par mot de passe : "admin" ou "unpair".
|
||||
var pendingAction by remember { mutableStateOf<String?>(null) }
|
||||
var pwd by remember { mutableStateOf("") }
|
||||
var pwdError by remember { mutableStateOf(false) }
|
||||
|
||||
// Au retour des écrans système (accessibilité, permissions, admin), on relit l'état réel.
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||
adminActive = systemActions.isAdminActive()
|
||||
locationGranted = systemActions.hasLocationPermission()
|
||||
backgroundGranted = systemActions.hasBackgroundLocationPermission()
|
||||
guardEnabled = systemActions.isGuardEnabled()
|
||||
}
|
||||
|
||||
fun runProtected(action: String, block: () -> Unit) {
|
||||
if (settings.hasPassword) { pendingAction = action; pwd = ""; pwdError = false } else block()
|
||||
}
|
||||
|
||||
fun execute(action: String) {
|
||||
when (action) {
|
||||
"admin" -> { systemActions.deactivateAdmin(); adminActive = systemActions.isAdminActive() }
|
||||
"unpair" -> vm.unpair()
|
||||
}
|
||||
}
|
||||
|
||||
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
|
||||
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.family_title),
|
||||
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
HorizontalDivider()
|
||||
|
||||
// --- Appairage ---
|
||||
when {
|
||||
!vm.remoteAvailable -> Text(
|
||||
stringResource(R.string.family_unavailable),
|
||||
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
settings.isPaired -> {
|
||||
Text(stringResource(R.string.family_paired), color = StateOk, style = MaterialTheme.typography.bodyMedium)
|
||||
OutlinedButton(onClick = { runProtected("unpair") { execute("unpair") } }) {
|
||||
Text(stringResource(R.string.family_unpair))
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Text(stringResource(R.string.family_pair_help), style = MaterialTheme.typography.bodyMedium)
|
||||
OutlinedTextField(
|
||||
value = code, onValueChange = { code = it.uppercase().take(8) },
|
||||
label = { Text(stringResource(R.string.family_code)) },
|
||||
singleLine = true, modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = deviceName, onValueChange = { deviceName = it },
|
||||
label = { Text(stringResource(R.string.family_device_name)) },
|
||||
singleLine = true, modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
pairing.error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Button(
|
||||
enabled = code.length >= 6 && !pairing.busy,
|
||||
onClick = { vm.pair(code, deviceName) }
|
||||
) { Text(stringResource(R.string.family_pair)) }
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// --- Anti-désinstallation ---
|
||||
Text(stringResource(R.string.family_admin_title), fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
stringResource(if (adminActive) R.string.family_admin_on else R.string.family_admin_off),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (adminActive) StateOk else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (adminActive) {
|
||||
OutlinedButton(onClick = { runProtected("admin") { execute("admin") } }) {
|
||||
Text(stringResource(R.string.family_admin_disable))
|
||||
}
|
||||
} else {
|
||||
Button(onClick = { systemActions.requestAdmin { adminActive = systemActions.isAdminActive() } }) {
|
||||
Text(stringResource(R.string.family_admin_enable))
|
||||
}
|
||||
}
|
||||
if (!settings.hasPassword) {
|
||||
Text(
|
||||
stringResource(R.string.family_admin_needs_password),
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// --- Limitation des applications ---
|
||||
Text(stringResource(R.string.family_guard_title), fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
stringResource(R.string.family_guard_desc),
|
||||
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (guardEnabled) {
|
||||
Text(stringResource(R.string.settings_perm_granted), color = StateOk)
|
||||
} else {
|
||||
Button(onClick = { systemActions.openAccessibilitySettings() }) {
|
||||
Text(stringResource(R.string.family_guard_enable))
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.family_guard_hint),
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// --- Position ---
|
||||
Text(stringResource(R.string.family_location_title), fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
stringResource(R.string.family_location_desc),
|
||||
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
when {
|
||||
!locationGranted -> Button(onClick = {
|
||||
systemActions.requestLocationPermission { locationGranted = it }
|
||||
}) { Text(stringResource(R.string.family_location_grant)) }
|
||||
!backgroundGranted -> Button(onClick = {
|
||||
systemActions.requestBackgroundLocationPermission {
|
||||
backgroundGranted = systemActions.hasBackgroundLocationPermission()
|
||||
}
|
||||
}) { Text(stringResource(R.string.family_location_background)) }
|
||||
else -> Text(stringResource(R.string.settings_perm_granted), color = StateOk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pendingAction?.let { action ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingAction = null },
|
||||
title = { Text(stringResource(R.string.password_gate_title)) },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = pwd, onValueChange = { pwd = it; pwdError = false },
|
||||
label = { Text(stringResource(R.string.password_field_label)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
isError = pwdError, singleLine = true, modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
if (pwdError) Text(
|
||||
stringResource(R.string.password_incorrect),
|
||||
color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
scope.launch {
|
||||
if (vm.verifyPassword(pwd)) { pendingAction = null; execute(action) } else pwdError = true
|
||||
}
|
||||
}) { Text(stringResource(R.string.common_confirm)) }
|
||||
},
|
||||
dismissButton = {
|
||||
OutlinedButton(onClick = { pendingAction = null }) { Text(stringResource(R.string.common_cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.jttools.datacontrol.ui.family
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.jttools.datacontrol.data.AppSettings
|
||||
import com.jttools.datacontrol.data.SettingsRepository
|
||||
import com.jttools.datacontrol.remote.RemoteSync
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class PairingUiState(val busy: Boolean = false, val error: String? = null)
|
||||
|
||||
@HiltViewModel
|
||||
class FamilyViewModel @Inject constructor(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val remoteSync: RemoteSync
|
||||
) : ViewModel() {
|
||||
|
||||
val settings = settingsRepository.settingsFlow.stateIn(
|
||||
viewModelScope, SharingStarted.WhileSubscribed(5_000), AppSettings()
|
||||
)
|
||||
|
||||
val remoteAvailable: Boolean get() = remoteSync.available
|
||||
|
||||
private val _pairing = MutableStateFlow(PairingUiState())
|
||||
val pairing: StateFlow<PairingUiState> = _pairing.asStateFlow()
|
||||
|
||||
fun pair(code: String, deviceName: String) {
|
||||
_pairing.value = PairingUiState(busy = true)
|
||||
viewModelScope.launch {
|
||||
val result = remoteSync.pair(code, deviceName)
|
||||
_pairing.value = PairingUiState(error = result.exceptionOrNull()?.message)
|
||||
}
|
||||
}
|
||||
|
||||
fun unpair() {
|
||||
viewModelScope.launch { remoteSync.unpair() }
|
||||
}
|
||||
|
||||
suspend fun verifyPassword(password: String): Boolean = settingsRepository.verifyPassword(password)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.jttools.datacontrol.ui.journal
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
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 kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun JournalScreen() {
|
||||
val context = LocalContext.current
|
||||
val entries by AppLog.entries.collectAsStateWithLifecycle()
|
||||
|
||||
// É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)) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) { live = liveStatus(context); delay(1000) }
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Journal", style = MaterialTheme.typography.headlineMedium)
|
||||
Card(shape = RoundedCornerShape(16.dp)) {
|
||||
Text(live, fontFamily = FontFamily.Monospace, fontSize = 12.sp, modifier = Modifier.padding(12.dp))
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = {
|
||||
val cm = context.getSystemService(ClipboardManager::class.java)
|
||||
cm.setPrimaryClip(ClipData.newPlainText("Journal DataControl", live + "\n\n" + AppLog.asText()))
|
||||
}) { Text("Copier") }
|
||||
OutlinedButton(onClick = {
|
||||
val send = Intent(Intent.ACTION_SEND).setType("text/plain")
|
||||
.putExtra(Intent.EXTRA_TEXT, live + "\n\n" + AppLog.asText())
|
||||
context.startActivity(Intent.createChooser(send, "Partager le journal").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}) { Text("Partager") }
|
||||
OutlinedButton(onClick = { AppLog.clear() }) { Text("Effacer") }
|
||||
}
|
||||
LazyColumn(Modifier.fillMaxWidth().weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
items(entries.asReversed()) { line ->
|
||||
val color = when {
|
||||
" E " in line.take(16) -> Color(0xFFC62828)
|
||||
" W " in line.take(16) -> Color(0xFFEF6C00)
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
Text(line, color = color, fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun liveStatus(context: Context): String = buildString {
|
||||
appendLine("VPN autorisé (prepare) : ${FirewallVpnService.isPrepared(context)}")
|
||||
appendLine("Tunnel VPN monté : ${FirewallVpnService.tunnelActive}")
|
||||
append("Attendu : le tunnel n'est monté QUE sur données mobiles, contrôle activé.")
|
||||
}
|
||||
@@ -2,6 +2,7 @@ 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
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
@@ -25,6 +26,7 @@ import com.jttools.datacontrol.R
|
||||
import com.jttools.datacontrol.ui.SystemActions
|
||||
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
|
||||
|
||||
@@ -32,10 +34,11 @@ private sealed class Destination(val route: String, val labelRes: Int, val icon:
|
||||
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.About)
|
||||
private val destinations = listOf(Destination.Dashboard, Destination.Settings, Destination.Whitelist, Destination.Journal, Destination.About)
|
||||
|
||||
@Composable
|
||||
fun DataControlNavHost(systemActions: SystemActions) {
|
||||
@@ -72,6 +75,7 @@ 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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +222,8 @@ private fun SettingsContent(
|
||||
}
|
||||
}
|
||||
|
||||
com.jttools.datacontrol.ui.family.FamilySection(systemActions)
|
||||
|
||||
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
|
||||
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(stringResource(R.string.settings_section_security), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<string name="nav_dashboard">Tableau de bord</string>
|
||||
<string name="nav_settings">Réglages</string>
|
||||
<string name="nav_whitelist">Liste blanche</string>
|
||||
<string name="nav_journal">Journal</string>
|
||||
<string name="nav_about">À propos</string>
|
||||
|
||||
<!-- Dashboard -->
|
||||
@@ -123,4 +124,29 @@
|
||||
<string name="unit_euro">€</string>
|
||||
<string name="common_cancel">Annuler</string>
|
||||
<string name="common_confirm">Confirmer</string>
|
||||
|
||||
<string name="family_title">Contrôle parental (FamilyControl)</string>
|
||||
<string name="family_unavailable">Liaison FamilyControl non configurée dans cette version (fichier firebase.properties absent à la compilation).</string>
|
||||
<string name="family_paired">Appareil associé à FamilyControl : le parent peut gérer les accès réseau, voir la batterie, la position et l\'historique de trafic.</string>
|
||||
<string name="family_unpair">Dissocier cet appareil</string>
|
||||
<string name="family_pair_help">Saisissez le code d\'appairage affiché dans FamilyControl pour associer cet appareil.</string>
|
||||
<string name="family_code">Code d\'appairage</string>
|
||||
<string name="family_device_name">Nom de l\'appareil</string>
|
||||
<string name="family_pair">Associer</string>
|
||||
<string name="family_admin_title">Protection anti-désinstallation</string>
|
||||
<string name="family_admin_on">Active : DataControl ne peut pas être désinstallé sans désactiver d\'abord cette protection (mot de passe demandé ici).</string>
|
||||
<string name="family_admin_off">Inactive : l\'application peut être désinstallée librement.</string>
|
||||
<string name="family_admin_enable">Activer la protection</string>
|
||||
<string name="family_admin_disable">Désactiver la protection</string>
|
||||
<string name="family_admin_needs_password">Définissez un mot de passe (section Sécurité) : sans lui, la protection peut être retirée librement.</string>
|
||||
<string name="family_location_title">Position de l\'appareil</string>
|
||||
<string name="family_location_desc">Partagée avec le parent via FamilyControl, y compris en arrière-plan (choisir « Toujours autoriser »).</string>
|
||||
<string name="family_location_grant">Autoriser la position</string>
|
||||
<string name="family_location_background">Autoriser en arrière-plan</string>
|
||||
<string name="guard_service_label">DataControl — limitation des applications</string>
|
||||
<string name="guard_service_description">Détecte l\'application utilisée pour appliquer les plages horaires et les limites de temps définies par les parents. Le contenu des écrans n\'est pas lu.</string>
|
||||
<string name="family_guard_title">Limitation des applications</string>
|
||||
<string name="family_guard_desc">Plages horaires, temps d\'écran et blocage d\'applications décidés par le parent. Nécessite le service d\'accessibilité « DataControl ».</string>
|
||||
<string name="family_guard_hint">Android 13+ : si l\'activation est grisée, ouvrez Infos sur l\'application → ⋮ → « Autoriser les paramètres restreints », puis réessayez.</string>
|
||||
<string name="family_guard_enable">Activer le service</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:accessibilityEventTypes="typeWindowStateChanged"
|
||||
android:accessibilityFeedbackType="feedbackGeneric"
|
||||
android:canRetrieveWindowContent="false"
|
||||
android:description="@string/guard_service_description"
|
||||
android:notificationTimeout="100" />
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<device-admin>
|
||||
<uses-policies />
|
||||
</device-admin>
|
||||
@@ -0,0 +1,5 @@
|
||||
# Copier vers firebase.properties (ignoré par git) et renseigner les valeurs de l'app Android
|
||||
# déclarée dans la console Firebase (Paramètres du projet > Vos applications).
|
||||
apiKey=
|
||||
appId=
|
||||
projectId=
|
||||
@@ -16,6 +16,8 @@ hiltNavigationCompose = "1.2.0"
|
||||
datastore = "1.1.1"
|
||||
coroutines = "1.8.1"
|
||||
workManager = "2.9.1"
|
||||
firebaseBom = "33.1.2"
|
||||
coroutinesPlayServices = "1.8.1"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -42,6 +44,10 @@ androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navig
|
||||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" }
|
||||
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
|
||||
firebase-auth = { group = "com.google.firebase", name = "firebase-auth" }
|
||||
firebase-firestore = { group = "com.google.firebase", name = "firebase-firestore" }
|
||||
kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutinesPlayServices" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
versionName=0.1.2
|
||||
versionCode=3
|
||||
versionName=0.1.4
|
||||
versionCode=5
|
||||
Reference in new issue
Block a user