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:
tuxgyverandClaude Opus 5.5 committed 2026-10-25 14:51:34 +01:00
1 parent c9547016eb
commit 1b5558ff8b
8 files changed
+199 -7

No files matched your search

@@ -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