Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
665d6e2cb3 | ||
|
|
c66302a2bd | ||
|
|
b138a15513 | ||
|
|
303d2c4cdc | ||
|
|
36c829c14d | ||
|
|
ed8be296ba | ||
|
|
7af250cc1c | ||
|
|
e6215ab4a7 | ||
|
|
1b5558ff8b |
No files matched your search
@@ -1,5 +1,38 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.9.0 – 2026-10-25
|
||||||
|
|
||||||
|
- 🛠️ **Modifié** — **Quatre modes réseau distincts** décidés par le serveur, affichés sur le tableau de bord et la notification : *Accès libre* (aucun filtrage), *Liste noire* (tout sauf les services bloqués), *Liste blanche* (rien sauf les domaines autorisés), *Internet coupé*. La liste noire ne s'applique désormais que dans le mode qui lui correspond.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- ✨ **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).
|
||||||
|
|
||||||
|
## v0.6.0 – 2026-10-25
|
||||||
|
|
||||||
|
- ✨ **Ajouté** — **Faire sonner le téléphone** depuis l'espace parents : une sonnerie d'alarme retentit à plein volume (même si le téléphone est en silencieux), avec vibration et une notification « touchez pour arrêter » ; la sonnerie s'arrête d'elle-même au bout de la durée demandée, ou quand le parent l'arrête.
|
||||||
|
- 🛠️ **Modifié** — Chaque action à distance (verrouillage, son, partage d'écran, sonnerie) est désormais acquittée au serveur — exécutée ou en échec, avec le détail — ce qui complète l'audit des commandes côté espace parents.
|
||||||
|
|
||||||
|
## v0.5.0 – 2026-10-25
|
||||||
|
|
||||||
|
- ✨ **Ajouté** — **Actions à distance depuis l'espace parents** (téléphones DataControl) : voir l'écran en direct, verrouiller / mettre en veille, couper ou rétablir le son.
|
||||||
|
- ✨ **Ajouté** — Partage d'écran par le service d'accessibilité (Android 11+), image réduite envoyée par le tunnel ; un bandeau rouge « Écran partagé avec le parent » reste affiché sur le téléphone pendant toute la durée, et le partage s'arrête de lui-même au bout de 5 minutes.
|
||||||
|
|
||||||
|
## v0.4.0 – 2026-10-25
|
||||||
|
|
||||||
|
- ✨ **Ajouté** — **Mises à jour poussées depuis le serveur** : depuis l'espace parents (bouton « Déployer » ou « Mettre à jour DataControl partout »), la nouvelle version est téléchargée par le tunnel, son empreinte vérifiée, puis installée — sans aucune action sur le téléphone en mode propriétaire, sinon après un toucher sur la notification « Mise à jour de DataControl ».
|
||||||
|
- 🛠️ **Modifié** — DataControl redémarre sa surveillance tout seul juste après une mise à jour.
|
||||||
|
|
||||||
|
## 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
|
## 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.
|
- ✨ **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
|
# DataControl
|
||||||
|
|
||||||
🚀 v0.2.0 · 🤖 Android · 🟣 Kotlin · 🎨 Jetpack Compose · 🔒 WireGuard · 🛡️ mode propriétaire · 👨👩👧 contrôle parental · 🧪 tests · 📄 licence non déclarée
|
🚀 v0.9.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,9 +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).
|
||||||
|
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -29,7 +32,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.
|
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*.
|
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.
|
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
|
```bash
|
||||||
# téléphone branché en USB, débogage USB actif, tous les comptes retirés le temps de la commande
|
# téléphone branché en USB, débogage USB actif, tous les comptes retirés le temps de la commande
|
||||||
@@ -58,7 +65,7 @@ Réseau : sortant UDP 51721 (tunnel vers `fam-vpn.tuxtech.fr`) et HTTPS (appaira
|
|||||||
## Limites connues
|
## 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.
|
- 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.
|
- 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).
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
@@ -77,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"
|
||||||
@@ -87,15 +97,49 @@
|
|||||||
android:resource="@xml/device_admin" />
|
android:resource="@xml/device_admin" />
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.app.action.DEVICE_ADMIN_ENABLED" />
|
<action android:name="android.app.action.DEVICE_ADMIN_ENABLED" />
|
||||||
|
<action android:name="android.app.action.PROFILE_PROVISIONING_COMPLETE" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</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>
|
||||||
|
|
||||||
|
<!-- Résultat des mises à jour poussées par le serveur (PackageInstaller). -->
|
||||||
|
<receiver
|
||||||
|
android:name=".remote.InstallResultReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<!-- Arrêt de la sonnerie « Faire sonner » depuis la notification. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".remote.RingStopReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".service.BootReceiver"
|
android:name=".service.BootReceiver"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:enabled="true">
|
android:enabled="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ class AppDeviceAdminReceiver : DeviceAdminReceiver() {
|
|||||||
|
|
||||||
@Inject lateinit var remoteSync: RemoteSync
|
@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 =
|
override fun onDisableRequested(context: Context, intent: Intent): CharSequence =
|
||||||
"Désactiver la protection permet de désinstaller DataControl. Cette action est signalée à FamilyControl."
|
"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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
@@ -61,6 +62,7 @@ class SettingsRepository @Inject constructor(
|
|||||||
val POLICY_VERSION = longPreferencesKey("policy_version")
|
val POLICY_VERSION = longPreferencesKey("policy_version")
|
||||||
val EVENT_QUEUE = stringPreferencesKey("event_queue")
|
val EVENT_QUEUE = stringPreferencesKey("event_queue")
|
||||||
val MIGRATED_V02 = booleanPreferencesKey("migrated_v02")
|
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. */
|
/** Clés de la v0.1 (Firebase, liste blanche locale, coupure cyclique locale) retirées à la mise à jour. */
|
||||||
@@ -100,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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,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,
|
||||||
@@ -325,6 +333,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 --------------------------------------------------------------------
|
// --- File des évènements --------------------------------------------------------------------
|
||||||
|
|
||||||
suspend fun queueEvent(event: PendingEvent) {
|
suspend fun queueEvent(event: PendingEvent) {
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
package com.jttools.datacontrol.guard
|
package com.jttools.datacontrol.guard
|
||||||
|
|
||||||
import android.accessibilityservice.AccessibilityService
|
import android.accessibilityservice.AccessibilityService
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.PixelFormat
|
||||||
|
import android.os.Build
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.WindowManager
|
||||||
|
import android.widget.TextView
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -12,7 +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.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
|
||||||
@@ -22,8 +33,11 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Détecte l'application au premier plan (service d'accessibilité, sans lire le contenu des écrans)
|
* Détecte l'application au premier plan (service d'accessibilité, sans lire le contenu des écrans)
|
||||||
@@ -35,6 +49,13 @@ class AppGuardService : AccessibilityService() {
|
|||||||
|
|
||||||
@Inject lateinit var engine: GuardEngine
|
@Inject lateinit var engine: GuardEngine
|
||||||
@Inject lateinit var stateHolder: MonitorStateHolder
|
@Inject lateinit var stateHolder: MonitorStateHolder
|
||||||
|
@Inject lateinit var remoteControl: RemoteControl
|
||||||
|
@Inject lateinit var remoteSync: RemoteSync
|
||||||
|
@Inject lateinit var settings: SettingsRepository
|
||||||
|
|
||||||
|
@Volatile private var parentalLocked = false
|
||||||
|
|
||||||
|
private var shareBanner: TextView? = null
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
private var exempt: Set<String> = emptySet()
|
private var exempt: Set<String> = emptySet()
|
||||||
@@ -62,14 +83,114 @@ 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partage d'écran demandé par le parent : capture l'écran (API 30+) et l'envoie au serveur, tant que
|
||||||
|
* [RemoteControl.screenShareUntil] n'est pas atteint. Un bandeau « Écran partagé » reste affiché.
|
||||||
|
*/
|
||||||
|
private fun startScreenShareLoop() = scope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
if (!remoteControl.screenSharing) {
|
||||||
|
hideBanner()
|
||||||
|
delay(1_000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||||
|
AppLog.w("Écran", "Partage d'écran indisponible (Android 11 minimum)")
|
||||||
|
remoteSync.pushEvent("screen_unsupported", "Partage d'écran impossible : Android 11 minimum")
|
||||||
|
delay(5_000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
showBanner()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
val jpeg = captureJpeg()
|
||||||
|
if (jpeg != null) remoteSync.pushScreen(jpeg)
|
||||||
|
}
|
||||||
|
delay(SCREEN_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@androidx.annotation.RequiresApi(Build.VERSION_CODES.R)
|
||||||
|
private suspend fun captureJpeg(): ByteArray? {
|
||||||
|
val bitmap = suspendCancellableCoroutine<Bitmap?> { cont ->
|
||||||
|
runCatching {
|
||||||
|
takeScreenshot(android.view.Display.DEFAULT_DISPLAY, mainExecutor,
|
||||||
|
object : TakeScreenshotCallback {
|
||||||
|
override fun onSuccess(result: ScreenshotResult) {
|
||||||
|
val bmp = runCatching {
|
||||||
|
Bitmap.wrapHardwareBuffer(result.hardwareBuffer, result.colorSpace)
|
||||||
|
?.copy(Bitmap.Config.ARGB_8888, false)
|
||||||
|
}.getOrNull()
|
||||||
|
result.hardwareBuffer.close()
|
||||||
|
if (cont.isActive) cont.resume(bmp)
|
||||||
|
}
|
||||||
|
override fun onFailure(errorCode: Int) {
|
||||||
|
if (cont.isActive) cont.resume(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}.onFailure { if (cont.isActive) cont.resume(null) }
|
||||||
|
} ?: return null
|
||||||
|
return runCatching {
|
||||||
|
val scaled = scaleDown(bitmap, MAX_WIDTH)
|
||||||
|
if (scaled !== bitmap) bitmap.recycle()
|
||||||
|
ByteArrayOutputStream().use { out ->
|
||||||
|
scaled.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out)
|
||||||
|
scaled.recycle()
|
||||||
|
out.toByteArray()
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scaleDown(bmp: Bitmap, maxWidth: Int): Bitmap {
|
||||||
|
if (bmp.width <= maxWidth) return bmp
|
||||||
|
val h = bmp.height * maxWidth / bmp.width
|
||||||
|
return Bitmap.createScaledBitmap(bmp, maxWidth, h, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showBanner() {
|
||||||
|
if (shareBanner != null) return
|
||||||
|
val wm = getSystemService(WindowManager::class.java)
|
||||||
|
val tv = TextView(this).apply {
|
||||||
|
text = getString(com.jttools.datacontrol.R.string.screen_shared_banner)
|
||||||
|
setBackgroundColor(Color.parseColor("#C62828"))
|
||||||
|
setTextColor(Color.WHITE)
|
||||||
|
setPadding(24, 12, 24, 12)
|
||||||
|
textSize = 13f
|
||||||
|
}
|
||||||
|
val lp = WindowManager.LayoutParams(
|
||||||
|
WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.WRAP_CONTENT,
|
||||||
|
WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
|
||||||
|
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE,
|
||||||
|
PixelFormat.TRANSLUCENT
|
||||||
|
).apply { gravity = Gravity.TOP }
|
||||||
|
runCatching { wm.addView(tv, lp); shareBanner = tv }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hideBanner() {
|
||||||
|
shareBanner?.let { runCatching { getSystemService(WindowManager::class.java).removeView(it) } }
|
||||||
|
shareBanner = null
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -90,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
|
||||||
@@ -122,6 +248,7 @@ class AppGuardService : AccessibilityService() {
|
|||||||
return when (snap.serverMode) {
|
return when (snap.serverMode) {
|
||||||
"cut" -> "BLOQUÉ (Internet coupé par le serveur) [$net]" to true
|
"cut" -> "BLOQUÉ (Internet coupé par le serveur) [$net]" to true
|
||||||
"allowlist" -> "LIMITÉ (liste blanche du serveur) [$net]" to true
|
"allowlist" -> "LIMITÉ (liste blanche du serveur) [$net]" to true
|
||||||
|
"blocklist" -> "filtré (liste noire du serveur) [$net]" to false
|
||||||
else -> "filtré par le serveur (accès libre) [$net]" to false
|
else -> "filtré par le serveur (accès libre) [$net]" to false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,6 +279,7 @@ class AppGuardService : AccessibilityService() {
|
|||||||
override fun onInterrupt() = Unit
|
override fun onInterrupt() = Unit
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
hideBanner()
|
||||||
runCatching { unregisterReceiver(screenReceiver) }
|
runCatching { unregisterReceiver(screenReceiver) }
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
@@ -160,6 +288,9 @@ class AppGuardService : AccessibilityService() {
|
|||||||
companion object {
|
companion object {
|
||||||
private const val CHECK_INTERVAL_MS = 10_000L
|
private const val CHECK_INTERVAL_MS = 10_000L
|
||||||
private const val EXEMPT_TTL_MS = 5 * 60_000L
|
private const val EXEMPT_TTL_MS = 5 * 60_000L
|
||||||
|
private const val SCREEN_INTERVAL_MS = 1_500L
|
||||||
|
private const val MAX_WIDTH = 720
|
||||||
|
private const val JPEG_QUALITY = 50
|
||||||
|
|
||||||
fun isEnabled(context: Context): Boolean {
|
fun isEnabled(context: Context): Boolean {
|
||||||
val enabled = Settings.Secure.getString(
|
val enabled = Settings.Secure.getString(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package com.jttools.datacontrol.remote
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.SystemClock
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.jttools.datacontrol.BuildConfig
|
||||||
|
import com.jttools.datacontrol.R
|
||||||
|
import com.jttools.datacontrol.data.UpdateManager
|
||||||
|
import com.jttools.datacontrol.log.AppLog
|
||||||
|
import dagger.hilt.EntryPoint
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mise à jour de DataControl poussée depuis le serveur familyd (bouton « Déployer » de l'espace parents).
|
||||||
|
*
|
||||||
|
* L'APK est téléchargé dans le tunnel, son empreinte SHA-256 vérifiée, puis confié à l'installateur
|
||||||
|
* d'Android : installation silencieuse en mode propriétaire de l'appareil ; sinon, une notification
|
||||||
|
* « Touchez pour installer » (Android impose alors une confirmation). Android refuse de toute façon un
|
||||||
|
* APK qui ne porte pas la signature de l'application installée.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AppDeployer @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) {
|
||||||
|
private var lastVersion: String? = null
|
||||||
|
private var lastAttemptAt = 0L
|
||||||
|
|
||||||
|
/** [order] : {"version", "url", "sha256", "size"} reçu dans la consigne du serveur. */
|
||||||
|
suspend fun handle(order: JSONObject, apiBase: String, report: suspend (String, String) -> Unit) =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val version = order.optString("version")
|
||||||
|
if (version.isBlank()) return@withContext
|
||||||
|
// Déploiement forcé (option du parent) : réinstalle même version comprise, en ignorant les garde-fous.
|
||||||
|
val force = order.optBoolean("force", false)
|
||||||
|
if (!force && !UpdateManager.isNewer(version, BuildConfig.VERSION_NAME)) return@withContext
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
if (!force && version == lastVersion && now - lastAttemptAt < RETRY_MS) return@withContext
|
||||||
|
lastVersion = version
|
||||||
|
lastAttemptAt = now
|
||||||
|
AppLog.i("Mise à jour", "Déploiement de DataControl $version demandé par le serveur" + if (force) " (forcé)" else "")
|
||||||
|
try {
|
||||||
|
val file = download(apiBase + order.getString("url"), order.getString("sha256").lowercase(), version)
|
||||||
|
install(file, version)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
AppLog.e("Mise à jour", "Déploiement de $version impossible", e)
|
||||||
|
report("update_failed", "DataControl $version : ${e.message ?: e.javaClass.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun download(url: String, sha256: String, version: String): File {
|
||||||
|
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
|
||||||
|
dir.listFiles()?.forEach { it.delete() }
|
||||||
|
val file = File(dir, "DataControl-$version.apk")
|
||||||
|
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||||
|
connectTimeout = 15_000
|
||||||
|
readTimeout = 60_000
|
||||||
|
}
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
try {
|
||||||
|
if (conn.responseCode != HttpURLConnection.HTTP_OK) error("téléchargement refusé (HTTP ${conn.responseCode})")
|
||||||
|
var total = 0L
|
||||||
|
conn.inputStream.use { input ->
|
||||||
|
file.outputStream().use { out ->
|
||||||
|
val buf = ByteArray(64 * 1024)
|
||||||
|
while (true) {
|
||||||
|
val n = input.read(buf)
|
||||||
|
if (n < 0) break
|
||||||
|
total += n
|
||||||
|
if (total > MAX_SIZE) error("APK trop volumineux")
|
||||||
|
digest.update(buf, 0, n)
|
||||||
|
out.write(buf, 0, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
conn.disconnect()
|
||||||
|
}
|
||||||
|
val got = digest.digest().joinToString("") { "%02x".format(it) }
|
||||||
|
if (got != sha256) {
|
||||||
|
file.delete()
|
||||||
|
error("empreinte SHA-256 différente de celle annoncée par le serveur")
|
||||||
|
}
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun install(file: File, version: String) {
|
||||||
|
val installer = context.packageManager.packageInstaller
|
||||||
|
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
|
||||||
|
setAppPackageName(context.packageName)
|
||||||
|
setSize(file.length())
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val id = installer.createSession(params)
|
||||||
|
installer.openSession(id).use { session ->
|
||||||
|
session.openWrite("base.apk", 0, file.length()).use { out ->
|
||||||
|
file.inputStream().use { it.copyTo(out) }
|
||||||
|
session.fsync(out)
|
||||||
|
}
|
||||||
|
val result = Intent(context, InstallResultReceiver::class.java).putExtra(EXTRA_VERSION, version)
|
||||||
|
val flags = PendingIntent.FLAG_UPDATE_CURRENT or
|
||||||
|
(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0)
|
||||||
|
session.commit(PendingIntent.getBroadcast(context, id, result, flags).intentSender)
|
||||||
|
}
|
||||||
|
AppLog.i("Mise à jour", "DataControl $version confié à l'installateur d'Android")
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val EXTRA_VERSION = "version"
|
||||||
|
private const val RETRY_MS = 30 * 60_000L
|
||||||
|
private const val MAX_SIZE = 100L * 1024 * 1024
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Résultat de l'installation : confirmation à demander à l'utilisateur, succès ou échec (signalé au parent). */
|
||||||
|
class InstallResultReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface Deps {
|
||||||
|
fun remoteSync(): RemoteSync
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val version = intent.getStringExtra(AppDeployer.EXTRA_VERSION).orEmpty()
|
||||||
|
when (val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
|
||||||
|
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||||
|
val confirm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_INTENT)
|
||||||
|
} ?: return
|
||||||
|
AppLog.i("Mise à jour", "DataControl $version : confirmation de l'utilisateur demandée")
|
||||||
|
notifyConfirm(context, confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), version)
|
||||||
|
}
|
||||||
|
PackageInstaller.STATUS_SUCCESS -> AppLog.i("Mise à jour", "DataControl $version installé")
|
||||||
|
else -> {
|
||||||
|
val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) ?: "code $status"
|
||||||
|
AppLog.e("Mise à jour", "Installation de DataControl $version refusée : $msg")
|
||||||
|
val pending = goAsync()
|
||||||
|
val remote = EntryPointAccessors.fromApplication(context.applicationContext, Deps::class.java).remoteSync()
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
remote.pushEvent("update_failed", "DataControl $version : $msg")
|
||||||
|
} finally {
|
||||||
|
pending.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notifyConfirm(context: Context, confirm: Intent, version: String) {
|
||||||
|
val nm = context.getSystemService(NotificationManager::class.java)
|
||||||
|
nm.createNotificationChannel(NotificationChannel(CHANNEL, "Mises à jour", NotificationManager.IMPORTANCE_HIGH))
|
||||||
|
val pi = PendingIntent.getActivity(context, 0, confirm, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||||
|
nm.notify(NOTIF_ID, NotificationCompat.Builder(context, CHANNEL)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle("Mise à jour de DataControl $version")
|
||||||
|
.setContentText("Touchez pour installer la version envoyée par le parent.")
|
||||||
|
.setContentIntent(pi)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val CHANNEL = "datacontrol_updates"
|
||||||
|
const val NOTIF_ID = 3101
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,31 @@ class FamilyApi(private val apiBase: String, private val token: String) {
|
|||||||
request("POST", "$apiBase/api/device/apps", token, JSONObject().put("apps", apps))
|
request("POST", "$apiBase/api/device/apps", token, JSONObject().put("apps", apps))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Attente longue des commandes du parent (verrouillage, son, partage d'écran). */
|
||||||
|
fun commands(waitSeconds: Int): JSONArray =
|
||||||
|
JSONObject(request("GET", "$apiBase/api/device/commands?wait=$waitSeconds", token, null, waitSeconds * 1000 + 5000))
|
||||||
|
.optJSONArray("commands") ?: JSONArray()
|
||||||
|
|
||||||
|
/** Envoie une image de l'écran (JPEG) pendant un partage. */
|
||||||
|
fun screen(jpeg: ByteArray) {
|
||||||
|
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). */
|
||||||
|
fun commandAck(id: String, status: String, detail: String) {
|
||||||
|
request("POST", "$apiBase/api/device/command-ack", token,
|
||||||
|
JSONObject().put("id", id).put("status", status).put("detail", detail.take(300)))
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val CONNECT_TIMEOUT_MS = 10_000
|
private const val CONNECT_TIMEOUT_MS = 10_000
|
||||||
private const val READ_TIMEOUT_MS = 15_000
|
private const val READ_TIMEOUT_MS = 15_000
|
||||||
@@ -50,24 +75,31 @@ class FamilyApi(private val apiBase: String, private val token: String) {
|
|||||||
return request("POST", "${serverUrl.trimEnd('/')}/api/pair", null, body)
|
return request("POST", "${serverUrl.trimEnd('/')}/api/pair", null, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun request(method: String, url: String, token: String?, body: JSONObject?): String {
|
fun request(method: String, url: String, token: String?, body: JSONObject?, readTimeoutMs: Int = READ_TIMEOUT_MS): String {
|
||||||
|
return request(method, url, token, body?.toString()?.toByteArray(Charsets.UTF_8),
|
||||||
|
"application/json; charset=utf-8", readTimeoutMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun request(method: String, url: String, token: String?, body: ByteArray?, contentType: String,
|
||||||
|
readTimeoutMs: Int = READ_TIMEOUT_MS): String {
|
||||||
val conn = try {
|
val conn = try {
|
||||||
(URL(url).openConnection() as HttpURLConnection).apply {
|
(URL(url).openConnection() as HttpURLConnection).apply {
|
||||||
requestMethod = method
|
requestMethod = method
|
||||||
connectTimeout = CONNECT_TIMEOUT_MS
|
connectTimeout = CONNECT_TIMEOUT_MS
|
||||||
readTimeout = READ_TIMEOUT_MS
|
readTimeout = readTimeoutMs
|
||||||
setRequestProperty("Accept", "application/json")
|
setRequestProperty("Accept", "application/json")
|
||||||
token?.let { setRequestProperty("Authorization", "Bearer $it") }
|
token?.let { setRequestProperty("Authorization", "Bearer $it") }
|
||||||
if (body != null) {
|
if (body != null) {
|
||||||
doOutput = true
|
doOutput = true
|
||||||
setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
setRequestProperty("Content-Type", contentType)
|
||||||
|
setFixedLengthStreamingMode(body.size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
throw ApiException(0, "Serveur injoignable : ${e.message}")
|
throw ApiException(0, "Serveur injoignable : ${e.message}")
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (body != null) conn.outputStream.use { it.write(body.toString().toByteArray(Charsets.UTF_8)) }
|
if (body != null) conn.outputStream.use { it.write(body) }
|
||||||
val code = conn.responseCode
|
val code = conn.responseCode
|
||||||
val text = (if (code in 200..299) conn.inputStream else conn.errorStream)
|
val text = (if (code in 200..299) conn.inputStream else conn.errorStream)
|
||||||
?.bufferedReader()?.use { it.readText() }.orEmpty()
|
?.bufferedReader()?.use { it.readText() }.orEmpty()
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package com.jttools.datacontrol.remote
|
||||||
|
|
||||||
|
import android.app.admin.DevicePolicyManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.os.SystemClock
|
||||||
|
import com.jttools.datacontrol.admin.AdminController
|
||||||
|
import com.jttools.datacontrol.data.SettingsRepository
|
||||||
|
import com.jttools.datacontrol.guard.ParentalLockActivity
|
||||||
|
import com.jttools.datacontrol.log.AppLog
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.json.JSONObject
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actions à distance demandées par le parent (via le serveur familyd) : verrouiller / mettre en veille,
|
||||||
|
* couper ou rétablir le son, partager l'écran. La boucle interroge le serveur en attente longue ; le
|
||||||
|
* partage d'écran est réalisé par le service d'accessibilité ([com.jttools.datacontrol.guard.AppGuardService]),
|
||||||
|
* qui observe [screenShareUntil].
|
||||||
|
*
|
||||||
|
* Le partage d'écran est toujours visible du téléphone (notification + bandeau « Écran partagé avec le parent »).
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class RemoteControl @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val remote: RemoteSync,
|
||||||
|
private val settings: SettingsRepository,
|
||||||
|
private val ringer: Ringer
|
||||||
|
) {
|
||||||
|
/** Instant (temps monotone) jusqu'auquel l'écran est partagé ; 0 = pas de partage. */
|
||||||
|
private val _screenShareUntil = MutableStateFlow(0L)
|
||||||
|
val screenShareUntil: StateFlow<Long> = _screenShareUntil.asStateFlow()
|
||||||
|
|
||||||
|
val screenSharing: Boolean get() = _screenShareUntil.value > SystemClock.elapsedRealtime()
|
||||||
|
|
||||||
|
private var started = false
|
||||||
|
|
||||||
|
fun start(scope: CoroutineScope) {
|
||||||
|
if (started) return
|
||||||
|
started = true
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
while (isActive) {
|
||||||
|
if (!settings.settingsFlow.first().isPaired) {
|
||||||
|
delay(10_000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val commands = remote.pollCommands(WAIT_SECONDS)
|
||||||
|
if (commands == null) {
|
||||||
|
delay(5_000) // tunnel coupé / serveur injoignable : on réessaie posément
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (i in 0 until commands.length()) {
|
||||||
|
val cmd = commands.getJSONObject(i)
|
||||||
|
val outcome = runCatching { handle(cmd) }
|
||||||
|
val (status, detail) = outcome.getOrNull()
|
||||||
|
?: ("failed" to (outcome.exceptionOrNull()?.message ?: "erreur"))
|
||||||
|
remote.ackCommand(cmd.optString("id"), status, detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fin automatique du partage d'écran à l'échéance.
|
||||||
|
scope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
if (screenSharing) {
|
||||||
|
delay(1_000)
|
||||||
|
} else {
|
||||||
|
if (_screenShareUntil.value != 0L) _screenShareUntil.value = 0L
|
||||||
|
delay(2_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renvoie (statut, détail) pour l'audit : "done" ou "failed". */
|
||||||
|
private suspend fun handle(cmd: JSONObject): Pair<String, String> = when (cmd.optString("type")) {
|
||||||
|
"lock" -> lock()
|
||||||
|
"plock" -> parentalLock(true)
|
||||||
|
"punlock" -> parentalLock(false)
|
||||||
|
"mute" -> setMuted(true)
|
||||||
|
"unmute" -> setMuted(false)
|
||||||
|
"screen_start" -> {
|
||||||
|
val seconds = cmd.optInt("seconds", 120).coerceIn(10, 300)
|
||||||
|
_screenShareUntil.value = SystemClock.elapsedRealtime() + seconds * 1000L
|
||||||
|
AppLog.i("Commande", "Partage d'écran démarré ($seconds s)")
|
||||||
|
"done" to "partage démarré"
|
||||||
|
}
|
||||||
|
"screen_stop" -> {
|
||||||
|
_screenShareUntil.value = 0L
|
||||||
|
AppLog.i("Commande", "Partage d'écran arrêté")
|
||||||
|
"done" to ""
|
||||||
|
}
|
||||||
|
"ring" -> ringer.start(cmd.optInt("seconds", 60))
|
||||||
|
"ring_stop" -> {
|
||||||
|
ringer.stop(silent = false)
|
||||||
|
"done" to ""
|
||||||
|
}
|
||||||
|
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> {
|
||||||
|
val dpm = context.getSystemService(DevicePolicyManager::class.java)
|
||||||
|
if (!AdminController.isActive(context)) {
|
||||||
|
AppLog.w("Commande", "Verrouillage impossible : DataControl n'est pas administrateur de l'appareil")
|
||||||
|
return "failed" to "DataControl n'est pas administrateur de l'appareil"
|
||||||
|
}
|
||||||
|
return runCatching { dpm.lockNow() }.fold(
|
||||||
|
onSuccess = { AppLog.i("Commande", "Téléphone verrouillé / mis en veille"); "done" to "" },
|
||||||
|
onFailure = { AppLog.w("Commande", "Verrouillage refusé : ${it.message}"); "failed" to (it.message ?: "refusé") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setMuted(muted: Boolean): Pair<String, String> {
|
||||||
|
val am = context.getSystemService(AudioManager::class.java)
|
||||||
|
val dir = if (muted) AudioManager.ADJUST_MUTE else AudioManager.ADJUST_UNMUTE
|
||||||
|
val streams = intArrayOf(
|
||||||
|
AudioManager.STREAM_MUSIC, AudioManager.STREAM_RING,
|
||||||
|
AudioManager.STREAM_NOTIFICATION, AudioManager.STREAM_SYSTEM
|
||||||
|
)
|
||||||
|
var any = false
|
||||||
|
for (s in streams) {
|
||||||
|
// Le son de la sonnerie peut exiger l'accès « Ne pas déranger » : on n'échoue pas pour autant.
|
||||||
|
if (runCatching { am.adjustStreamVolume(s, dir, 0) }.isSuccess) any = true
|
||||||
|
}
|
||||||
|
AppLog.i("Commande", if (muted) "Son coupé (${if (any) "ok" else "refusé"})" else "Son rétabli")
|
||||||
|
return if (any) "done" to "" else "failed" to "aucun flux audio modifiable (accès « Ne pas déranger » ?)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val WAIT_SECONDS = 25
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,8 @@ import javax.inject.Singleton
|
|||||||
class RemoteSync @Inject constructor(
|
class RemoteSync @Inject constructor(
|
||||||
private val settings: SettingsRepository,
|
private val settings: SettingsRepository,
|
||||||
private val installedApps: InstalledAppsRepository,
|
private val installedApps: InstalledAppsRepository,
|
||||||
private val tunnel: TunnelManager
|
private val tunnel: TunnelManager,
|
||||||
|
private val deployer: AppDeployer
|
||||||
) {
|
) {
|
||||||
private val flushLock = Mutex()
|
private val flushLock = Mutex()
|
||||||
private var started = false
|
private var started = false
|
||||||
@@ -45,6 +46,48 @@ class RemoteSync @Inject constructor(
|
|||||||
return FamilyApi(link.apiBase, token)
|
return FamilyApi(link.apiBase, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Attend les commandes du parent (bloquant jusqu'à [waitSeconds] s). null si le tunnel est coupé ou en erreur. */
|
||||||
|
suspend fun pollCommands(waitSeconds: Int): JSONArray? = withContext(Dispatchers.IO) {
|
||||||
|
if (!tunnel.up.value) return@withContext null
|
||||||
|
val api = api() ?: return@withContext null
|
||||||
|
try { api.commands(waitSeconds) } catch (_: ApiException) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Envoie une image de l'écran pendant un partage. */
|
||||||
|
suspend fun pushScreen(jpeg: ByteArray): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
if (!tunnel.up.value) return@withContext false
|
||||||
|
val api = api() ?: return@withContext 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). */
|
||||||
|
suspend fun ackCommand(id: String, status: String, detail: String = "") = withContext(Dispatchers.IO) {
|
||||||
|
if (id.isBlank() || !tunnel.up.value) return@withContext
|
||||||
|
val api = api() ?: return@withContext
|
||||||
|
runCatching { api.commandAck(id, status, detail) }
|
||||||
|
}
|
||||||
|
|
||||||
/** Associe le téléphone au serveur : clés WireGuard générées ici, la clé privée ne quitte jamais l'appareil. */
|
/** Associe le téléphone au serveur : clés WireGuard générées ici, la clé privée ne quitte jamais l'appareil. */
|
||||||
suspend fun pair(input: PairingInput, deviceName: String): Result<FamilyLink> = withContext(Dispatchers.IO) {
|
suspend fun pair(input: PairingInput, deviceName: String): Result<FamilyLink> = withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -98,6 +141,10 @@ class RemoteSync @Inject constructor(
|
|||||||
settings.applyServerPolicy(version, mode, reason, rules)
|
settings.applyServerPolicy(version, mode, reason, rules)
|
||||||
if (mode != current.serverMode) AppLog.i("Liaison", "Mode réseau : $mode${if (reason.isNotBlank()) " ($reason)" else ""}")
|
if (mode != current.serverMode) AppLog.i("Liaison", "Mode réseau : $mode${if (reason.isNotBlank()) " ($reason)" else ""}")
|
||||||
}
|
}
|
||||||
|
// Mise à jour de DataControl poussée par le parent.
|
||||||
|
p.optJSONObject("deploy")?.let { order ->
|
||||||
|
current.link?.let { link -> deployer.handle(order, link.apiBase) { t, d -> pushEvent(t, d) } }
|
||||||
|
}
|
||||||
flushEvents()
|
flushEvents()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package com.jttools.datacontrol.remote
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.media.MediaPlayer
|
||||||
|
import android.media.RingtoneManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.VibrationEffect
|
||||||
|
import android.os.Vibrator
|
||||||
|
import android.os.VibratorManager
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.jttools.datacontrol.R
|
||||||
|
import com.jttools.datacontrol.log.AppLog
|
||||||
|
import dagger.hilt.EntryPoint
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import java.util.Timer
|
||||||
|
import java.util.TimerTask
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* « Faire sonner » demandé par le parent : joue une sonnerie d'alarme à plein volume, même si le
|
||||||
|
* téléphone est en silencieux (le flux d'alarme n'est pas concerné par le mode silencieux), vibre et
|
||||||
|
* affiche une notification « Appuyez pour arrêter ». S'arrête tout seul au bout de la durée demandée,
|
||||||
|
* à la demande du parent (commande ring_stop), ou quand l'enfant touche la notification.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class Ringer @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) {
|
||||||
|
private var player: MediaPlayer? = null
|
||||||
|
private var previousVolume = -1
|
||||||
|
private var stopTimer: Timer? = null
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun start(seconds: Int): Pair<String, String> {
|
||||||
|
stop(silent = true) // repart proprement si une sonnerie était déjà en cours
|
||||||
|
val am = context.getSystemService(AudioManager::class.java)
|
||||||
|
val uri = RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_ALARM)
|
||||||
|
?: RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_RINGTONE)
|
||||||
|
?: return "failed" to "aucune sonnerie disponible"
|
||||||
|
return try {
|
||||||
|
previousVolume = am.getStreamVolume(AudioManager.STREAM_ALARM)
|
||||||
|
am.setStreamVolume(AudioManager.STREAM_ALARM, am.getStreamMaxVolume(AudioManager.STREAM_ALARM), 0)
|
||||||
|
player = MediaPlayer().apply {
|
||||||
|
setAudioAttributes(
|
||||||
|
AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_ALARM)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
setDataSource(context, uri)
|
||||||
|
isLooping = true
|
||||||
|
prepare()
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
vibrate(true)
|
||||||
|
notify(context)
|
||||||
|
val secs = seconds.coerceIn(5, 300)
|
||||||
|
stopTimer = Timer().apply {
|
||||||
|
schedule(object : TimerTask() {
|
||||||
|
override fun run() = stop(silent = false)
|
||||||
|
}, secs * 1000L)
|
||||||
|
}
|
||||||
|
AppLog.i("Commande", "Sonnerie déclenchée ($secs s)")
|
||||||
|
"done" to "sonnerie déclenchée"
|
||||||
|
} catch (e: Exception) {
|
||||||
|
AppLog.e("Commande", "Sonnerie impossible", e)
|
||||||
|
stop(silent = true)
|
||||||
|
"failed" to (e.message ?: "sonnerie impossible")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun stop(silent: Boolean) {
|
||||||
|
stopTimer?.cancel(); stopTimer = null
|
||||||
|
runCatching { player?.stop() }
|
||||||
|
runCatching { player?.release() }
|
||||||
|
player = null
|
||||||
|
vibrate(false)
|
||||||
|
if (previousVolume >= 0) {
|
||||||
|
runCatching {
|
||||||
|
context.getSystemService(AudioManager::class.java)
|
||||||
|
.setStreamVolume(AudioManager.STREAM_ALARM, previousVolume, 0)
|
||||||
|
}
|
||||||
|
previousVolume = -1
|
||||||
|
}
|
||||||
|
context.getSystemService(NotificationManager::class.java).cancel(NOTIF_ID)
|
||||||
|
if (!silent) AppLog.i("Commande", "Sonnerie arrêtée")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun vibrate(on: Boolean) {
|
||||||
|
val vib = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
context.getSystemService(VibratorManager::class.java)?.defaultVibrator
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION") context.getSystemService(Vibrator::class.java)
|
||||||
|
} ?: return
|
||||||
|
if (!on) { runCatching { vib.cancel() }; return }
|
||||||
|
runCatching {
|
||||||
|
val pattern = longArrayOf(0, 600, 400)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
vib.vibrate(VibrationEffect.createWaveform(pattern, 0))
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION") vib.vibrate(pattern, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notify(context: Context) {
|
||||||
|
val nm = context.getSystemService(NotificationManager::class.java)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
nm.createNotificationChannel(
|
||||||
|
NotificationChannel(CHANNEL, "Sonnerie", NotificationManager.IMPORTANCE_HIGH).apply {
|
||||||
|
setSound(null, null) // le son est joué par le lecteur (flux d'alarme)
|
||||||
|
enableVibration(false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val stop = PendingIntent.getBroadcast(
|
||||||
|
context, 0, Intent(context, RingStopReceiver::class.java),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
nm.notify(NOTIF_ID, NotificationCompat.Builder(context, CHANNEL)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle("DataControl sonne")
|
||||||
|
.setContentText("Demandé par le parent — touchez pour arrêter.")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_ALARM)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(stop)
|
||||||
|
.setFullScreenIntent(stop, true)
|
||||||
|
.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CHANNEL = "datacontrol_ring"
|
||||||
|
private const val NOTIF_ID = 3102
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Arrêt de la sonnerie depuis la notification (touche de l'enfant). */
|
||||||
|
class RingStopReceiver : BroadcastReceiver() {
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface Deps {
|
||||||
|
fun ringer(): Ringer
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
EntryPointAccessors.fromApplication(context.applicationContext, Deps::class.java).ringer().stop(silent = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,8 @@ class BootReceiver : BroadcastReceiver() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent?) {
|
override fun onReceive(context: Context, intent: Intent?) {
|
||||||
if (intent?.action != Intent.ACTION_BOOT_COMPLETED) return
|
// Redémarrage de l'appareil, ou DataControl vient d'être mis à jour (déploiement depuis le serveur).
|
||||||
|
if (intent?.action != Intent.ACTION_BOOT_COMPLETED && intent?.action != Intent.ACTION_MY_PACKAGE_REPLACED) return
|
||||||
val appContext = context.applicationContext
|
val appContext = context.applicationContext
|
||||||
val entryPoint = EntryPointAccessors.fromApplication(appContext, BootReceiverEntryPoint::class.java)
|
val entryPoint = EntryPointAccessors.fromApplication(appContext, BootReceiverEntryPoint::class.java)
|
||||||
val pendingResult = goAsync()
|
val pendingResult = goAsync()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -52,6 +53,7 @@ class DataMonitorService : LifecycleService() {
|
|||||||
@Inject lateinit var remoteSync: RemoteSync
|
@Inject lateinit var remoteSync: RemoteSync
|
||||||
@Inject lateinit var guardEngine: GuardEngine
|
@Inject lateinit var guardEngine: GuardEngine
|
||||||
@Inject lateinit var tunnel: TunnelManager
|
@Inject lateinit var tunnel: TunnelManager
|
||||||
|
@Inject lateinit var remoteControl: com.jttools.datacontrol.remote.RemoteControl
|
||||||
|
|
||||||
private var latestSettings: AppSettings = AppSettings()
|
private var latestSettings: AppSettings = AppSettings()
|
||||||
private var latestTransport: NetworkTransport = NetworkTransport.NONE
|
private var latestTransport: NetworkTransport = NetworkTransport.NONE
|
||||||
@@ -62,6 +64,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. */
|
/** 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 serverSeenAt = SystemClock.elapsedRealtime()
|
||||||
private var lockdownRelaxed = false
|
private var lockdownRelaxed = false
|
||||||
|
private var lastProvisionAttempt = 0L
|
||||||
|
private var provisionJob: Job? = null
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
super.onStartCommand(intent, flags, startId)
|
super.onStartCommand(intent, flags, startId)
|
||||||
@@ -104,6 +108,15 @@ class DataMonitorService : LifecycleService() {
|
|||||||
}
|
}
|
||||||
guardEngine.start()
|
guardEngine.start()
|
||||||
deviceReporter.start(lifecycleScope)
|
deviceReporter.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
|
||||||
@@ -136,9 +149,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 wanted = settings.controlEnabled && settings.isPaired
|
||||||
val up = tunnel.up.value
|
val up = tunnel.up.value
|
||||||
|
applyOwnerPolicy(wanted, up)
|
||||||
when {
|
when {
|
||||||
wanted && !up -> maybeBringUp(settings)
|
wanted && !up -> maybeBringUp(settings)
|
||||||
!wanted && up && tunnelJob?.isActive != true -> {
|
!wanted && up && tunnelJob?.isActive != true -> {
|
||||||
@@ -146,11 +163,11 @@ class DataMonitorService : LifecycleService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val fault = wanted && !up
|
val fault = wanted && !up
|
||||||
applyOwnerPolicy(wanted, up)
|
|
||||||
|
|
||||||
val dataState = when {
|
val dataState = when {
|
||||||
!settings.controlEnabled -> if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK
|
!settings.controlEnabled -> if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK
|
||||||
settings.serverMode != "open" -> DataState.CUT
|
// « blocklist » laisse tout passer (sauf la liste noire DNS) : traité comme « open », pas comme coupé.
|
||||||
|
settings.serverMode == "allowlist" || settings.serverMode == "cut" -> DataState.CUT
|
||||||
latestTransport == NetworkTransport.WIFI -> DataState.WIFI
|
latestTransport == NetworkTransport.WIFI -> DataState.WIFI
|
||||||
percent >= WARNING_THRESHOLD -> DataState.WARNING
|
percent >= WARNING_THRESHOLD -> DataState.WARNING
|
||||||
else -> DataState.OK
|
else -> DataState.OK
|
||||||
@@ -179,6 +196,36 @@ class DataMonitorService : LifecycleService() {
|
|||||||
updateNotification(stateHolder.snapshot.value)
|
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.
|
* 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
|
* Garde-fou : sans poignée de main avec le serveur depuis [SERVER_LOST_MS], le blocage hors VPN est
|
||||||
@@ -249,6 +296,7 @@ class DataMonitorService : LifecycleService() {
|
|||||||
val modeLabel = when (snapshot.serverMode) {
|
val modeLabel = when (snapshot.serverMode) {
|
||||||
"cut" -> getString(R.string.mode_cut)
|
"cut" -> getString(R.string.mode_cut)
|
||||||
"allowlist" -> getString(R.string.mode_allowlist)
|
"allowlist" -> getString(R.string.mode_allowlist)
|
||||||
|
"blocklist" -> getString(R.string.mode_blocklist)
|
||||||
else -> getString(R.string.mode_open)
|
else -> getString(R.string.mode_open)
|
||||||
}
|
}
|
||||||
val (emoji, stateLabel, color) = when {
|
val (emoji, stateLabel, color) = when {
|
||||||
|
|||||||
@@ -27,10 +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("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.")
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
@@ -108,7 +111,8 @@ object AboutContent {
|
|||||||
)),
|
)),
|
||||||
AboutSection("Ce que voit le serveur", listOf(
|
AboutSection("Ce que voit le serveur", listOf(
|
||||||
AboutEntry("Noms de domaine et volume", "Comme tout le trafic passe par le serveur du parent, celui-ci voit les sites demandés (journal DNS conservé 7 jours) et le volume échangé."),
|
AboutEntry("Noms de domaine et volume", "Comme tout le trafic passe par le serveur du parent, celui-ci voit les sites demandés (journal DNS conservé 7 jours) et le volume échangé."),
|
||||||
AboutEntry("Pas le contenu", "Les échanges chiffrés des applications (HTTPS) restent illisibles pour le serveur ; le service d'accessibilité ne lit pas le contenu des écrans.")
|
AboutEntry("Pas le contenu", "Les échanges chiffrés des applications (HTTPS) restent illisibles pour le serveur ; le service d'accessibilité ne lit pas le contenu des écrans."),
|
||||||
|
AboutEntry("Partage d'écran visible", "Le parent ne peut voir l'écran que pendant un partage qu'il déclenche ; le téléphone l'affiche alors en permanence (bandeau rouge) et le partage s'arrête seul au bout de 5 minutes. Aucune capture en dehors de ces moments.")
|
||||||
)),
|
)),
|
||||||
AboutSection("Privilèges et licence", listOf(
|
AboutSection("Privilèges et licence", listOf(
|
||||||
AboutEntry("Rien d'activé à votre insu", "VPN, position, administrateur d'appareil et accessibilité passent chacun par une autorisation explicite d'Android."),
|
AboutEntry("Rien d'activé à votre insu", "VPN, position, administrateur d'appareil et accessibilité passent chacun par une autorisation explicite d'Android."),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
// Appareil associé : seul un code du serveur (dicté par le parent) l'autorise.
|
||||||
|
settings.isPaired -> {
|
||||||
|
unlockCodeError = null
|
||||||
|
unlockBusy = false
|
||||||
|
showUnlockCodeDialog = true
|
||||||
|
}
|
||||||
|
passwordSet == true -> {
|
||||||
disablePasswordError = null
|
disablePasswordError = null
|
||||||
showDisablePasswordDialog = true
|
showDisablePasswordDialog = true
|
||||||
} else {
|
}
|
||||||
viewModel.setControlEnabled(false)
|
passwordSet != null -> viewModel.setControlEnabled(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,6 +211,7 @@ fun DashboardScreen(systemActions: SystemActions) {
|
|||||||
val modeText = when (snapshot.serverMode) {
|
val modeText = when (snapshot.serverMode) {
|
||||||
"cut" -> stringResource(R.string.mode_cut)
|
"cut" -> stringResource(R.string.mode_cut)
|
||||||
"allowlist" -> stringResource(R.string.mode_allowlist)
|
"allowlist" -> stringResource(R.string.mode_allowlist)
|
||||||
|
"blocklist" -> stringResource(R.string.mode_blocklist)
|
||||||
else -> stringResource(R.string.mode_open)
|
else -> stringResource(R.string.mode_open)
|
||||||
}
|
}
|
||||||
val tunnelText = when {
|
val tunnelText = when {
|
||||||
@@ -246,6 +258,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>
|
||||||
@@ -116,6 +127,7 @@
|
|||||||
<string name="family_guard_enable">Activer le service</string>
|
<string name="family_guard_enable">Activer le service</string>
|
||||||
<!-- v0.2 : tunnel WireGuard et serveur familyd -->
|
<!-- v0.2 : tunnel WireGuard et serveur familyd -->
|
||||||
<string name="mode_open">Accès libre</string>
|
<string name="mode_open">Accès libre</string>
|
||||||
|
<string name="mode_blocklist">Liste noire (certains services bloqués)</string>
|
||||||
<string name="mode_allowlist">Liste blanche seulement</string>
|
<string name="mode_allowlist">Liste blanche seulement</string>
|
||||||
<string name="mode_cut">Internet coupé</string>
|
<string name="mode_cut">Internet coupé</string>
|
||||||
<string name="dashboard_tunnel">Tunnel</string>
|
<string name="dashboard_tunnel">Tunnel</string>
|
||||||
@@ -135,4 +147,5 @@
|
|||||||
<string name="family_owner_release">Retirer le mode propriétaire</string>
|
<string name="family_owner_release">Retirer le mode propriétaire</string>
|
||||||
<string name="family_owner_release_error">Retrait impossible : voir le journal.</string>
|
<string name="family_owner_release_error">Retrait impossible : voir le journal.</string>
|
||||||
<string name="family_owner_off">Inactif. Pour l\'activer (une seule fois, téléphone branché au PC, tous les comptes retirés le temps de la commande) :</string>
|
<string name="family_owner_off">Inactif. Pour l\'activer (une seule fois, téléphone branché au PC, tous les comptes retirés le temps de la commande) :</string>
|
||||||
|
<string name="screen_shared_banner">🔴 Écran partagé avec le parent</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -4,4 +4,5 @@
|
|||||||
android:accessibilityFeedbackType="feedbackGeneric"
|
android:accessibilityFeedbackType="feedbackGeneric"
|
||||||
android:canRetrieveWindowContent="false"
|
android:canRetrieveWindowContent="false"
|
||||||
android:description="@string/guard_service_description"
|
android:description="@string/guard_service_description"
|
||||||
android:notificationTimeout="100" />
|
android:notificationTimeout="100"
|
||||||
|
android:canTakeScreenshot="true" />
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<device-admin>
|
<device-admin>
|
||||||
<uses-policies />
|
<uses-policies>
|
||||||
|
<force-lock />
|
||||||
|
</uses-policies>
|
||||||
</device-admin>
|
</device-admin>
|
||||||
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
versionName=0.2.0
|
versionName=0.9.0
|
||||||
versionCode=12
|
versionCode=19
|
||||||
Reference in new issue
Block a user