feat: actions à distance du parent (voir l'écran, verrouiller/veille, couper le son)

- Partage d'écran par le service d'accessibilité (Android 11+), image réduite envoyée dans le tunnel
- Bandeau « Écran partagé avec le parent » visible pendant tout le partage, arrêt auto après 5 min
- Verrouillage/mise en veille (lockNow, force-lock) et coupure/rétablissement du son
- Boucle de commandes en attente longue via /api/device/commands

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 15:07:48 +01:00
1 parent e6215ab4a7
commit 7af250cc1c
12 files changed
+288 -10

No files matched your search

@@ -1,6 +1,13 @@
package com.jttools.datacontrol.guard
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.ComponentName
import android.content.Context
@@ -13,6 +20,8 @@ import android.telecom.TelecomManager
import android.view.accessibility.AccessibilityEvent
import android.view.inputmethod.InputMethodManager
import com.jttools.datacontrol.log.AppLog
import com.jttools.datacontrol.remote.RemoteControl
import com.jttools.datacontrol.remote.RemoteSync
import com.jttools.datacontrol.service.MonitorStateHolder
import com.jttools.datacontrol.service.NetworkTransport
import dagger.hilt.android.AndroidEntryPoint
@@ -22,8 +31,11 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.launch
import java.io.ByteArrayOutputStream
import javax.inject.Inject
import kotlin.coroutines.resume
/**
* Détecte l'application au premier plan (service d'accessibilité, sans lire le contenu des écrans)
@@ -35,6 +47,10 @@ class AppGuardService : AccessibilityService() {
@Inject lateinit var engine: GuardEngine
@Inject lateinit var stateHolder: MonitorStateHolder
@Inject lateinit var remoteControl: RemoteControl
@Inject lateinit var remoteSync: RemoteSync
private var shareBanner: TextView? = null
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var exempt: Set<String> = emptySet()
@@ -65,6 +81,94 @@ class AppGuardService : AccessibilityService() {
currentPkg?.let { enforce(it) }
}
}
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?) {
@@ -152,6 +256,7 @@ class AppGuardService : AccessibilityService() {
override fun onInterrupt() = Unit
override fun onDestroy() {
hideBanner()
runCatching { unregisterReceiver(screenReceiver) }
scope.cancel()
super.onDestroy()
@@ -160,6 +265,9 @@ class AppGuardService : AccessibilityService() {
companion object {
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
private const val MAX_WIDTH = 720
private const val JPEG_QUALITY = 50
fun isEnabled(context: Context): Boolean {
val enabled = Settings.Secure.getString(
@@ -38,6 +38,16 @@ class FamilyApi(private val apiBase: String, private val token: String) {
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")
}
companion object {
private const val CONNECT_TIMEOUT_MS = 10_000
private const val READ_TIMEOUT_MS = 15_000
@@ -50,24 +60,31 @@ class FamilyApi(private val apiBase: String, private val token: String) {
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 {
(URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = method
connectTimeout = CONNECT_TIMEOUT_MS
readTimeout = READ_TIMEOUT_MS
readTimeout = readTimeoutMs
setRequestProperty("Accept", "application/json")
token?.let { setRequestProperty("Authorization", "Bearer $it") }
if (body != null) {
doOutput = true
setRequestProperty("Content-Type", "application/json; charset=utf-8")
setRequestProperty("Content-Type", contentType)
setFixedLengthStreamingMode(body.size)
}
}
} catch (e: IOException) {
throw ApiException(0, "Serveur injoignable : ${e.message}")
}
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 text = (if (code in 200..299) conn.inputStream else conn.errorStream)
?.bufferedReader()?.use { it.readText() }.orEmpty()
@@ -0,0 +1,125 @@
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.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
) {
/** 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()) {
runCatching { handle(commands.getJSONObject(i)) }
.onFailure { AppLog.w("Commande", "Commande ignorée : ${it.message}") }
}
}
}
// 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)
}
}
}
}
private suspend fun handle(cmd: JSONObject) {
when (cmd.optString("type")) {
"lock" -> lock()
"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)")
}
"screen_stop" -> {
_screenShareUntil.value = 0L
AppLog.i("Commande", "Partage d'écran arrêté")
}
}
}
private fun lock() {
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
}
runCatching { dpm.lockNow() }
.onSuccess { AppLog.i("Commande", "Téléphone verrouillé / mis en veille") }
.onFailure { AppLog.w("Commande", "Verrouillage refusé : ${it.message}") }
}
private fun setMuted(muted: Boolean) {
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")
}
private companion object {
const val WAIT_SECONDS = 25
}
}
@@ -46,6 +46,20 @@ class RemoteSync @Inject constructor(
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 }
}
/** 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) {
runCatching {
@@ -52,6 +52,7 @@ class DataMonitorService : LifecycleService() {
@Inject lateinit var remoteSync: RemoteSync
@Inject lateinit var guardEngine: GuardEngine
@Inject lateinit var tunnel: TunnelManager
@Inject lateinit var remoteControl: com.jttools.datacontrol.remote.RemoteControl
private var latestSettings: AppSettings = AppSettings()
private var latestTransport: NetworkTransport = NetworkTransport.NONE
@@ -106,6 +107,7 @@ class DataMonitorService : LifecycleService() {
}
guardEngine.start()
deviceReporter.start(lifecycleScope)
remoteControl.start(lifecycleScope)
lifecycleScope.launch {
connectivityObserver.observe().collect {
latestTransport = it
@@ -32,6 +32,7 @@ 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, et voir l'écran en direct. Pendant un partage d'écran, un bandeau rouge « Écran partagé avec le parent » reste affiché sur le téléphone et le partage s'arrête tout seul au bout de 5 minutes."),
AboutEntry("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.")
))
)
@@ -109,7 +110,8 @@ object AboutContent {
)),
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("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(
AboutEntry("Rien d'activé à votre insu", "VPN, position, administrateur d'appareil et accessibilité passent chacun par une autorisation explicite d'Android."),