feat: verrouillage parental et désactivation par code du serveur

Le parent verrouille le téléphone à distance sur un écran de blocage plein
écran (ParentalLockActivity) que seul un code à usage unique généré par le
serveur permet de lever. La désactivation du contrôle (couper la protection,
dissocier, quitter le mode propriétaire, retirer l'anti-désinstallation) exige
désormais, sur un téléphone associé, ce code du serveur au lieu du mot de passe
local : l'enfant ne peut plus rien désactiver sans l'accord du parent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ
This commit is contained in:
tuxgyverandClaude Opus 4.8 committed 2026-10-25 21:26:41 +01:00
1 parent b138a15513
commit c66302a2bd
19 files changed
+436 -19

No files matched your search

@@ -0,0 +1,183 @@
package com.jttools.datacontrol.guard
import android.app.admin.DevicePolicyManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
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.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import com.jttools.datacontrol.R
import com.jttools.datacontrol.admin.AdminController
import com.jttools.datacontrol.data.SettingsRepository
import com.jttools.datacontrol.remote.RemoteSync
import com.jttools.datacontrol.ui.theme.DataControlTheme
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Écran de verrouillage parental plein écran : affiché sur commande du parent (« plock »), il reste
* au premier plan jusqu'à la saisie d'un code à usage unique généré par le serveur (action "unlock").
*
* Le code remplace le mot de passe local : seul le parent, en dictant le code, peut déverrouiller.
* En mode propriétaire de l'appareil, l'écran est épinglé (lock task) : accueil et récents neutralisés.
* Sinon, l'activité se remet d'elle-même au premier plan si l'enfant tente de la quitter.
*/
@AndroidEntryPoint
class ParentalLockActivity : ComponentActivity() {
@Inject lateinit var remoteSync: RemoteSync
@Inject lateinit var settings: SettingsRepository
private var unlocked = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
}
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
// Le bouton Retour ne ferme pas le verrou.
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() = Unit
})
setContent {
DataControlTheme {
var code by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
val scope = androidx.compose.runtime.rememberCoroutineScope()
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(
getString(R.string.parental_lock_title),
style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center
)
Spacer(Modifier.height(10.dp))
Text(
getString(R.string.parental_lock_message),
style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center
)
Spacer(Modifier.height(24.dp))
OutlinedTextField(
value = code,
onValueChange = { code = it.take(20); error = null },
label = { Text(getString(R.string.parental_lock_code_label)) },
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Characters),
isError = error != null,
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
error?.let {
Spacer(Modifier.height(6.dp))
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(20.dp))
Button(
enabled = !busy,
onClick = {
busy = true
scope.launch {
val result = remoteSync.verifyUnlock(code, "unlock")
if (result == null) {
settings.setParentalLocked(false) // l'observateur ferme l'écran
} else {
error = result
busy = false
}
}
},
modifier = Modifier.fillMaxWidth()
) { Text(getString(R.string.parental_lock_unlock)) }
}
}
}
}
// Le parent peut aussi déverrouiller à distance (« punlock ») : on ferme dès que l'état repasse à faux.
lifecycleScope.launch {
settings.settingsFlow.map { it.parentalLocked }.collect { locked ->
if (!locked) {
unlocked = true
stopPinning()
finish()
}
}
}
}
override fun onResume() {
super.onResume()
startPinning()
}
override fun onStop() {
super.onStop()
// Tant que le verrou tient, toute tentative de quitter (Accueil, bascule) ramène l'écran.
if (!unlocked && !isFinishing) {
startActivity(intent(this))
}
}
/** Épingle l'écran en mode propriétaire : l'accueil et les récents ne peuvent plus l'écarter. */
private fun startPinning() {
if (!AdminController.isDeviceOwner(this)) return
runCatching {
val dpm = getSystemService(DevicePolicyManager::class.java)
dpm.setLockTaskPackages(AdminController.component(this), arrayOf(packageName))
startLockTask()
}
}
private fun stopPinning() {
runCatching { stopLockTask() }
}
companion object {
fun intent(context: Context): Intent =
Intent(context, ParentalLockActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
}