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

@@ -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
}
}