3 Commits
38 changed files with 2073 additions and 46 deletions

No files matched your search

+3
View File
@@ -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
+19
View File
@@ -1,5 +1,24 @@
# 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).
- Notification : le total estimé apparaît aussi dans la vue repliée.
- Pronote (`com.indexeducation.pronote`) est dans la liste blanche par défaut (données mobiles et Wi-Fi).
- Réinitialisation unique du mot de passe de verrouillage au prochain lancement.
## v0.1.1 – 2026-08-30
- `make publish`/`make deploy` : publie l'APK signé comme asset d'une Release Gitea (tag + notes extraites du CHANGELOG), sur le même modèle que les autres apps Android du même auteur.
+14
View File
@@ -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)
+50
View File
@@ -9,6 +9,11 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<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" />
@@ -49,6 +54,51 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- FileProvider pour installer l'APK de mise à jour téléchargé -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
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"
@@ -4,15 +4,24 @@ import android.app.Application
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import com.jttools.datacontrol.data.SettingsRepository
import com.jttools.datacontrol.service.DataMonitorRestartWorker
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltAndroidApp
class DataControlApp : Application() {
@Inject lateinit var settingsRepository: SettingsRepository
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.
val request = PeriodicWorkRequestBuilder<DataMonitorRestartWorker>(15, TimeUnit.MINUTES).build()
@@ -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,23 +7,32 @@ 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(),
val autoResetEnabled: Boolean = true,
val cutoffIntervalMinutes: Int = 15,
val baselineMobileBytesAtReset: Long = 0L,
val whitelistedPackages: Set<String> = emptySet(),
val whitelistedPackagesWifi: Set<String> = emptySet(),
val whitelistedPackages: Set<String> = DEFAULT_WHITELIST,
val whitelistedPackagesWifi: Set<String> = DEFAULT_WHITELIST,
val wifiFilterEnabled: Boolean = false,
val showSystemApps: Boolean = false,
val passwordHash: String? = null,
val passwordSalt: String? = null,
val cutoffCycleAnchorMillis: Long? = null,
val cutoffInPulse: Boolean = false,
val cutoffPulseStartMillis: Long = 0L
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. */
@@ -38,6 +47,13 @@ data class AppSettings(
}
return next
}
companion object {
const val PRONOTE_PACKAGE = "com.indexeducation.pronote"
/** Applications autorisées par défaut (tant que l'utilisateur n'a pas modifié la liste blanche). */
val DEFAULT_WHITELIST: Set<String> = setOf(PRONOTE_PACKAGE)
}
}
/** État calculé de la consommation, dérivé des réglages + de la mesure de données réelle. */
@@ -52,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
)
}
@@ -38,9 +38,21 @@ class SettingsRepository @Inject constructor(
val SHOW_SYSTEM_APPS = booleanPreferencesKey("show_system_apps")
val PASSWORD_HASH = stringPreferencesKey("password_hash")
val PASSWORD_SALT = stringPreferencesKey("password_salt")
val PASSWORD_RESET_V1_DONE = booleanPreferencesKey("password_reset_v1_done")
val CUTOFF_ANCHOR_MILLIS = longPreferencesKey("cutoff_anchor_millis")
val CUTOFF_IN_PULSE = booleanPreferencesKey("cutoff_in_pulse")
val CUTOFF_PULSE_START_MILLIS = longPreferencesKey("cutoff_pulse_start_millis")
val FAMILY_ID = stringPreferencesKey("family_id")
val DEVICE_ID = stringPreferencesKey("device_id")
val REMOTE_CONFIG_VERSION = longPreferencesKey("remote_config_version")
val RULES_JSON = stringPreferencesKey("rules_json")
val 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 ->
@@ -62,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]
)
}
@@ -152,6 +168,20 @@ class SettingsRepository @Inject constructor(
}
}
/**
* Réinitialisation unique (v0.1.2) du mot de passe de verrouillage : le supprime une seule fois,
* puis mémorise qu'elle a eu lieu pour ne pas effacer un mot de passe défini ensuite.
*/
suspend fun resetPasswordOnce() {
context.dataStore.edit {
if (it[Keys.PASSWORD_RESET_V1_DONE] != true) {
it.remove(Keys.PASSWORD_HASH)
it.remove(Keys.PASSWORD_SALT)
it[Keys.PASSWORD_RESET_V1_DONE] = true
}
}
}
/** Vrai si aucun mot de passe n'est défini, ou si [password] correspond au mot de passe stocké. */
suspend fun verifyPassword(password: String): Boolean {
val prefs = context.dataStore.data.first()
@@ -172,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.data
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
/**
* Mise à jour via les Releases Gitea du dépôt (dernière release publiée + son asset .apk).
* Le dépôt est public : aucun jeton n'est nécessaire.
*/
class UpdateManager(private val context: Context, private val currentVersion: String) {
data class UpdateInfo(val versionName: String, val apkUrl: String, val changelog: String)
sealed interface CheckResult {
data object UpToDate : CheckResult
data class Available(val info: UpdateInfo) : CheckResult
data class Error(val message: String) : CheckResult
}
suspend fun check(): CheckResult = withContext(Dispatchers.IO) {
try {
val conn = open(RELEASES_URL)
try {
if (conn.responseCode != HttpURLConnection.HTTP_OK) {
return@withContext CheckResult.Error("HTTP ${conn.responseCode}")
}
val json = JSONObject(conn.inputStream.bufferedReader().use { it.readText() })
val version = json.optString("tag_name").removePrefix("v")
if (version.isBlank()) return@withContext CheckResult.Error("Release sans tag")
val assets = json.optJSONArray("assets")
var apkUrl = ""
if (assets != null) {
for (i in 0 until assets.length()) {
val asset = assets.getJSONObject(i)
if (asset.optString("name").endsWith(".apk")) {
apkUrl = asset.optString("browser_download_url")
break
}
}
}
when {
!isNewer(version, currentVersion) -> CheckResult.UpToDate
apkUrl.isBlank() -> CheckResult.Error("Aucun APK dans la release v$version")
else -> CheckResult.Available(UpdateInfo(version, apkUrl, json.optString("body")))
}
} finally {
conn.disconnect()
}
} catch (e: Exception) {
CheckResult.Error(e.message ?: e.javaClass.simpleName)
}
}
/** Télécharge l'APK dans le cache ; [onProgress] reçoit 0-100. Retourne le fichier ou lève une exception. */
suspend fun download(info: UpdateInfo, onProgress: (Int) -> Unit): File = withContext(Dispatchers.IO) {
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
dir.listFiles()?.forEach { it.delete() }
val file = File(dir, "DataControl-${info.versionName}.apk")
val conn = open(info.apkUrl)
try {
if (conn.responseCode != HttpURLConnection.HTTP_OK) error("HTTP ${conn.responseCode}")
val total = conn.contentLengthLong
var done = 0L
conn.inputStream.use { input ->
file.outputStream().use { output ->
val buf = ByteArray(32 * 1024)
var n: Int
while (input.read(buf).also { n = it } != -1) {
output.write(buf, 0, n)
done += n
if (total > 0) onProgress((done * 100 / total).toInt())
}
}
}
} finally {
conn.disconnect()
}
file
}
/** Vrai si l'installation a été lancée ; faux si l'utilisateur doit d'abord autoriser l'installation d'APK. */
fun install(file: File): Boolean {
if (!context.packageManager.canRequestPackageInstalls()) {
context.startActivity(
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
return false
}
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
context.startActivity(
Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, "application/vnd.android.package-archive")
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
)
return true
}
private fun open(url: String): HttpURLConnection =
(URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 30_000
setRequestProperty("User-Agent", "DataControl/$currentVersion")
setRequestProperty("Accept", "application/json, application/octet-stream")
}
companion object {
const val RELEASES_URL = "https://git.tuxtech.fr/api/v1/repos/tuxgyver/DataControl/releases/latest"
fun isNewer(remote: String, current: String): Boolean {
val r = remote.split(".").map { it.toIntOrNull() ?: 0 }
val c = current.split(".").map { it.toIntOrNull() ?: 0 }
for (i in 0 until maxOf(r.size, c.size)) {
val rv = r.getOrElse(i) { 0 }
val cv = c.getOrElse(i) { 0 }
if (rv != cv) return rv > cv
}
return false
}
}
}
@@ -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,8 +345,12 @@ class DataMonitorService : LifecycleService() {
} else "—"
val priceText = String.format("%.2f", snapshot.estimatedTotalPriceEuro)
val contentText = "$emoji $stateLabel · ${snapshot.consumedMo}/${snapshot.planLimitMo} Mo ($percentText)"
val bigText = "$contentText\n${getString(R.string.dashboard_cost_total)} : $priceText €"
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 €"
val bigText = "$usageText\n$costText"
val openApp = PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE
@@ -310,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) {
@@ -11,13 +11,20 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
@@ -28,6 +35,8 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import com.jttools.datacontrol.R
import com.jttools.datacontrol.data.UpdateManager
import kotlinx.coroutines.launch
@Composable
fun AboutScreen() {
@@ -46,6 +55,13 @@ fun AboutScreen() {
}.getOrNull()
}
val updateManager = remember(versionName) { UpdateManager(context.applicationContext, versionName) }
val scope = rememberCoroutineScope()
var updateStatus by remember { mutableStateOf<String?>(null) }
var available by remember { mutableStateOf<UpdateManager.UpdateInfo?>(null) }
var busy by remember { mutableStateOf(false) }
var progress by remember { mutableIntStateOf(-1) }
Column(
modifier = Modifier
.fillMaxSize()
@@ -83,6 +99,65 @@ fun AboutScreen() {
}
}
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(stringResource(R.string.update_section), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
HorizontalDivider()
val info = available
val statusText = when {
progress >= 0 -> stringResource(R.string.update_downloading, progress)
updateStatus != null -> updateStatus!!
info != null -> stringResource(R.string.update_available, info.versionName)
else -> null
}
if (statusText != null) Text(statusText, style = MaterialTheme.typography.bodyMedium)
if (info != null && progress < 0 && info.changelog.isNotBlank()) {
Text(info.changelog, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (progress >= 0) {
LinearProgressIndicator(progress = { progress / 100f }, modifier = Modifier.fillMaxWidth())
}
val needPermission = stringResource(R.string.update_need_permission)
val upToDate = stringResource(R.string.update_up_to_date)
val errorFmt = stringResource(R.string.update_error, "%s")
Button(
enabled = !busy,
onClick = {
busy = true
scope.launch {
if (info == null) {
updateStatus = null
when (val r = updateManager.check()) {
is UpdateManager.CheckResult.UpToDate -> updateStatus = upToDate
is UpdateManager.CheckResult.Available -> available = r.info
is UpdateManager.CheckResult.Error -> updateStatus = errorFmt.replace("%s", r.message)
}
} else {
updateStatus = null
progress = 0
try {
val file = updateManager.download(info) { progress = it }
if (!updateManager.install(file)) updateStatus = needPermission
} catch (e: Exception) {
updateStatus = errorFmt.replace("%s", e.message ?: e.javaClass.simpleName)
}
progress = -1
}
busy = false
}
}
) {
Text(
when {
busy && info == null -> stringResource(R.string.update_checking)
info != null -> stringResource(R.string.update_download_install)
else -> stringResource(R.string.update_check)
}
)
}
}
}
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(stringResource(R.string.about_section_description), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
@@ -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)
+35
View File
@@ -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 -->
@@ -89,6 +90,15 @@
<string name="about_description">DataControl surveille en arrière-plan la consommation de données mobiles et coupe automatiquement l\'accès à Internet en cas de dépassement du forfait, sans jamais toucher au Wi-Fi.</string>
<string name="about_section_how">Fonctionnement</string>
<string name="about_how_it_works">Le filtrage repose sur un pare-feu local basé sur VpnService (sans root) : le trafic des applications non autorisées est capturé dans un tunnel VPN local puis silencieusement ignoré, tandis que les applications de la liste blanche continuent de communiquer normalement.</string>
<string name="update_section">Mise à jour</string>
<string name="update_check">Rechercher une mise à jour</string>
<string name="update_checking">Recherche en cours…</string>
<string name="update_up_to_date">Vous avez la dernière version.</string>
<string name="update_available">Version %1$s disponible</string>
<string name="update_download_install">Télécharger et installer</string>
<string name="update_downloading">Téléchargement… %1$d %%</string>
<string name="update_need_permission">Autorisez l\'installation d\'applications pour DataControl, puis relancez la mise à jour.</string>
<string name="update_error">Échec : %1$s</string>
<string name="about_author">Auteur</string>
<string name="about_author_value">JT-Tools — Johnny</string>
@@ -114,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" />
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<device-admin>
<uses-policies />
</device-admin>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="updates" path="updates/" />
</paths>
+5
View File
@@ -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=
+6
View File
@@ -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
View File
@@ -1,2 +1,2 @@
versionName=0.1.1
versionCode=2
versionName=0.1.4
versionCode=5