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
1b5558ff8b
8 files changed
+199
-7
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user