feat: provisionnement du mode propriétaire par QR code (appairage et protection automatiques)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ
This commit is contained in:
1 parent
c9547016eb
commit
fd0b197f33
8 files changed
+199
-7
No files matched your search
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## v0.3.0 – 2026-10-25
|
||||
|
||||
- ✨ **Ajouté** — **Provisionnement du mode propriétaire par QR code**, sans PC ni débogage USB : sur un téléphone réinitialisé, toucher 6 fois l'écran d'accueil puis scanner le QR créé sur l'espace parents. DataControl est installé, devient propriétaire de l'appareil, s'associe tout seul au serveur et active la protection.
|
||||
- ✨ **Ajouté** — En mode propriétaire, DataControl s'accorde les autorisations de notification et de position, et pose ses verrous avant de monter le tunnel.
|
||||
|
||||
## v0.2.0 – 2026-10-25
|
||||
|
||||
- ✨ **Ajouté** — **Tunnel WireGuard permanent vers le serveur familyd** du parent : tout le trafic du téléphone (Wi-Fi et données mobiles) passe par le serveur, qui applique le mode choisi (accès libre, liste blanche seulement, Internet coupé) et bloque les services interdits.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# DataControl
|
||||
|
||||
🚀 v0.2.0 · 🤖 Android · 🟣 Kotlin · 🎨 Jetpack Compose · 🔒 WireGuard · 🛡️ mode propriétaire · 👨👩👧 contrôle parental · 🧪 tests · 📄 licence non déclarée
|
||||
🚀 v0.3.0 · 🤖 Android · 🟣 Kotlin · 🎨 Jetpack Compose · 🔒 WireGuard · 🛡️ mode propriétaire · 👨👩👧 contrôle parental · 🧪 tests · 📄 licence non déclarée
|
||||
|
||||
**DataControl** est l'application Android du téléphone de l'enfant. Elle relie le téléphone en permanence, par un tunnel **WireGuard**, au serveur de contrôle parental **familyd** du parent : tout le trafic (Wi-Fi comme données mobiles) passe par ce serveur, qui décide de ce qui passe. Sur le téléphone, elle bloque les applications interdites, compte le temps d'écran et suit la consommation de données mobiles.
|
||||
|
||||
@@ -29,7 +29,11 @@
|
||||
1. **Installer DataControl** : par la Release Gitea, ou — sans PC — en ajoutant d'abord le téléphone par QR code dans l'application WireGuard (espace parents → *Ajouter un appareil*), puis en ouvrant `http://10.78.0.1/installer` dans son navigateur.
|
||||
2. **Associer** : espace parents → *Associer un téléphone (DataControl)* ; saisir le code dans *Réglages → Contrôle parental*.
|
||||
3. **Activer la protection** sur le tableau de bord et accepter la demande VPN d'Android.
|
||||
4. **Verrouiller** (recommandé) : mot de passe dans *Réglages → Sécurité*, administrateur d'appareil, et si possible le mode propriétaire :
|
||||
4. **Verrouiller** (recommandé) : mot de passe dans *Réglages → Sécurité*, administrateur d'appareil, et si possible le mode propriétaire.
|
||||
|
||||
**Par QR code, sans PC** (téléphone réinitialisé) : espace parents → *Préparer un téléphone (mode propriétaire)* ; sur l'écran « Bienvenue » du téléphone, toucher 6 fois au même endroit, scanner le QR. DataControl est installé, devient propriétaire, s'associe et active la protection tout seul.
|
||||
|
||||
**Ou par adb** :
|
||||
|
||||
```bash
|
||||
# téléphone branché en USB, débogage USB actif, tous les comptes retirés le temps de la commande
|
||||
@@ -58,7 +62,7 @@ Réseau : sortant UDP 51721 (tunnel vers `fam-vpn.tuxtech.fr`) et HTTPS (appaira
|
||||
## Limites connues
|
||||
|
||||
- Sans le mode propriétaire, le VPN peut être coupé dans les réglages Android : le serveur le voit et alerte le parent, sans pouvoir l'empêcher.
|
||||
- Le mode propriétaire se provisionne par `adb` (débogage USB) sur un téléphone sans compte, ou par QR code au premier démarrage après réinitialisation (non encore proposé).
|
||||
- Le mode propriétaire se provisionne par QR code au premier démarrage (téléphone réinitialisé) ou par `adb` sur un téléphone sans compte.
|
||||
- Un seul VPN peut être actif à la fois sous Android : DataControl est incompatible avec un autre VPN.
|
||||
- Si le serveur familyd est arrêté, le téléphone n'est plus filtré (et, en mode propriétaire, n'a plus Internet pendant 15 min au plus).
|
||||
- Le compteur de consommation local repose sur `TrafficStats` (remis à zéro au redémarrage, la référence est recalée) ; le serveur mesure de son côté le trafic réel du tunnel.
|
||||
|
||||
@@ -87,9 +87,32 @@
|
||||
android:resource="@xml/device_admin" />
|
||||
<intent-filter>
|
||||
<action android:name="android.app.action.DEVICE_ADMIN_ENABLED" />
|
||||
<action android:name="android.app.action.PROFILE_PROVISIONING_COMPLETE" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Provisionnement « propriétaire de l'appareil » par QR code (assistant de première mise en route). -->
|
||||
<activity
|
||||
android:name=".admin.ProvisioningModeActivity"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_DEVICE_ADMIN"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar">
|
||||
<intent-filter>
|
||||
<action android:name="android.app.action.GET_PROVISIONING_MODE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".admin.PolicyComplianceActivity"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_DEVICE_ADMIN"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar">
|
||||
<intent-filter>
|
||||
<action android:name="android.app.action.ADMIN_POLICY_COMPLIANCE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver
|
||||
android:name=".service.BootReceiver"
|
||||
android:exported="true"
|
||||
|
||||
@@ -15,6 +15,11 @@ class AppDeviceAdminReceiver : DeviceAdminReceiver() {
|
||||
|
||||
@Inject lateinit var remoteSync: RemoteSync
|
||||
|
||||
/** Android 9 et avant : fin du provisionnement signalée ici (Android 10+ : PolicyComplianceActivity). */
|
||||
override fun onProfileProvisioningComplete(context: Context, intent: Intent) {
|
||||
Provisioning.complete(context, Provisioning.extrasOf(intent))
|
||||
}
|
||||
|
||||
override fun onDisableRequested(context: Context, intent: Intent): CharSequence =
|
||||
"Désactiver la protection permet de désinstaller DataControl. Cette action est signalée à FamilyControl."
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.jttools.datacontrol.admin
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.app.admin.DevicePolicyManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.PersistableBundle
|
||||
import com.jttools.datacontrol.data.SettingsRepository
|
||||
import com.jttools.datacontrol.log.AppLog
|
||||
import com.jttools.datacontrol.service.DataMonitorService
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
/**
|
||||
* Provisionnement « propriétaire de l'appareil » par QR code, à la première mise en route d'un téléphone
|
||||
* réinitialisé (QR créé sur l'espace parents). Le QR transmet, dans le bundle « admin extras », l'adresse du
|
||||
* serveur familyd, un code d'appairage et le nom du téléphone : DataControl s'associe ensuite tout seul
|
||||
* (voir DataMonitorService) et active la protection.
|
||||
*/
|
||||
object Provisioning {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface Deps {
|
||||
fun settingsRepository(): SettingsRepository
|
||||
}
|
||||
|
||||
fun extrasOf(intent: Intent?): PersistableBundle? {
|
||||
intent ?: return null
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra(DevicePolicyManager.EXTRA_PROVISIONING_ADMIN_EXTRAS_BUNDLE, PersistableBundle::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getParcelableExtra(DevicePolicyManager.EXTRA_PROVISIONING_ADMIN_EXTRAS_BUNDLE)
|
||||
}
|
||||
}
|
||||
|
||||
/** Fin du provisionnement : appairage en attente, autorisations accordées, surveillance démarrée. */
|
||||
fun complete(context: Context, extras: PersistableBundle?) {
|
||||
val app = context.applicationContext
|
||||
val url = extras?.getString("familyd_url").orEmpty()
|
||||
val code = extras?.getString("familyd_code").orEmpty()
|
||||
val name = extras?.getString("device_name").orEmpty()
|
||||
if (url.startsWith("https://") && code.isNotBlank()) {
|
||||
val settings = EntryPointAccessors.fromApplication(app, Deps::class.java).settingsRepository()
|
||||
// Écriture courte et indispensable avant de rendre la main à l'assistant de configuration.
|
||||
runBlocking { settings.setPendingProvision(url, code, name) }
|
||||
AppLog.i("Propriétaire", "Provisionnement par QR : appairage prévu avec $url (« $name »)")
|
||||
} else {
|
||||
AppLog.w("Propriétaire", "Provisionnement sans informations d'appairage : associer à la main")
|
||||
}
|
||||
grantPermissions(app)
|
||||
DataMonitorService.start(app)
|
||||
}
|
||||
|
||||
/** Le propriétaire de l'appareil accorde lui-même les autorisations d'exécution utiles. */
|
||||
private fun grantPermissions(context: Context) {
|
||||
if (!AdminController.isDeviceOwner(context)) return
|
||||
val dpm = context.getSystemService(DevicePolicyManager::class.java)
|
||||
val admin = AdminController.component(context)
|
||||
val perms = buildList {
|
||||
add(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
add(Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) add(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) add(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
for (p in perms) {
|
||||
val ok = runCatching {
|
||||
dpm.setPermissionGrantState(admin, context.packageName, p, DevicePolicyManager.PERMISSION_GRANT_STATE_GRANTED)
|
||||
}.getOrDefault(false)
|
||||
AppLog.i("Propriétaire", "Autorisation ${p.substringAfterLast('.')} : ${if (ok) "accordée" else "refusée par le système"}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Android 12+ : l'assistant demande quel mode de gestion l'application veut (ici : appareil entièrement géré). */
|
||||
class ProvisioningModeActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setResult(RESULT_OK, Intent().putExtra(
|
||||
DevicePolicyManager.EXTRA_PROVISIONING_MODE, DevicePolicyManager.PROVISIONING_MODE_FULLY_MANAGED_DEVICE
|
||||
))
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
/** Android 10+ : dernière étape du provisionnement, l'application finalise sa configuration. */
|
||||
class PolicyComplianceActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
Provisioning.complete(this, Provisioning.extrasOf(intent))
|
||||
setResult(RESULT_OK)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ class SettingsRepository @Inject constructor(
|
||||
val POLICY_VERSION = longPreferencesKey("policy_version")
|
||||
val EVENT_QUEUE = stringPreferencesKey("event_queue")
|
||||
val MIGRATED_V02 = booleanPreferencesKey("migrated_v02")
|
||||
val PENDING_PROVISION = stringPreferencesKey("pending_provision")
|
||||
}
|
||||
|
||||
/** Clés de la v0.1 (Firebase, liste blanche locale, coupure cyclique locale) retirées à la mise à jour. */
|
||||
@@ -325,6 +326,24 @@ class SettingsRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Appairage prévu par le QR de provisionnement -------------------------------------------
|
||||
|
||||
/** (serveur, code, nom) reçus du QR de provisionnement, en attente d'appairage. */
|
||||
suspend fun pendingProvision(): Triple<String, String, String>? = runCatching {
|
||||
val o = JSONObject(context.dataStore.data.first()[Keys.PENDING_PROVISION] ?: return null)
|
||||
Triple(o.getString("url"), o.getString("code"), o.optString("name"))
|
||||
}.getOrNull()
|
||||
|
||||
suspend fun setPendingProvision(url: String, code: String, name: String) {
|
||||
context.dataStore.edit {
|
||||
it[Keys.PENDING_PROVISION] = JSONObject().put("url", url).put("code", code).put("name", name).toString()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearPendingProvision() {
|
||||
context.dataStore.edit { it.remove(Keys.PENDING_PROVISION) }
|
||||
}
|
||||
|
||||
// --- File des évènements --------------------------------------------------------------------
|
||||
|
||||
suspend fun queueEvent(event: PendingEvent) {
|
||||
|
||||
@@ -62,6 +62,8 @@ class DataMonitorService : LifecycleService() {
|
||||
/** Dernière poignée de main récente avec le serveur (temps monotone), pour le garde-fou du mode propriétaire. */
|
||||
private var serverSeenAt = SystemClock.elapsedRealtime()
|
||||
private var lockdownRelaxed = false
|
||||
private var lastProvisionAttempt = 0L
|
||||
private var provisionJob: Job? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
@@ -136,9 +138,13 @@ class DataMonitorService : LifecycleService() {
|
||||
}
|
||||
}
|
||||
|
||||
// Tunnel : monté si contrôle activé + appareil associé ; sinon arrêté.
|
||||
if (!settings.isPaired) maybeProvisionPairing()
|
||||
|
||||
// Tunnel : monté si contrôle activé + appareil associé ; sinon arrêté. En mode propriétaire, les
|
||||
// verrous (dont l'autorisation VPN accordée par le système) passent avant la montée du tunnel.
|
||||
val wanted = settings.controlEnabled && settings.isPaired
|
||||
val up = tunnel.up.value
|
||||
applyOwnerPolicy(wanted, up)
|
||||
when {
|
||||
wanted && !up -> maybeBringUp(settings)
|
||||
!wanted && up && tunnelJob?.isActive != true -> {
|
||||
@@ -146,7 +152,6 @@ class DataMonitorService : LifecycleService() {
|
||||
}
|
||||
}
|
||||
val fault = wanted && !up
|
||||
applyOwnerPolicy(wanted, up)
|
||||
|
||||
val dataState = when {
|
||||
!settings.controlEnabled -> if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK
|
||||
@@ -179,6 +184,36 @@ class DataMonitorService : LifecycleService() {
|
||||
updateNotification(stateHolder.snapshot.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Appairage prévu par le QR de provisionnement : tenté toutes les minutes jusqu'à réussite ; la
|
||||
* protection s'active aussitôt. Un code refusé (expiré, déjà utilisé) est abandonné : appairage à la main.
|
||||
*/
|
||||
private fun maybeProvisionPairing() {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (provisionJob?.isActive == true || (lastProvisionAttempt != 0L && now - lastProvisionAttempt < 60_000L)) return
|
||||
lastProvisionAttempt = now
|
||||
provisionJob = lifecycleScope.launch {
|
||||
val (url, code, name) = settingsRepository.pendingProvision() ?: return@launch
|
||||
val input = com.jttools.datacontrol.remote.PairingInput.parse("familyd://pair?url=$url&code=$code") ?: run {
|
||||
settingsRepository.clearPendingProvision()
|
||||
return@launch
|
||||
}
|
||||
remoteSync.pair(input, name.ifBlank { android.os.Build.MODEL }).fold(
|
||||
onSuccess = {
|
||||
settingsRepository.clearPendingProvision()
|
||||
settingsRepository.setControlEnabled(true)
|
||||
AppLog.i("Propriétaire", "Associé automatiquement après provisionnement : protection activée")
|
||||
},
|
||||
onFailure = { e ->
|
||||
if ((e as? com.jttools.datacontrol.remote.ApiException)?.code in setOf(404, 409, 422)) {
|
||||
settingsRepository.clearPendingProvision()
|
||||
AppLog.w("Propriétaire", "Code du QR refusé (${e.message}) : associer à la main")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode propriétaire : VPN toujours actif et réglages verrouillés tant que la protection est voulue.
|
||||
* Garde-fou : sans poignée de main avec le serveur depuis [SERVER_LOST_MS], le blocage hors VPN est
|
||||
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
versionName=0.2.0
|
||||
versionCode=12
|
||||
versionName=0.3.0
|
||||
versionCode=13
|
||||
Reference in new issue
Block a user