From d4aa0e1962c346c8b09de18cc5de13e420850338 Mon Sep 17 00:00:00 2001 From: tuxgyver Date: Sun, 25 Oct 2026 22:28:16 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20partage=20d'=C3=A9cran=20Android=205+?= =?UTF-8?q?=20(MediaProjection)=20et=20blocage=20des=20installations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ --- CHANGELOG.md | 5 + README.md | 2 +- app/src/main/AndroidManifest.xml | 13 ++ .../jttools/datacontrol/admin/OwnerPolicy.kt | 9 +- .../jttools/datacontrol/data/AppSettings.kt | 2 + .../datacontrol/data/SettingsRepository.kt | 7 +- .../datacontrol/guard/AppGuardService.kt | 24 +++ .../datacontrol/remote/RemoteControl.kt | 30 ++- .../jttools/datacontrol/remote/RemoteSync.kt | 7 +- .../remote/ScreenCaptureService.kt | 193 ++++++++++++++++++ .../remote/ScreenConsentActivity.kt | 43 ++++ .../datacontrol/service/DataMonitorService.kt | 3 +- .../datacontrol/ui/about/AboutContent.kt | 3 +- version.properties | 4 +- 14 files changed, 328 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/jttools/datacontrol/remote/ScreenCaptureService.kt create mode 100644 app/src/main/java/com/jttools/datacontrol/remote/ScreenConsentActivity.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index f126e5a..dee1b15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 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 - 🛠️ **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. diff --git a/README.md b/README.md index d1ff3fc..448f6fd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 408aadf..1b18b93 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + @@ -46,6 +47,18 @@ android:exported="false" android:foregroundServiceType="dataSync" /> + + + + + = 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 diff --git a/app/src/main/java/com/jttools/datacontrol/data/AppSettings.kt b/app/src/main/java/com/jttools/datacontrol/data/AppSettings.kt index 3489be2..82a669d 100644 --- a/app/src/main/java/com/jttools/datacontrol/data/AppSettings.kt +++ b/app/src/main/java/com/jttools/datacontrol/data/AppSettings.kt @@ -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 ) { diff --git a/app/src/main/java/com/jttools/datacontrol/data/SettingsRepository.kt b/app/src/main/java/com/jttools/datacontrol/data/SettingsRepository.kt index 2302dca..f247a42 100644 --- a/app/src/main/java/com/jttools/datacontrol/data/SettingsRepository.kt +++ b/app/src/main/java/com/jttools/datacontrol/data/SettingsRepository.kt @@ -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 } } diff --git a/app/src/main/java/com/jttools/datacontrol/guard/AppGuardService.kt b/app/src/main/java/com/jttools/datacontrol/guard/AppGuardService.kt index 9df44b7..6fadf19 100644 --- a/app/src/main/java/com/jttools/datacontrol/guard/AppGuardService.kt +++ b/app/src/main/java/com/jttools/datacontrol/guard/AppGuardService.kt @@ -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 diff --git a/app/src/main/java/com/jttools/datacontrol/remote/RemoteControl.kt b/app/src/main/java/com/jttools/datacontrol/remote/RemoteControl.kt index d4fc943..856cad6 100644 --- a/app/src/main/java/com/jttools/datacontrol/remote/RemoteControl.kt +++ b/app/src/main/java/com/jttools/datacontrol/remote/RemoteControl.kt @@ -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 { + _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 { val dpm = context.getSystemService(DevicePolicyManager::class.java) if (!AdminController.isActive(context)) { diff --git a/app/src/main/java/com/jttools/datacontrol/remote/RemoteSync.kt b/app/src/main/java/com/jttools/datacontrol/remote/RemoteSync.kt index 23de6ff..d90871b 100644 --- a/app/src/main/java/com/jttools/datacontrol/remote/RemoteSync.kt +++ b/app/src/main/java/com/jttools/datacontrol/remote/RemoteSync.kt @@ -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 -> diff --git a/app/src/main/java/com/jttools/datacontrol/remote/ScreenCaptureService.kt b/app/src/main/java/com/jttools/datacontrol/remote/ScreenCaptureService.kt new file mode 100644 index 0000000..fa02c67 --- /dev/null +++ b/app/src/main/java/com/jttools/datacontrol/remote/ScreenCaptureService.kt @@ -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)) + } + } +} diff --git a/app/src/main/java/com/jttools/datacontrol/remote/ScreenConsentActivity.kt b/app/src/main/java/com/jttools/datacontrol/remote/ScreenConsentActivity.kt new file mode 100644 index 0000000..5047320 --- /dev/null +++ b/app/src/main/java/com/jttools/datacontrol/remote/ScreenConsentActivity.kt @@ -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) + } +} diff --git a/app/src/main/java/com/jttools/datacontrol/service/DataMonitorService.kt b/app/src/main/java/com/jttools/datacontrol/service/DataMonitorService.kt index a246f82..09a33e3 100644 --- a/app/src/main/java/com/jttools/datacontrol/service/DataMonitorService.kt +++ b/app/src/main/java/com/jttools/datacontrol/service/DataMonitorService.kt @@ -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). */ diff --git a/app/src/main/java/com/jttools/datacontrol/ui/about/AboutContent.kt b/app/src/main/java/com/jttools/datacontrol/ui/about/AboutContent.kt index 82dd0a0..aa9f231 100644 --- a/app/src/main/java/com/jttools/datacontrol/ui/about/AboutContent.kt +++ b/app/src/main/java/com/jttools/datacontrol/ui/about/AboutContent.kt @@ -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.") )) ) diff --git a/version.properties b/version.properties index 3f38107..2d0e56e 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -versionName=0.9.0 -versionCode=19 +versionName=0.10.0 +versionCode=20