feat: partage d'écran Android 5+ (MediaProjection) et blocage des installations
- Partage d'écran par MediaProjection (ScreenCaptureService + ScreenConsentActivity) quand la capture par accessibilité n'est pas possible (Android < 11 ou service coupé). En mode propriétaire, le service d'accessibilité valide seul le consentement système. - Blocage de l'installation d'applications (Play Store compris) piloté par le parent (policy block_installs → OwnerPolicy DISALLOW_INSTALL_APPS en mode propriétaire). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ
This commit is contained in:
1 parent
665d6e2cb3
commit
d4aa0e1962
14 files changed
+328
-17
No files matched your search
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.10.0 – 2026-10-25
|
||||||
|
|
||||||
|
- ✨ **Ajouté** — **Partage d'écran compatible dès Android 5** : capture par MediaProjection quand la capture par le service d'accessibilité n'est pas possible (celle-ci reste réservée à Android 11+). En mode propriétaire, le consentement système « Démarrer la capture » peut être validé automatiquement par le service d'accessibilité ; sinon un appui est demandé sur le téléphone. Une notification « Écran partagé avec le parent » reste affichée pendant le partage.
|
||||||
|
- ✨ **Ajouté** — **Blocage de l'installation d'applications** (Play Store compris), piloté par le parent depuis l'espace parents. Appliqué en mode propriétaire (restriction système `DISALLOW_INSTALL_APPS`).
|
||||||
|
|
||||||
## v0.9.0 – 2026-10-25
|
## 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.
|
- 🛠️ **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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# DataControl
|
# DataControl
|
||||||
|
|
||||||
🚀 v0.9.0 · 🤖 Android · 🟣 Kotlin · 🎨 Jetpack Compose · 🔒 WireGuard · 🛡️ mode propriétaire · 👨👩👧 contrôle parental · 🧪 tests · 📄 licence non déclarée
|
🚀 v0.10.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.
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<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.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||||
<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.VIBRATE" />
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
@@ -46,6 +47,18 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
|
<!-- Partage d'écran par MediaProjection (fonctionne dès Android 5, contrairement à l'accessibilité). -->
|
||||||
|
<service
|
||||||
|
android:name=".remote.ScreenCaptureService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="mediaProjection" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".remote.ScreenConsentActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||||
|
|
||||||
<!-- FileProvider pour installer l'APK de mise à jour téléchargé -->
|
<!-- FileProvider pour installer l'APK de mise à jour téléchargé -->
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ object OwnerPolicy {
|
|||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) add(UserManager.DISALLOW_CONFIG_PRIVATE_DNS)
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) add(UserManager.DISALLOW_CONFIG_PRIVATE_DNS)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** État voulu, appliqué tel quel ; [lockdown] = connexions bloquées hors VPN. */
|
/** État voulu, appliqué tel quel ; [lockdown] = connexions bloquées hors VPN ;
|
||||||
data class Target(val protect: Boolean, val lockdown: Boolean)
|
* [blockInstalls] = installation d'applications interdite (Play Store compris). */
|
||||||
|
data class Target(val protect: Boolean, val lockdown: Boolean, val blockInstalls: Boolean = false)
|
||||||
|
|
||||||
private var applied: Target? = null
|
private var applied: Target? = null
|
||||||
|
|
||||||
@@ -49,11 +50,15 @@ object OwnerPolicy {
|
|||||||
// Le système accorde lui-même l'autorisation VPN au paquet désigné : aucune fenêtre de consentement.
|
// Le système accorde lui-même l'autorisation VPN au paquet désigné : aucune fenêtre de consentement.
|
||||||
dpm.setAlwaysOnVpnPackage(admin, context.packageName, target.lockdown)
|
dpm.setAlwaysOnVpnPackage(admin, context.packageName, target.lockdown)
|
||||||
RESTRICTIONS.forEach { dpm.addUserRestriction(admin, it) }
|
RESTRICTIONS.forEach { dpm.addUserRestriction(admin, it) }
|
||||||
|
// Blocage des installations : facultatif, piloté par le parent.
|
||||||
|
if (target.blockInstalls) dpm.addUserRestriction(admin, UserManager.DISALLOW_INSTALL_APPS)
|
||||||
|
else dpm.clearUserRestriction(admin, UserManager.DISALLOW_INSTALL_APPS)
|
||||||
dpm.setUninstallBlocked(admin, context.packageName, true)
|
dpm.setUninstallBlocked(admin, context.packageName, true)
|
||||||
requireAutoTime(context)
|
requireAutoTime(context)
|
||||||
} else {
|
} else {
|
||||||
dpm.setAlwaysOnVpnPackage(admin, null, false)
|
dpm.setAlwaysOnVpnPackage(admin, null, false)
|
||||||
RESTRICTIONS.forEach { dpm.clearUserRestriction(admin, it) }
|
RESTRICTIONS.forEach { dpm.clearUserRestriction(admin, it) }
|
||||||
|
dpm.clearUserRestriction(admin, UserManager.DISALLOW_INSTALL_APPS)
|
||||||
dpm.setUninstallBlocked(admin, context.packageName, false)
|
dpm.setUninstallBlocked(admin, context.packageName, false)
|
||||||
}
|
}
|
||||||
applied = target
|
applied = target
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ data class AppSettings(
|
|||||||
val serverMode: String = "open",
|
val serverMode: String = "open",
|
||||||
val serverModeReason: String = "",
|
val serverModeReason: String = "",
|
||||||
val policyVersion: Long = 0L,
|
val policyVersion: Long = 0L,
|
||||||
|
/** Bloque l'installation d'applications (Play Store compris) — appliqué en mode propriétaire. */
|
||||||
|
val blockInstalls: Boolean = false,
|
||||||
/** Téléphone verrouillé à distance par le parent : un code du serveur est exigé pour le déverrouiller. */
|
/** Téléphone verrouillé à distance par le parent : un code du serveur est exigé pour le déverrouiller. */
|
||||||
val parentalLocked: Boolean = false
|
val parentalLocked: Boolean = false
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ class SettingsRepository @Inject constructor(
|
|||||||
val LINK_TOKEN_ENC = stringPreferencesKey("link_token_enc")
|
val LINK_TOKEN_ENC = stringPreferencesKey("link_token_enc")
|
||||||
val WG_PRIVATE_ENC = stringPreferencesKey("wg_private_enc")
|
val WG_PRIVATE_ENC = stringPreferencesKey("wg_private_enc")
|
||||||
val SERVER_MODE = stringPreferencesKey("server_mode")
|
val SERVER_MODE = stringPreferencesKey("server_mode")
|
||||||
|
val BLOCK_INSTALLS = booleanPreferencesKey("block_installs")
|
||||||
val SERVER_MODE_REASON = stringPreferencesKey("server_mode_reason")
|
val SERVER_MODE_REASON = stringPreferencesKey("server_mode_reason")
|
||||||
val POLICY_VERSION = longPreferencesKey("policy_version")
|
val POLICY_VERSION = longPreferencesKey("policy_version")
|
||||||
val EVENT_QUEUE = stringPreferencesKey("event_queue")
|
val EVENT_QUEUE = stringPreferencesKey("event_queue")
|
||||||
@@ -103,7 +104,8 @@ class SettingsRepository @Inject constructor(
|
|||||||
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
|
parentalLocked = prefs[Keys.PARENTAL_LOCKED] ?: defaults.parentalLocked,
|
||||||
|
blockInstalls = prefs[Keys.BLOCK_INSTALLS] ?: defaults.blockInstalls
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,11 +326,12 @@ class SettingsRepository @Inject constructor(
|
|||||||
suspend fun wgPrivateKey(): String? = SecretBox.decrypt(context.dataStore.data.first()[Keys.WG_PRIVATE_ENC])
|
suspend fun wgPrivateKey(): String? = SecretBox.decrypt(context.dataStore.data.first()[Keys.WG_PRIVATE_ENC])
|
||||||
|
|
||||||
/** Consigne reçue du serveur (mode réseau en cours et règles d'applications). */
|
/** Consigne reçue du serveur (mode réseau en cours et règles d'applications). */
|
||||||
suspend fun applyServerPolicy(version: Long, mode: String, reason: String, rulesJson: String?) {
|
suspend fun applyServerPolicy(version: Long, mode: String, reason: String, rulesJson: String?, blockInstalls: Boolean) {
|
||||||
context.dataStore.edit {
|
context.dataStore.edit {
|
||||||
it[Keys.POLICY_VERSION] = version
|
it[Keys.POLICY_VERSION] = version
|
||||||
it[Keys.SERVER_MODE] = mode
|
it[Keys.SERVER_MODE] = mode
|
||||||
it[Keys.SERVER_MODE_REASON] = reason
|
it[Keys.SERVER_MODE_REASON] = reason
|
||||||
|
it[Keys.BLOCK_INSTALLS] = blockInstalls
|
||||||
if (rulesJson.isNullOrBlank() || rulesJson == "{}") it.remove(Keys.RULES_JSON) else it[Keys.RULES_JSON] = rulesJson
|
if (rulesJson.isNullOrBlank() || rulesJson == "{}") it.remove(Keys.RULES_JSON) else it[Keys.RULES_JSON] = rulesJson
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import android.os.PowerManager
|
|||||||
import android.provider.Settings
|
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.accessibility.AccessibilityNodeInfo
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import com.jttools.datacontrol.data.SettingsRepository
|
import com.jttools.datacontrol.data.SettingsRepository
|
||||||
import com.jttools.datacontrol.log.AppLog
|
import com.jttools.datacontrol.log.AppLog
|
||||||
@@ -186,6 +187,11 @@ class AppGuardService : AccessibilityService() {
|
|||||||
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
|
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
|
||||||
if (event?.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) return
|
if (event?.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) return
|
||||||
val pkg = event.packageName?.toString() ?: return
|
val pkg = event.packageName?.toString() ?: return
|
||||||
|
// Auto-validation du consentement au partage d'écran (dialogue système), en mode propriétaire.
|
||||||
|
if (remoteControl.awaitingScreenConsent && confirmProjectionConsent()) {
|
||||||
|
remoteControl.awaitingScreenConsent = false
|
||||||
|
return
|
||||||
|
}
|
||||||
// Verrouillage parental : toute tentative de passer à une autre application ramène l'écran de blocage.
|
// Verrouillage parental : toute tentative de passer à une autre application ramène l'écran de blocage.
|
||||||
if (parentalLocked && pkg != packageName) {
|
if (parentalLocked && pkg != packageName) {
|
||||||
enforceParentalLock()
|
enforceParentalLock()
|
||||||
@@ -216,6 +222,23 @@ class AppGuardService : AccessibilityService() {
|
|||||||
runCatching { startActivity(ParentalLockActivity.intent(this)) }
|
runCatching { startActivity(ParentalLockActivity.intent(this)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Clique « Démarrer maintenant » sur le dialogue système de partage d'écran (MediaProjection). */
|
||||||
|
private fun confirmProjectionConsent(): Boolean {
|
||||||
|
val root = rootInActiveWindow ?: return false
|
||||||
|
val btn = findClickableByText(root, CONSENT_WORDS)
|
||||||
|
return btn != null && btn.performAction(AccessibilityNodeInfo.ACTION_CLICK)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findClickableByText(node: AccessibilityNodeInfo?, re: Regex): AccessibilityNodeInfo? {
|
||||||
|
if (node == null) return null
|
||||||
|
val text = (node.text?.toString() ?: "") + " " + (node.contentDescription?.toString() ?: "")
|
||||||
|
if (node.isClickable && re.containsMatchIn(text)) return node
|
||||||
|
for (i in 0 until node.childCount) {
|
||||||
|
findClickableByText(node.getChild(i), re)?.let { return it }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -286,6 +309,7 @@ class AppGuardService : AccessibilityService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private val CONSENT_WORDS = Regex("démarrer|commencer|autoriser|accepter|start now|\\ballow\\b", RegexOption.IGNORE_CASE)
|
||||||
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 SCREEN_INTERVAL_MS = 1_500L
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package com.jttools.datacontrol.remote
|
|||||||
import android.app.admin.DevicePolicyManager
|
import android.app.admin.DevicePolicyManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
|
import android.os.Build
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import com.jttools.datacontrol.admin.AdminController
|
import com.jttools.datacontrol.admin.AdminController
|
||||||
|
import com.jttools.datacontrol.guard.AppGuardService
|
||||||
import com.jttools.datacontrol.data.SettingsRepository
|
import com.jttools.datacontrol.data.SettingsRepository
|
||||||
import com.jttools.datacontrol.guard.ParentalLockActivity
|
import com.jttools.datacontrol.guard.ParentalLockActivity
|
||||||
import com.jttools.datacontrol.log.AppLog
|
import com.jttools.datacontrol.log.AppLog
|
||||||
@@ -43,6 +45,9 @@ class RemoteControl @Inject constructor(
|
|||||||
|
|
||||||
val screenSharing: Boolean get() = _screenShareUntil.value > SystemClock.elapsedRealtime()
|
val screenSharing: Boolean get() = _screenShareUntil.value > SystemClock.elapsedRealtime()
|
||||||
|
|
||||||
|
/** Vrai pendant qu'on attend le consentement MediaProjection (le dialogue système est à l'écran). */
|
||||||
|
@Volatile var awaitingScreenConsent = false
|
||||||
|
|
||||||
private var started = false
|
private var started = false
|
||||||
|
|
||||||
fun start(scope: CoroutineScope) {
|
fun start(scope: CoroutineScope) {
|
||||||
@@ -88,14 +93,11 @@ class RemoteControl @Inject constructor(
|
|||||||
"punlock" -> parentalLock(false)
|
"punlock" -> parentalLock(false)
|
||||||
"mute" -> setMuted(true)
|
"mute" -> setMuted(true)
|
||||||
"unmute" -> setMuted(false)
|
"unmute" -> setMuted(false)
|
||||||
"screen_start" -> {
|
"screen_start" -> startScreenShare(cmd.optInt("seconds", 120).coerceIn(10, 300))
|
||||||
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" -> {
|
"screen_stop" -> {
|
||||||
_screenShareUntil.value = 0L
|
_screenShareUntil.value = 0L
|
||||||
|
awaitingScreenConsent = false
|
||||||
|
ScreenCaptureService.stop(context)
|
||||||
AppLog.i("Commande", "Partage d'écran arrêté")
|
AppLog.i("Commande", "Partage d'écran arrêté")
|
||||||
"done" to ""
|
"done" to ""
|
||||||
}
|
}
|
||||||
@@ -123,6 +125,22 @@ class RemoteControl @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Démarre le partage d'écran : capture silencieuse par l'accessibilité (Android 11+) ou MediaProjection. */
|
||||||
|
private fun startScreenShare(seconds: Int): Pair<String, String> {
|
||||||
|
_screenShareUntil.value = SystemClock.elapsedRealtime() + seconds * 1000L
|
||||||
|
val silent = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && AppGuardService.isEnabled(context)
|
||||||
|
if (silent) {
|
||||||
|
awaitingScreenConsent = false
|
||||||
|
AppLog.i("Commande", "Partage d'écran démarré ($seconds s, accessibilité)")
|
||||||
|
} else if (!ScreenCaptureService.running) {
|
||||||
|
awaitingScreenConsent = true
|
||||||
|
runCatching { context.startActivity(ScreenConsentActivity.intent(context)) }
|
||||||
|
.onFailure { AppLog.w("Commande", "Lancement du partage MediaProjection impossible : ${it.message}") }
|
||||||
|
AppLog.i("Commande", "Partage d'écran démarré ($seconds s, MediaProjection)")
|
||||||
|
}
|
||||||
|
return "done" to "partage démarré"
|
||||||
|
}
|
||||||
|
|
||||||
private fun lock(): Pair<String, String> {
|
private fun lock(): Pair<String, String> {
|
||||||
val dpm = context.getSystemService(DevicePolicyManager::class.java)
|
val dpm = context.getSystemService(DevicePolicyManager::class.java)
|
||||||
if (!AdminController.isActive(context)) {
|
if (!AdminController.isActive(context)) {
|
||||||
|
|||||||
@@ -136,10 +136,13 @@ class RemoteSync @Inject constructor(
|
|||||||
val version = p.optLong("version")
|
val version = p.optLong("version")
|
||||||
val mode = p.optString("mode", "open")
|
val mode = p.optString("mode", "open")
|
||||||
val reason = p.optString("mode_reason")
|
val reason = p.optString("mode_reason")
|
||||||
if (version != current.policyVersion || mode != current.serverMode || reason != current.serverModeReason) {
|
val blockInstalls = p.optBoolean("block_installs", false)
|
||||||
|
if (version != current.policyVersion || mode != current.serverMode || reason != current.serverModeReason
|
||||||
|
|| blockInstalls != current.blockInstalls) {
|
||||||
val rules = p.optJSONObject("app_rules")?.takeIf { it.length() > 0 }?.toString()
|
val rules = p.optJSONObject("app_rules")?.takeIf { it.length() > 0 }?.toString()
|
||||||
settings.applyServerPolicy(version, mode, reason, rules)
|
settings.applyServerPolicy(version, mode, reason, rules, blockInstalls)
|
||||||
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 ""}")
|
||||||
|
if (blockInstalls != current.blockInstalls) AppLog.i("Liaison", "Blocage des installations : $blockInstalls")
|
||||||
}
|
}
|
||||||
// Mise à jour de DataControl poussée par le parent.
|
// Mise à jour de DataControl poussée par le parent.
|
||||||
p.optJSONObject("deploy")?.let { order ->
|
p.optJSONObject("deploy")?.let { order ->
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package com.jttools.datacontrol.remote
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.PixelFormat
|
||||||
|
import android.hardware.display.DisplayManager
|
||||||
|
import android.hardware.display.VirtualDisplay
|
||||||
|
import android.media.ImageReader
|
||||||
|
import android.media.projection.MediaProjection
|
||||||
|
import android.media.projection.MediaProjectionManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.HandlerThread
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.util.DisplayMetrics
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.jttools.datacontrol.R
|
||||||
|
import com.jttools.datacontrol.log.AppLog
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partage d'écran par MediaProjection — fonctionne dès Android 5, contrairement à la capture par le
|
||||||
|
* service d'accessibilité ([com.jttools.datacontrol.guard.AppGuardService]) réservée à Android 11+.
|
||||||
|
*
|
||||||
|
* Démarré par [ScreenConsentActivity] après le consentement du système (ou son auto-validation par
|
||||||
|
* l'accessibilité en mode propriétaire). Capture l'écran tant que le parent partage
|
||||||
|
* ([RemoteControl.screenSharing]), redimensionne et envoie les images au serveur par le tunnel.
|
||||||
|
*/
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class ScreenCaptureService : Service() {
|
||||||
|
|
||||||
|
@Inject lateinit var remoteSync: RemoteSync
|
||||||
|
@Inject lateinit var remoteControl: RemoteControl
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private var projection: MediaProjection? = null
|
||||||
|
private var virtualDisplay: VirtualDisplay? = null
|
||||||
|
private var imageReader: ImageReader? = null
|
||||||
|
private var thread: HandlerThread? = null
|
||||||
|
private var lastSent = 0L
|
||||||
|
@Volatile private var busy = false
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (intent?.action == ACTION_STOP) { stop(); return START_NOT_STICKY }
|
||||||
|
startForegroundNotif()
|
||||||
|
val code = intent?.getIntExtra(EXTRA_CODE, 0) ?: 0
|
||||||
|
val data: Intent? = intent?.let {
|
||||||
|
if (Build.VERSION.SDK_INT >= 33) it.getParcelableExtra(EXTRA_DATA, Intent::class.java)
|
||||||
|
else @Suppress("DEPRECATION") it.getParcelableExtra(EXTRA_DATA)
|
||||||
|
}
|
||||||
|
if (data == null || code == 0) { stop(); return START_NOT_STICKY }
|
||||||
|
running = true
|
||||||
|
val mpm = getSystemService(MediaProjectionManager::class.java)
|
||||||
|
projection = try {
|
||||||
|
mpm.getMediaProjection(code, data)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
AppLog.e("Écran", "MediaProjection impossible", e); stop(); return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
projection?.registerCallback(object : MediaProjection.Callback() {
|
||||||
|
override fun onStop() { stop() }
|
||||||
|
}, Handler(mainLooper))
|
||||||
|
startCapture()
|
||||||
|
scope.launch { // s'arrête dès que le parent cesse de partager
|
||||||
|
while (running) {
|
||||||
|
if (!remoteControl.screenSharing) { stop(); break }
|
||||||
|
delay(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startCapture() {
|
||||||
|
val metrics = DisplayMetrics()
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
getSystemService(WindowManager::class.java).defaultDisplay.getRealMetrics(metrics)
|
||||||
|
val scale = if (metrics.widthPixels > MAX_WIDTH) MAX_WIDTH.toFloat() / metrics.widthPixels else 1f
|
||||||
|
val w = (metrics.widthPixels * scale).toInt().coerceAtLeast(1)
|
||||||
|
val h = (metrics.heightPixels * scale).toInt().coerceAtLeast(1)
|
||||||
|
thread = HandlerThread("screencap").apply { start() }
|
||||||
|
val handler = Handler(thread!!.looper)
|
||||||
|
val reader = ImageReader.newInstance(w, h, PixelFormat.RGBA_8888, 2)
|
||||||
|
imageReader = reader
|
||||||
|
virtualDisplay = projection?.createVirtualDisplay(
|
||||||
|
"datacontrol-screen", w, h, metrics.densityDpi,
|
||||||
|
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, reader.surface, null, handler
|
||||||
|
)
|
||||||
|
reader.setOnImageAvailableListener({ r ->
|
||||||
|
val image = r.acquireLatestImage() ?: return@setOnImageAvailableListener
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
if (busy || now - lastSent < CAPTURE_INTERVAL_MS) { image.close(); return@setOnImageAvailableListener }
|
||||||
|
lastSent = now
|
||||||
|
busy = true
|
||||||
|
try {
|
||||||
|
val plane = image.planes[0]
|
||||||
|
val pixelStride = plane.pixelStride
|
||||||
|
val rowPadding = plane.rowStride - pixelStride * w
|
||||||
|
val bmp = Bitmap.createBitmap(w + rowPadding / pixelStride, h, Bitmap.Config.ARGB_8888)
|
||||||
|
bmp.copyPixelsFromBuffer(plane.buffer)
|
||||||
|
image.close()
|
||||||
|
val frame = if (rowPadding == 0) bmp else Bitmap.createBitmap(bmp, 0, 0, w, h).also { bmp.recycle() }
|
||||||
|
scope.launch {
|
||||||
|
val jpeg = ByteArrayOutputStream().use { out ->
|
||||||
|
frame.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out); out.toByteArray()
|
||||||
|
}
|
||||||
|
frame.recycle()
|
||||||
|
runCatching { remoteSync.pushScreen(jpeg) }
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
runCatching { image.close() }
|
||||||
|
busy = false
|
||||||
|
AppLog.w("Écran", "Capture impossible : ${e.message}")
|
||||||
|
}
|
||||||
|
}, handler)
|
||||||
|
AppLog.i("Écran", "Partage d'écran (MediaProjection) démarré — $w×$h")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stop() {
|
||||||
|
running = false
|
||||||
|
runCatching { virtualDisplay?.release() }
|
||||||
|
runCatching { imageReader?.close() }
|
||||||
|
runCatching { projection?.stop() }
|
||||||
|
virtualDisplay = null; imageReader = null; projection = null
|
||||||
|
thread?.quitSafely(); thread = null
|
||||||
|
scope.cancel()
|
||||||
|
runCatching { stopForeground(STOP_FOREGROUND_REMOVE) }
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() { running = false; super.onDestroy() }
|
||||||
|
|
||||||
|
private fun startForegroundNotif() {
|
||||||
|
val nm = getSystemService(NotificationManager::class.java)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
nm.createNotificationChannel(
|
||||||
|
NotificationChannel(CHANNEL, "Partage d'écran", NotificationManager.IMPORTANCE_LOW)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val notif = NotificationCompat.Builder(this, CHANNEL)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle("Écran partagé avec le parent")
|
||||||
|
.setContentText("DataControl partage l'écran de ce téléphone.")
|
||||||
|
.setOngoing(true)
|
||||||
|
.build()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIF_ID, notif)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile var running = false
|
||||||
|
private set
|
||||||
|
private const val CHANNEL = "screen_share"
|
||||||
|
private const val NOTIF_ID = 4242
|
||||||
|
private const val EXTRA_CODE = "code"
|
||||||
|
private const val EXTRA_DATA = "data"
|
||||||
|
private const val ACTION_STOP = "stop"
|
||||||
|
private const val MAX_WIDTH = 720
|
||||||
|
private const val JPEG_QUALITY = 50
|
||||||
|
private const val CAPTURE_INTERVAL_MS = 1200L
|
||||||
|
|
||||||
|
fun start(context: Context, resultCode: Int, data: Intent) {
|
||||||
|
val i = Intent(context, ScreenCaptureService::class.java)
|
||||||
|
.putExtra(EXTRA_CODE, resultCode).putExtra(EXTRA_DATA, data)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(i)
|
||||||
|
else context.startService(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
if (!running) return
|
||||||
|
context.startService(Intent(context, ScreenCaptureService::class.java).setAction(ACTION_STOP))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.jttools.datacontrol.remote
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.media.projection.MediaProjectionManager
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import com.jttools.datacontrol.log.AppLog
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demande le consentement du système au partage d'écran (MediaProjection) puis lance
|
||||||
|
* [ScreenCaptureService]. Transparente et sans interface : en mode propriétaire, le service
|
||||||
|
* d'accessibilité peut valider seul le dialogue « Démarrer la capture » (sinon un appui est requis).
|
||||||
|
*/
|
||||||
|
class ScreenConsentActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private val launcher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { res ->
|
||||||
|
val data = res.data
|
||||||
|
if (res.resultCode == RESULT_OK && data != null) {
|
||||||
|
ScreenCaptureService.start(this, res.resultCode, data)
|
||||||
|
} else {
|
||||||
|
AppLog.w("Écran", "Partage d'écran refusé sur le téléphone")
|
||||||
|
}
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val mpm = getSystemService(MediaProjectionManager::class.java)
|
||||||
|
try {
|
||||||
|
launcher.launch(mpm.createScreenCaptureIntent())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
AppLog.e("Écran", "Demande de partage d'écran impossible", e)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun intent(context: Context): Intent =
|
||||||
|
Intent(context, ScreenConsentActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -244,7 +244,8 @@ class DataMonitorService : LifecycleService() {
|
|||||||
else remoteSync.pushEvent("lockdown_restored", "Serveur de nouveau joignable : blocage hors VPN rétabli")
|
else remoteSync.pushEvent("lockdown_restored", "Serveur de nouveau joignable : blocage hors VPN rétabli")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OwnerPolicy.sync(this, OwnerPolicy.Target(protect = wanted, lockdown = wanted && !serverLost))
|
OwnerPolicy.sync(this, OwnerPolicy.Target(protect = wanted, lockdown = wanted && !serverLost,
|
||||||
|
blockInstalls = latestSettings.blockInstalls))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Monte le tunnel, au plus une tentative toutes les [TUNNEL_RETRY_MS] (évite de boucler contre un autre VPN). */
|
/** Monte le tunnel, au plus une tentative toutes les [TUNNEL_RETRY_MS] (évite de boucler contre un autre VPN). */
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ object AboutContent {
|
|||||||
AboutEntry("Journal de diagnostic", "État du tunnel, mode appliqué et, si le service d'accessibilité est activé, chaque application utilisée. Conservé 15 jours, avec copie et partage."),
|
AboutEntry("Journal de diagnostic", "État du tunnel, mode appliqué et, si le service d'accessibilité est activé, chaque application utilisée. Conservé 15 jours, avec copie et partage."),
|
||||||
AboutEntry("Mise à jour intégrée", "Recherche et installation de la dernière version publiée, depuis l'onglet Journal de cette page."),
|
AboutEntry("Mise à jour intégrée", "Recherche et installation de la dernière version publiée, depuis l'onglet Journal de cette page."),
|
||||||
AboutEntry("Mise à jour poussée par le parent", "Depuis l'espace parents : la nouvelle version arrive par le tunnel, son empreinte est vérifiée, puis elle s'installe toute seule en mode propriétaire (sinon, touchez la notification « Mise à jour de DataControl »)."),
|
AboutEntry("Mise à jour poussée par le parent", "Depuis l'espace parents : la nouvelle version arrive par le tunnel, son empreinte est vérifiée, puis elle s'installe toute seule en mode propriétaire (sinon, touchez la notification « Mise à jour de DataControl »)."),
|
||||||
AboutEntry("Actions à distance du parent", "Depuis l'espace parents : verrouiller / mettre en veille, couper ou rétablir le son, 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("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. Le partage d'écran fonctionne dès Android 5 (MediaProjection) ou, sur Android 11+, par le service d'accessibilité ; une notification « Écran partagé avec le parent » reste affichée et le partage s'arrête tout seul au bout de 5 minutes."),
|
||||||
|
AboutEntry("Blocage des installations", "Le parent peut interdire l'installation de nouvelles applications (Play Store compris) ; appliqué en mode propriétaire de l'appareil."),
|
||||||
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.")
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
|
|||||||
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
versionName=0.9.0
|
versionName=0.10.0
|
||||||
versionCode=19
|
versionCode=20
|
||||||
Reference in new issue
Block a user