Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c66302a2bd |
No files matched your search
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.8.0 – 2026-10-25
|
||||||
|
|
||||||
|
- ✨ **Ajouté** — **Verrouillage parental à distance** : depuis l'espace parents, le parent verrouille le téléphone sur un écran de blocage plein écran que seul un **code à usage unique** (généré par le serveur et dicté à l'enfant) permet de lever. En mode propriétaire, l'écran est épinglé (accueil et récents neutralisés) ; sinon il revient de lui-même si l'enfant tente de le quitter. Le parent peut aussi déverrouiller à distance.
|
||||||
|
- 🔒 **Sécurité** — **Désactivation protégée par un code du serveur** : sur un téléphone associé, couper la protection, dissocier le téléphone, quitter le mode propriétaire ou retirer l'anti-désinstallation exige désormais un code à usage unique fourni par le parent — **à la place du mot de passe local**. L'enfant ne peut plus rien désactiver sans l'accord du parent.
|
||||||
|
|
||||||
## v0.7.0 – 2026-10-25
|
## v0.7.0 – 2026-10-25
|
||||||
|
|
||||||
- ✨ **Ajouté** — **Déploiement forcé** depuis l'espace parents : réinstalle DataControl via le tunnel WireGuard même si la version proposée n'est pas plus récente que celle installée (réparation / réinstallation à distance).
|
- ✨ **Ajouté** — **Déploiement forcé** depuis l'espace parents : réinstalle DataControl via le tunnel WireGuard même si la version proposée n'est pas plus récente que celle installée (réparation / réinstallation à distance).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# DataControl
|
# DataControl
|
||||||
|
|
||||||
🚀 v0.7.0 · 🤖 Android · 🟣 Kotlin · 🎨 Jetpack Compose · 🔒 WireGuard · 🛡️ mode propriétaire · 👨👩👧 contrôle parental · 🧪 tests · 📄 licence non déclarée
|
🚀 v0.8.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.
|
**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.
|
||||||
|
|
||||||
@@ -18,11 +18,12 @@
|
|||||||
- **Tunnel coupé = alerte** — Si le tunnel tombe (VPN retiré, autre VPN, serveur injoignable), la protection reste activée : la notification passe en rouge (« PROTECTION INACTIVE »), le parent est prévenu et le tunnel est remonté dès que possible.
|
- **Tunnel coupé = alerte** — Si le tunnel tombe (VPN retiré, autre VPN, serveur injoignable), la protection reste activée : la notification passe en rouge (« PROTECTION INACTIVE »), le parent est prévenu et le tunnel est remonté dès que possible.
|
||||||
- **Mode propriétaire de l'appareil** (Device Owner, facultatif) — VPN toujours actif avec blocage des connexions hors VPN, réglages VPN / date et heure / DNS privé verrouillés, pas de réinitialisation ni de mode sans échec, DataControl indésinstallable. Garde-fou : si le serveur ne répond plus pendant 15 min, le blocage hors VPN est levé jusqu'à son retour.
|
- **Mode propriétaire de l'appareil** (Device Owner, facultatif) — VPN toujours actif avec blocage des connexions hors VPN, réglages VPN / date et heure / DNS privé verrouillés, pas de réinitialisation ni de mode sans échec, DataControl indésinstallable. Garde-fou : si le serveur ne répond plus pendant 15 min, le blocage hors VPN est levé jusqu'à son retour.
|
||||||
- **Suivi de la consommation** — Données mobiles depuis le début du cycle (`TrafficStats`), forfait, coût estimé, réinitialisation mensuelle automatique ou manuelle.
|
- **Suivi de la consommation** — Données mobiles depuis le début du cycle (`TrafficStats`), forfait, coût estimé, réinitialisation mensuelle automatique ou manuelle.
|
||||||
- **Mot de passe de verrouillage** — Protège les Réglages, l'effacement du journal, la désactivation de la protection et la sortie du mode propriétaire. Haché avec PBKDF2 ; après 3 essais ratés, attente croissante (30 s à 15 min).
|
- **Mot de passe de verrouillage** — Protège l'accès aux Réglages et l'effacement du journal (haché avec PBKDF2 ; après 3 essais ratés, attente croissante de 30 s à 15 min). Sur un téléphone associé, la *désactivation* du contrôle et la sortie du mode propriétaire exigent en plus un code du serveur dicté par le parent (voir ci-dessus).
|
||||||
- **Journal de diagnostic** — État du tunnel, mode appliqué et, avec le service d'accessibilité, chaque application utilisée. Conservé 15 jours.
|
- **Journal de diagnostic** — État du tunnel, mode appliqué et, avec le service d'accessibilité, chaque application utilisée. Conservé 15 jours.
|
||||||
- **Mise à jour intégrée** — Dernière Release Gitea depuis l'onglet *À propos* ; le serveur familyd la propose aussi aux appareils du tunnel sur `http://10.78.0.1/installer`.
|
- **Mise à jour intégrée** — Dernière Release Gitea depuis l'onglet *À propos* ; le serveur familyd la propose aussi aux appareils du tunnel sur `http://10.78.0.1/installer`.
|
||||||
- **Déploiement depuis le serveur** — Le parent pousse la nouvelle version depuis l'espace parents : téléchargement par le tunnel, empreinte SHA-256 vérifiée, installation silencieuse en mode propriétaire (sinon un toucher sur la notification).
|
- **Déploiement depuis le serveur** — Le parent pousse la nouvelle version depuis l'espace parents : téléchargement par le tunnel, empreinte SHA-256 vérifiée, installation silencieuse en mode propriétaire (sinon un toucher sur la notification).
|
||||||
- **Actions à distance** — Depuis l'espace parents : voir l'écran en direct (service d'accessibilité, Android 11+, avec bandeau visible « Écran partagé » sur le téléphone), verrouiller / mettre en veille, couper ou rétablir le son.
|
- **Actions à distance** — Depuis l'espace parents : voir l'écran en direct (service d'accessibilité, Android 11+, avec bandeau visible « Écran partagé » sur le téléphone), verrouiller / mettre en veille, couper ou rétablir le son, faire sonner, et **verrouiller le téléphone** sur un écran de blocage que seul un code du serveur lève.
|
||||||
|
- **Déverrouillage et désactivation par code** — Sur un téléphone associé, couper la protection, dissocier, quitter le mode propriétaire ou lever le verrouillage parental exige un **code à usage unique** généré par le parent (dans l'espace parents) et dicté à l'enfant, au lieu du mot de passe local.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,15 @@
|
|||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:theme="@style/Theme.DataControl" />
|
android:theme="@style/Theme.DataControl" />
|
||||||
|
|
||||||
|
<!-- Verrouillage parental plein écran : levé uniquement par un code à usage unique du serveur. -->
|
||||||
|
<activity
|
||||||
|
android:name=".guard.ParentalLockActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:showOnLockScreen="true"
|
||||||
|
android:theme="@style/Theme.DataControl" />
|
||||||
|
|
||||||
<!-- Administrateur d'appareil : bloque la désinstallation tant qu'il est actif. -->
|
<!-- Administrateur d'appareil : bloque la désinstallation tant qu'il est actif. -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".admin.AppDeviceAdminReceiver"
|
android:name=".admin.AppDeviceAdminReceiver"
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ data class AppSettings(
|
|||||||
/** Mode réseau appliqué par le serveur : open, allowlist ou cut. */
|
/** Mode réseau appliqué par le serveur : open, allowlist ou cut. */
|
||||||
val serverMode: String = "open",
|
val serverMode: String = "open",
|
||||||
val serverModeReason: String = "",
|
val serverModeReason: String = "",
|
||||||
val policyVersion: Long = 0L
|
val policyVersion: Long = 0L,
|
||||||
|
/** Téléphone verrouillé à distance par le parent : un code du serveur est exigé pour le déverrouiller. */
|
||||||
|
val parentalLocked: Boolean = false
|
||||||
) {
|
) {
|
||||||
val isPaired: Boolean get() = link != null
|
val isPaired: Boolean get() = link != null
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class SettingsRepository @Inject constructor(
|
|||||||
val PASSWORD_FAILS = intPreferencesKey("password_fails")
|
val PASSWORD_FAILS = intPreferencesKey("password_fails")
|
||||||
val PASSWORD_LOCK_UNTIL = longPreferencesKey("password_lock_until")
|
val PASSWORD_LOCK_UNTIL = longPreferencesKey("password_lock_until")
|
||||||
val RULES_JSON = stringPreferencesKey("rules_json")
|
val RULES_JSON = stringPreferencesKey("rules_json")
|
||||||
|
val PARENTAL_LOCKED = booleanPreferencesKey("parental_locked")
|
||||||
val USAGE_DAY = stringPreferencesKey("usage_day")
|
val USAGE_DAY = stringPreferencesKey("usage_day")
|
||||||
val USAGE_JSON = stringPreferencesKey("usage_json")
|
val USAGE_JSON = stringPreferencesKey("usage_json")
|
||||||
val CLOCK_SKEW_MS = longPreferencesKey("clock_skew_ms")
|
val CLOCK_SKEW_MS = longPreferencesKey("clock_skew_ms")
|
||||||
@@ -101,7 +102,8 @@ class SettingsRepository @Inject constructor(
|
|||||||
link = FamilyLink.fromJson(prefs[Keys.LINK_JSON]),
|
link = FamilyLink.fromJson(prefs[Keys.LINK_JSON]),
|
||||||
serverMode = prefs[Keys.SERVER_MODE] ?: defaults.serverMode,
|
serverMode = prefs[Keys.SERVER_MODE] ?: defaults.serverMode,
|
||||||
serverModeReason = prefs[Keys.SERVER_MODE_REASON] ?: defaults.serverModeReason,
|
serverModeReason = prefs[Keys.SERVER_MODE_REASON] ?: defaults.serverModeReason,
|
||||||
policyVersion = prefs[Keys.POLICY_VERSION] ?: defaults.policyVersion
|
policyVersion = prefs[Keys.POLICY_VERSION] ?: defaults.policyVersion,
|
||||||
|
parentalLocked = prefs[Keys.PARENTAL_LOCKED] ?: defaults.parentalLocked
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +123,11 @@ class SettingsRepository @Inject constructor(
|
|||||||
context.dataStore.edit { it[Keys.CONTROL_ENABLED] = enabled }
|
context.dataStore.edit { it[Keys.CONTROL_ENABLED] = enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Verrouillage parental plein écran (commande « plock » du parent, levé par un code du serveur). */
|
||||||
|
suspend fun setParentalLocked(locked: Boolean) {
|
||||||
|
context.dataStore.edit { it[Keys.PARENTAL_LOCKED] = locked }
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun updatePlan(
|
suspend fun updatePlan(
|
||||||
planLimitMo: Long,
|
planLimitMo: Long,
|
||||||
planPriceEuro: Double,
|
planPriceEuro: Double,
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ import android.provider.Settings
|
|||||||
import android.telecom.TelecomManager
|
import android.telecom.TelecomManager
|
||||||
import android.view.accessibility.AccessibilityEvent
|
import android.view.accessibility.AccessibilityEvent
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
|
import com.jttools.datacontrol.data.SettingsRepository
|
||||||
import com.jttools.datacontrol.log.AppLog
|
import com.jttools.datacontrol.log.AppLog
|
||||||
import com.jttools.datacontrol.remote.RemoteControl
|
import com.jttools.datacontrol.remote.RemoteControl
|
||||||
import com.jttools.datacontrol.remote.RemoteSync
|
import com.jttools.datacontrol.remote.RemoteSync
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import com.jttools.datacontrol.service.MonitorStateHolder
|
import com.jttools.datacontrol.service.MonitorStateHolder
|
||||||
import com.jttools.datacontrol.service.NetworkTransport
|
import com.jttools.datacontrol.service.NetworkTransport
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
@@ -49,6 +51,9 @@ class AppGuardService : AccessibilityService() {
|
|||||||
@Inject lateinit var stateHolder: MonitorStateHolder
|
@Inject lateinit var stateHolder: MonitorStateHolder
|
||||||
@Inject lateinit var remoteControl: RemoteControl
|
@Inject lateinit var remoteControl: RemoteControl
|
||||||
@Inject lateinit var remoteSync: RemoteSync
|
@Inject lateinit var remoteSync: RemoteSync
|
||||||
|
@Inject lateinit var settings: SettingsRepository
|
||||||
|
|
||||||
|
@Volatile private var parentalLocked = false
|
||||||
|
|
||||||
private var shareBanner: TextView? = null
|
private var shareBanner: TextView? = null
|
||||||
|
|
||||||
@@ -78,7 +83,14 @@ class AppGuardService : AccessibilityService() {
|
|||||||
while (isActive) {
|
while (isActive) {
|
||||||
delay(CHECK_INTERVAL_MS)
|
delay(CHECK_INTERVAL_MS)
|
||||||
engine.tick()
|
engine.tick()
|
||||||
currentPkg?.let { enforce(it) }
|
if (parentalLocked) enforceParentalLock() else currentPkg?.let { enforce(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Suit le verrouillage parental demandé par le parent (ramène l'écran de blocage si l'enfant le quitte).
|
||||||
|
scope.launch {
|
||||||
|
settings.settingsFlow.map { it.parentalLocked }.collect { locked ->
|
||||||
|
parentalLocked = locked
|
||||||
|
if (locked) enforceParentalLock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
startScreenShareLoop()
|
startScreenShareLoop()
|
||||||
@@ -174,6 +186,11 @@ class AppGuardService : AccessibilityService() {
|
|||||||
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
|
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
|
||||||
if (event?.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) return
|
if (event?.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) return
|
||||||
val pkg = event.packageName?.toString() ?: return
|
val pkg = event.packageName?.toString() ?: return
|
||||||
|
// Verrouillage parental : toute tentative de passer à une autre application ramène l'écran de blocage.
|
||||||
|
if (parentalLocked && pkg != packageName) {
|
||||||
|
enforceParentalLock()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (pkg in exemptPackages()) {
|
if (pkg in exemptPackages()) {
|
||||||
currentPkg = null
|
currentPkg = null
|
||||||
lastLoggedPkg = null // revenir ensuite dans la même application compte comme une nouvelle utilisation
|
lastLoggedPkg = null // revenir ensuite dans la même application compte comme une nouvelle utilisation
|
||||||
@@ -194,6 +211,11 @@ class AppGuardService : AccessibilityService() {
|
|||||||
engine.check(pkg)?.let { enforce(pkg, it) }
|
engine.check(pkg)?.let { enforce(pkg, it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ramène l'écran de verrouillage parental au premier plan tant que le parent n'a pas levé le verrou. */
|
||||||
|
private fun enforceParentalLock() {
|
||||||
|
runCatching { startActivity(ParentalLockActivity.intent(this)) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun enforce(pkg: String, block: Block) {
|
private fun enforce(pkg: String, block: Block) {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
if (now - lastBlockAt < 2_000) return
|
if (now - lastBlockAt < 2_000) return
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,15 @@ class FamilyApi(private val apiBase: String, private val token: String) {
|
|||||||
request("POST", "$apiBase/api/device/screen", token, jpeg, "image/jpeg")
|
request("POST", "$apiBase/api/device/screen", token, jpeg, "image/jpeg")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie auprès du serveur le code dicté par le parent (désactivation / déverrouillage).
|
||||||
|
* Renvoie sans erreur si le code est accepté ; lève [ApiException] (404) sinon.
|
||||||
|
*/
|
||||||
|
fun unlock(code: String, action: String) {
|
||||||
|
request("POST", "$apiBase/api/device/unlock", token,
|
||||||
|
JSONObject().put("code", code).put("action", action))
|
||||||
|
}
|
||||||
|
|
||||||
/** Signale au serveur qu'une commande a été exécutée (audit). */
|
/** Signale au serveur qu'une commande a été exécutée (audit). */
|
||||||
fun commandAck(id: String, status: String, detail: String) {
|
fun commandAck(id: String, status: String, detail: String) {
|
||||||
request("POST", "$apiBase/api/device/command-ack", token,
|
request("POST", "$apiBase/api/device/command-ack", token,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.media.AudioManager
|
|||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import com.jttools.datacontrol.admin.AdminController
|
import com.jttools.datacontrol.admin.AdminController
|
||||||
import com.jttools.datacontrol.data.SettingsRepository
|
import com.jttools.datacontrol.data.SettingsRepository
|
||||||
|
import com.jttools.datacontrol.guard.ParentalLockActivity
|
||||||
import com.jttools.datacontrol.log.AppLog
|
import com.jttools.datacontrol.log.AppLog
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -81,8 +82,10 @@ class RemoteControl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Renvoie (statut, détail) pour l'audit : "done" ou "failed". */
|
/** Renvoie (statut, détail) pour l'audit : "done" ou "failed". */
|
||||||
private fun handle(cmd: JSONObject): Pair<String, String> = when (cmd.optString("type")) {
|
private suspend fun handle(cmd: JSONObject): Pair<String, String> = when (cmd.optString("type")) {
|
||||||
"lock" -> lock()
|
"lock" -> lock()
|
||||||
|
"plock" -> parentalLock(true)
|
||||||
|
"punlock" -> parentalLock(false)
|
||||||
"mute" -> setMuted(true)
|
"mute" -> setMuted(true)
|
||||||
"unmute" -> setMuted(false)
|
"unmute" -> setMuted(false)
|
||||||
"screen_start" -> {
|
"screen_start" -> {
|
||||||
@@ -104,6 +107,22 @@ class RemoteControl @Inject constructor(
|
|||||||
else -> "failed" to "commande inconnue"
|
else -> "failed" to "commande inconnue"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verrouillage parental plein écran : affiche [ParentalLockActivity], que seul un code du serveur
|
||||||
|
* (dicté par le parent) peut lever. [lock] = false lève le verrou à distance.
|
||||||
|
*/
|
||||||
|
private suspend fun parentalLock(lock: Boolean): Pair<String, String> {
|
||||||
|
settings.setParentalLocked(lock)
|
||||||
|
return if (lock) {
|
||||||
|
runCatching { context.startActivity(ParentalLockActivity.intent(context)) }
|
||||||
|
AppLog.w("Commande", "Verrouillage parental activé (code du serveur requis pour déverrouiller)")
|
||||||
|
"done" to "téléphone verrouillé"
|
||||||
|
} else {
|
||||||
|
AppLog.i("Commande", "Verrouillage parental levé par le parent")
|
||||||
|
"done" to "téléphone déverrouillé"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun lock(): Pair<String, String> {
|
private fun lock(): Pair<String, String> {
|
||||||
val dpm = context.getSystemService(DevicePolicyManager::class.java)
|
val dpm = context.getSystemService(DevicePolicyManager::class.java)
|
||||||
if (!AdminController.isActive(context)) {
|
if (!AdminController.isActive(context)) {
|
||||||
|
|||||||
@@ -60,6 +60,27 @@ class RemoteSync @Inject constructor(
|
|||||||
try { api.screen(jpeg); true } catch (_: ApiException) { false }
|
try { api.screen(jpeg); true } catch (_: ApiException) { false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie auprès du serveur un code dicté par le parent. [action] vaut "deactivate" (couper la
|
||||||
|
* protection, se dissocier, sortir du mode propriétaire) ou "unlock" (lever le blocage parental).
|
||||||
|
* Renvoie null si le code est accepté, sinon le message d'erreur à afficher.
|
||||||
|
*/
|
||||||
|
suspend fun verifyUnlock(code: String, action: String): String? = withContext(Dispatchers.IO) {
|
||||||
|
val clean = code.trim()
|
||||||
|
if (clean.isBlank()) return@withContext "Entrez le code fourni par le parent."
|
||||||
|
val api = api() ?: return@withContext "Ce téléphone n'est pas associé à un serveur."
|
||||||
|
try {
|
||||||
|
api.unlock(clean, action)
|
||||||
|
null
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
when (e.code) {
|
||||||
|
404 -> "Code incorrect, déjà utilisé ou expiré."
|
||||||
|
0 -> "Serveur injoignable : vérifiez que le téléphone est connecté."
|
||||||
|
else -> e.message ?: "Vérification impossible."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Accuse réception/exécution d'une commande (audit côté parent). */
|
/** Accuse réception/exécution d'une commande (audit côté parent). */
|
||||||
suspend fun ackCommand(id: String, status: String, detail: String = "") = withContext(Dispatchers.IO) {
|
suspend fun ackCommand(id: String, status: String, detail: String = "") = withContext(Dispatchers.IO) {
|
||||||
if (id.isBlank() || !tunnel.up.value) return@withContext
|
if (id.isBlank() || !tunnel.up.value) return@withContext
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import com.jttools.datacontrol.vpn.TunnelManager
|
|||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
@@ -108,6 +109,14 @@ class DataMonitorService : LifecycleService() {
|
|||||||
guardEngine.start()
|
guardEngine.start()
|
||||||
deviceReporter.start(lifecycleScope)
|
deviceReporter.start(lifecycleScope)
|
||||||
remoteControl.start(lifecycleScope)
|
remoteControl.start(lifecycleScope)
|
||||||
|
// Verrouillage parental encore actif après un redémarrage : on le rétablit à l'ouverture.
|
||||||
|
lifecycleScope.launch {
|
||||||
|
if (settingsRepository.settingsFlow.first().parentalLocked) {
|
||||||
|
runCatching {
|
||||||
|
startActivity(com.jttools.datacontrol.guard.ParentalLockActivity.intent(this@DataMonitorService))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
connectivityObserver.observe().collect {
|
connectivityObserver.observe().collect {
|
||||||
latestTransport = it
|
latestTransport = it
|
||||||
|
|||||||
@@ -27,12 +27,13 @@ object AboutContent {
|
|||||||
AboutEntry("Réinitialisation", "Automatique chaque mois à la date anniversaire, ou manuelle depuis le tableau de bord.")
|
AboutEntry("Réinitialisation", "Automatique chaque mois à la date anniversaire, ou manuelle depuis le tableau de bord.")
|
||||||
)),
|
)),
|
||||||
AboutSection("Sur le téléphone", listOf(
|
AboutSection("Sur le téléphone", listOf(
|
||||||
AboutEntry("Mot de passe de verrouillage", "Protège les Réglages, l'effacement du journal et la désactivation de la protection."),
|
AboutEntry("Mot de passe de verrouillage", "Protège l'accès aux Réglages et l'effacement du journal. Sur un téléphone associé, la désactivation de la protection et la sortie du mode propriétaire exigent en plus un code du serveur dicté par le parent."),
|
||||||
|
AboutEntry("Code de désactivation / déverrouillage", "Couper la protection, dissocier, quitter le mode propriétaire ou lever le verrouillage parental demande un code à usage unique généré par le parent dans l'espace parents : l'enfant ne peut rien désactiver seul."),
|
||||||
AboutEntry("Anti-désinstallation", "DataControl peut devenir administrateur de l'appareil : Android refuse alors de le désinstaller tant que la protection est active."),
|
AboutEntry("Anti-désinstallation", "DataControl peut devenir administrateur de l'appareil : Android refuse alors de le désinstaller tant que la protection est active."),
|
||||||
AboutEntry("Journal de diagnostic", "État du tunnel, mode appliqué et, si le service d'accessibilité est activé, chaque application utilisée. Conservé 15 jours, avec copie et partage."),
|
AboutEntry("Journal de diagnostic", "État du tunnel, mode appliqué et, si le service d'accessibilité est activé, chaque application utilisée. Conservé 15 jours, avec copie et partage."),
|
||||||
AboutEntry("Mise à jour intégrée", "Recherche et installation de la dernière version publiée, depuis l'onglet Journal de cette page."),
|
AboutEntry("Mise à jour intégrée", "Recherche et installation de la dernière version publiée, depuis l'onglet Journal de cette page."),
|
||||||
AboutEntry("Mise à jour poussée par le parent", "Depuis l'espace parents : la nouvelle version arrive par le tunnel, son empreinte est vérifiée, puis elle s'installe toute seule en mode propriétaire (sinon, touchez la notification « Mise à jour de DataControl »)."),
|
AboutEntry("Mise à jour poussée par le parent", "Depuis l'espace parents : la nouvelle version arrive par le tunnel, son empreinte est vérifiée, puis elle s'installe toute seule en mode propriétaire (sinon, touchez la notification « Mise à jour de DataControl »)."),
|
||||||
AboutEntry("Actions à distance du parent", "Depuis l'espace parents : verrouiller / mettre en veille, couper ou rétablir le son, et voir l'écran en direct. Pendant un partage d'écran, un bandeau rouge « Écran partagé avec le parent » reste affiché sur le téléphone et le partage s'arrête tout seul au bout de 5 minutes."),
|
AboutEntry("Actions à distance du parent", "Depuis l'espace parents : verrouiller / mettre en veille, couper ou rétablir le son, faire sonner, voir l'écran en direct, et verrouiller le téléphone sur un écran de blocage que seul un code du serveur lève. Pendant un partage d'écran, un bandeau rouge « Écran partagé avec le parent » reste affiché sur le téléphone et le partage s'arrête tout seul au bout de 5 minutes."),
|
||||||
AboutEntry("Reprise automatique", "La surveillance et le tunnel redémarrent après un redémarrage de l'appareil, et sont relancés périodiquement si le système les a arrêtés.")
|
AboutEntry("Reprise automatique", "La surveillance et le tunnel redémarrent après un redémarrage de l'appareil, et sont relancés périodiquement si le système les a arrêtés.")
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -136,3 +137,49 @@ fun PasswordPromptDialog(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boîte de dialogue qui exige un code du serveur (dicté par le parent) pour une action sensible :
|
||||||
|
* désactiver le contrôle, se dissocier, sortir du mode propriétaire. Remplace le mot de passe local.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun UnlockCodeDialog(
|
||||||
|
title: String,
|
||||||
|
message: String,
|
||||||
|
error: String?,
|
||||||
|
busy: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (String) -> Unit
|
||||||
|
) {
|
||||||
|
var code by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(title) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(message, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = code,
|
||||||
|
onValueChange = { code = it.take(20) },
|
||||||
|
label = { Text(stringResource(R.string.unlock_code_field)) },
|
||||||
|
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Characters),
|
||||||
|
isError = error != null,
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
error?.let {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(enabled = !busy, onClick = { onConfirm(code) }) { Text(stringResource(R.string.common_confirm)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
OutlinedButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ import com.jttools.datacontrol.data.DataState
|
|||||||
import com.jttools.datacontrol.service.DataMonitorService
|
import com.jttools.datacontrol.service.DataMonitorService
|
||||||
import com.jttools.datacontrol.ui.SystemActions
|
import com.jttools.datacontrol.ui.SystemActions
|
||||||
import com.jttools.datacontrol.ui.components.PasswordPromptDialog
|
import com.jttools.datacontrol.ui.components.PasswordPromptDialog
|
||||||
|
import com.jttools.datacontrol.ui.components.UnlockCodeDialog
|
||||||
import com.jttools.datacontrol.ui.theme.StateCut
|
import com.jttools.datacontrol.ui.theme.StateCut
|
||||||
import com.jttools.datacontrol.ui.theme.StateOk
|
import com.jttools.datacontrol.ui.theme.StateOk
|
||||||
import com.jttools.datacontrol.ui.theme.StateWarning
|
import com.jttools.datacontrol.ui.theme.StateWarning
|
||||||
@@ -60,6 +61,9 @@ fun DashboardScreen(systemActions: SystemActions) {
|
|||||||
var showResetDialog by remember { mutableStateOf(false) }
|
var showResetDialog by remember { mutableStateOf(false) }
|
||||||
var showDisablePasswordDialog by remember { mutableStateOf(false) }
|
var showDisablePasswordDialog by remember { mutableStateOf(false) }
|
||||||
var disablePasswordError by remember { mutableStateOf<String?>(null) }
|
var disablePasswordError by remember { mutableStateOf<String?>(null) }
|
||||||
|
var showUnlockCodeDialog by remember { mutableStateOf(false) }
|
||||||
|
var unlockCodeError by remember { mutableStateOf<String?>(null) }
|
||||||
|
var unlockBusy by remember { mutableStateOf(false) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val stateColor = when {
|
val stateColor = when {
|
||||||
@@ -175,12 +179,19 @@ fun DashboardScreen(systemActions: SystemActions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (passwordSet != null) { // null : réglages pas encore chargés
|
} else { // désactivation du contrôle
|
||||||
if (passwordSet == true) {
|
when {
|
||||||
disablePasswordError = null
|
// Appareil associé : seul un code du serveur (dicté par le parent) l'autorise.
|
||||||
showDisablePasswordDialog = true
|
settings.isPaired -> {
|
||||||
} else {
|
unlockCodeError = null
|
||||||
viewModel.setControlEnabled(false)
|
unlockBusy = false
|
||||||
|
showUnlockCodeDialog = true
|
||||||
|
}
|
||||||
|
passwordSet == true -> {
|
||||||
|
disablePasswordError = null
|
||||||
|
showDisablePasswordDialog = true
|
||||||
|
}
|
||||||
|
passwordSet != null -> viewModel.setControlEnabled(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,6 +257,29 @@ fun DashboardScreen(systemActions: SystemActions) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showUnlockCodeDialog) {
|
||||||
|
UnlockCodeDialog(
|
||||||
|
title = stringResource(R.string.unlock_code_title),
|
||||||
|
message = stringResource(R.string.unlock_code_message),
|
||||||
|
error = unlockCodeError,
|
||||||
|
busy = unlockBusy,
|
||||||
|
onDismiss = { showUnlockCodeDialog = false },
|
||||||
|
onConfirm = { code ->
|
||||||
|
unlockBusy = true
|
||||||
|
scope.launch {
|
||||||
|
val error = viewModel.verifyUnlock(code)
|
||||||
|
unlockBusy = false
|
||||||
|
if (error == null) {
|
||||||
|
viewModel.setControlEnabled(false)
|
||||||
|
showUnlockCodeDialog = false
|
||||||
|
} else {
|
||||||
|
unlockCodeError = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (showDisablePasswordDialog) {
|
if (showDisablePasswordDialog) {
|
||||||
PasswordPromptDialog(
|
PasswordPromptDialog(
|
||||||
title = stringResource(R.string.password_gate_title),
|
title = stringResource(R.string.password_gate_title),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.jttools.datacontrol.data.AppSettings
|
import com.jttools.datacontrol.data.AppSettings
|
||||||
import com.jttools.datacontrol.data.SettingsRepository
|
import com.jttools.datacontrol.data.SettingsRepository
|
||||||
import com.jttools.datacontrol.data.UsageRepository
|
import com.jttools.datacontrol.data.UsageRepository
|
||||||
|
import com.jttools.datacontrol.remote.RemoteSync
|
||||||
import com.jttools.datacontrol.service.MonitorStateHolder
|
import com.jttools.datacontrol.service.MonitorStateHolder
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -18,6 +19,7 @@ import javax.inject.Inject
|
|||||||
class DashboardViewModel @Inject constructor(
|
class DashboardViewModel @Inject constructor(
|
||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository,
|
||||||
private val usageRepository: UsageRepository,
|
private val usageRepository: UsageRepository,
|
||||||
|
private val remoteSync: RemoteSync,
|
||||||
stateHolder: MonitorStateHolder
|
stateHolder: MonitorStateHolder
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -41,4 +43,7 @@ class DashboardViewModel @Inject constructor(
|
|||||||
|
|
||||||
/** null si le mot de passe est bon, sinon le message d'erreur. */
|
/** null si le mot de passe est bon, sinon le message d'erreur. */
|
||||||
suspend fun checkPassword(password: String): String? = settingsRepository.checkPassword(password)
|
suspend fun checkPassword(password: String): String? = settingsRepository.checkPassword(password)
|
||||||
|
|
||||||
|
/** Vérifie le code du serveur (action "deactivate") dicté par le parent. null = accepté. */
|
||||||
|
suspend fun verifyUnlock(code: String): String? = remoteSync.verifyUnlock(code, "deactivate")
|
||||||
}
|
}
|
||||||
@@ -34,6 +34,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import com.jttools.datacontrol.R
|
import com.jttools.datacontrol.R
|
||||||
import com.jttools.datacontrol.admin.OwnerPolicy
|
import com.jttools.datacontrol.admin.OwnerPolicy
|
||||||
import com.jttools.datacontrol.ui.SystemActions
|
import com.jttools.datacontrol.ui.SystemActions
|
||||||
|
import com.jttools.datacontrol.ui.components.UnlockCodeDialog
|
||||||
import com.jttools.datacontrol.ui.theme.StateOk
|
import com.jttools.datacontrol.ui.theme.StateOk
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -52,11 +53,16 @@ fun FamilySection(systemActions: SystemActions) {
|
|||||||
var code by remember { mutableStateOf("") }
|
var code by remember { mutableStateOf("") }
|
||||||
var deviceName by remember { mutableStateOf(android.os.Build.MODEL) }
|
var deviceName by remember { mutableStateOf(android.os.Build.MODEL) }
|
||||||
|
|
||||||
// Action sensible en attente de confirmation par mot de passe : "admin" ou "unpair".
|
// Action sensible en attente de confirmation par mot de passe : "admin", "unpair" ou "owner".
|
||||||
var pendingAction by remember { mutableStateOf<String?>(null) }
|
var pendingAction by remember { mutableStateOf<String?>(null) }
|
||||||
var pwd by remember { mutableStateOf("") }
|
var pwd by remember { mutableStateOf("") }
|
||||||
var pwdError by remember { mutableStateOf<String?>(null) }
|
var pwdError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
// Même action, mais confirmée par un code du serveur quand l'appareil est associé (remplace le mot de passe).
|
||||||
|
var pendingCodeAction by remember { mutableStateOf<String?>(null) }
|
||||||
|
var codeError by remember { mutableStateOf<String?>(null) }
|
||||||
|
var codeBusy by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Au retour des écrans système (accessibilité, permissions, admin), on relit l'état réel.
|
// Au retour des écrans système (accessibilité, permissions, admin), on relit l'état réel.
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
var owner by remember { mutableStateOf(OwnerPolicy.isDeviceOwner(context)) }
|
var owner by remember { mutableStateOf(OwnerPolicy.isDeviceOwner(context)) }
|
||||||
@@ -71,7 +77,12 @@ fun FamilySection(systemActions: SystemActions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun runProtected(action: String, block: () -> Unit) {
|
fun runProtected(action: String, block: () -> Unit) {
|
||||||
if (settings.hasPassword) { pendingAction = action; pwd = ""; pwdError = null } else block()
|
when {
|
||||||
|
// Appareil associé : un code du serveur (dicté par le parent) est exigé, pas le mot de passe local.
|
||||||
|
settings.isPaired -> { pendingCodeAction = action; codeError = null; codeBusy = false }
|
||||||
|
settings.hasPassword -> { pendingAction = action; pwd = ""; pwdError = null }
|
||||||
|
else -> block()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun execute(action: String) {
|
fun execute(action: String) {
|
||||||
@@ -229,6 +240,24 @@ fun FamilySection(systemActions: SystemActions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingCodeAction?.let { action ->
|
||||||
|
UnlockCodeDialog(
|
||||||
|
title = stringResource(R.string.unlock_code_title),
|
||||||
|
message = stringResource(R.string.unlock_code_message),
|
||||||
|
error = codeError,
|
||||||
|
busy = codeBusy,
|
||||||
|
onDismiss = { pendingCodeAction = null },
|
||||||
|
onConfirm = { code ->
|
||||||
|
codeBusy = true
|
||||||
|
scope.launch {
|
||||||
|
val err = vm.verifyUnlock(code)
|
||||||
|
codeBusy = false
|
||||||
|
if (err == null) { pendingCodeAction = null; execute(action) } else codeError = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pendingAction?.let { action ->
|
pendingAction?.let { action ->
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { pendingAction = null },
|
onDismissRequest = { pendingAction = null },
|
||||||
|
|||||||
@@ -63,4 +63,7 @@ class FamilyViewModel @Inject constructor(
|
|||||||
|
|
||||||
/** null si le mot de passe est bon, sinon le message d'erreur. */
|
/** null si le mot de passe est bon, sinon le message d'erreur. */
|
||||||
suspend fun checkPassword(password: String): String? = settingsRepository.checkPassword(password)
|
suspend fun checkPassword(password: String): String? = settingsRepository.checkPassword(password)
|
||||||
|
|
||||||
|
/** Vérifie le code du serveur (action "deactivate") dicté par le parent. null = accepté. */
|
||||||
|
suspend fun verifyUnlock(code: String): String? = remoteSync.verifyUnlock(code, "deactivate")
|
||||||
}
|
}
|
||||||
@@ -66,6 +66,17 @@
|
|||||||
<string name="password_field_label">Mot de passe</string>
|
<string name="password_field_label">Mot de passe</string>
|
||||||
<string name="password_unlock">Déverrouiller</string>
|
<string name="password_unlock">Déverrouiller</string>
|
||||||
|
|
||||||
|
<!-- Verrouillage parental (écran plein écran levé par un code du serveur) -->
|
||||||
|
<string name="parental_lock_title">Téléphone verrouillé</string>
|
||||||
|
<string name="parental_lock_message">Ce téléphone a été verrouillé à distance par le parent. Demandez-lui le code de déverrouillage.</string>
|
||||||
|
<string name="parental_lock_code_label">Code de déverrouillage</string>
|
||||||
|
<string name="parental_lock_unlock">Déverrouiller</string>
|
||||||
|
|
||||||
|
<!-- Code du serveur exigé pour désactiver le contrôle (remplace le mot de passe local) -->
|
||||||
|
<string name="unlock_code_title">Code de désactivation</string>
|
||||||
|
<string name="unlock_code_message">Pour désactiver le contrôle, demandez au parent de générer un code de désactivation depuis l\'espace parents, puis saisissez-le ici.</string>
|
||||||
|
<string name="unlock_code_field">Code du parent</string>
|
||||||
|
|
||||||
<!-- About -->
|
<!-- About -->
|
||||||
<string name="about_version">Version %1$s</string>
|
<string name="about_version">Version %1$s</string>
|
||||||
<string name="update_section">Mise à jour</string>
|
<string name="update_section">Mise à jour</string>
|
||||||
|
|||||||
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
versionName=0.7.0
|
versionName=0.8.0
|
||||||
versionCode=17
|
versionCode=18
|
||||||
Reference in new issue
Block a user