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:
tuxgyverandClaude Opus 4.8 committed 2026-10-25 22:28:16 +01:00
1 parent 665d6e2cb3
commit d4aa0e1962
14 files changed
+328 -17

No files matched your search

+13
View File
@@ -6,6 +6,7 @@
<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_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.VIBRATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@@ -46,6 +47,18 @@
android:exported="false"
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é -->
<provider
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)
}
/** État voulu, appliqué tel quel ; [lockdown] = connexions bloquées hors VPN. */
data class Target(val protect: Boolean, val lockdown: Boolean)
/** État voulu, appliqué tel quel ; [lockdown] = connexions bloquées hors VPN ;
* [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
@@ -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.
dpm.setAlwaysOnVpnPackage(admin, context.packageName, target.lockdown)
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)
requireAutoTime(context)
} else {
dpm.setAlwaysOnVpnPackage(admin, null, false)
RESTRICTIONS.forEach { dpm.clearUserRestriction(admin, it) }
dpm.clearUserRestriction(admin, UserManager.DISALLOW_INSTALL_APPS)
dpm.setUninstallBlocked(admin, context.packageName, false)
}
applied = target
@@ -25,6 +25,8 @@ data class AppSettings(
val serverMode: String = "open",
val serverModeReason: String = "",
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. */
val parentalLocked: Boolean = false
) {
@@ -58,6 +58,7 @@ class SettingsRepository @Inject constructor(
val LINK_TOKEN_ENC = stringPreferencesKey("link_token_enc")
val WG_PRIVATE_ENC = stringPreferencesKey("wg_private_enc")
val SERVER_MODE = stringPreferencesKey("server_mode")
val BLOCK_INSTALLS = booleanPreferencesKey("block_installs")
val SERVER_MODE_REASON = stringPreferencesKey("server_mode_reason")
val POLICY_VERSION = longPreferencesKey("policy_version")
val EVENT_QUEUE = stringPreferencesKey("event_queue")
@@ -103,7 +104,8 @@ class SettingsRepository @Inject constructor(
serverMode = prefs[Keys.SERVER_MODE] ?: defaults.serverMode,
serverModeReason = prefs[Keys.SERVER_MODE_REASON] ?: defaults.serverModeReason,
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])
/** 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 {
it[Keys.POLICY_VERSION] = version
it[Keys.SERVER_MODE] = mode
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
}
}
@@ -18,6 +18,7 @@ import android.os.PowerManager
import android.provider.Settings
import android.telecom.TelecomManager
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import android.view.inputmethod.InputMethodManager
import com.jttools.datacontrol.data.SettingsRepository
import com.jttools.datacontrol.log.AppLog
@@ -186,6 +187,11 @@ class AppGuardService : AccessibilityService() {
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (event?.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) 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.
if (parentalLocked && pkg != packageName) {
enforceParentalLock()
@@ -216,6 +222,23 @@ class AppGuardService : AccessibilityService() {
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) {
val now = System.currentTimeMillis()
if (now - lastBlockAt < 2_000) return
@@ -286,6 +309,7 @@ class AppGuardService : AccessibilityService() {
}
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 EXEMPT_TTL_MS = 5 * 60_000L
private const val SCREEN_INTERVAL_MS = 1_500L
@@ -3,8 +3,10 @@ package com.jttools.datacontrol.remote
import android.app.admin.DevicePolicyManager
import android.content.Context
import android.media.AudioManager
import android.os.Build
import android.os.SystemClock
import com.jttools.datacontrol.admin.AdminController
import com.jttools.datacontrol.guard.AppGuardService
import com.jttools.datacontrol.data.SettingsRepository
import com.jttools.datacontrol.guard.ParentalLockActivity
import com.jttools.datacontrol.log.AppLog
@@ -43,6 +45,9 @@ class RemoteControl @Inject constructor(
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
fun start(scope: CoroutineScope) {
@@ -88,14 +93,11 @@ class RemoteControl @Inject constructor(
"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_start" -> startScreenShare(cmd.optInt("seconds", 120).coerceIn(10, 300))
"screen_stop" -> {
_screenShareUntil.value = 0L
awaitingScreenConsent = false
ScreenCaptureService.stop(context)
AppLog.i("Commande", "Partage d'écran arrêté")
"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> {
val dpm = context.getSystemService(DevicePolicyManager::class.java)
if (!AdminController.isActive(context)) {
@@ -136,10 +136,13 @@ class RemoteSync @Inject constructor(
val version = p.optLong("version")
val mode = p.optString("mode", "open")
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()
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 (blockInstalls != current.blockInstalls) AppLog.i("Liaison", "Blocage des installations : $blockInstalls")
}
// Mise à jour de DataControl poussée par le parent.
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")
}
}
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). */
@@ -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("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("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.")
))
)