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 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, private val ringer: Ringer ) { /** Instant (temps monotone) jusqu'auquel l'écran est partagé ; 0 = pas de partage. */ private val _screenShareUntil = MutableStateFlow(0L) val screenShareUntil: StateFlow = _screenShareUntil.asStateFlow() 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) { 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()) { val cmd = commands.getJSONObject(i) val outcome = runCatching { handle(cmd) } val (status, detail) = outcome.getOrNull() ?: ("failed" to (outcome.exceptionOrNull()?.message ?: "erreur")) remote.ackCommand(cmd.optString("id"), status, detail) } } } // 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) } } } } /** Renvoie (statut, détail) pour l'audit : "done" ou "failed". */ private suspend fun handle(cmd: JSONObject): Pair = when (cmd.optString("type")) { "lock" -> lock() "plock" -> parentalLock(true) "punlock" -> parentalLock(false) "mute" -> setMuted(true) "unmute" -> setMuted(false) "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 "" } "ring" -> ringer.start(cmd.optInt("seconds", 60)) "ring_stop" -> { ringer.stop(silent = false) "done" to "" } else -> "failed" to "commande inconnue" } /** * Verrouillage parental plein écran : affiche [ParentalLockActivity], que seul un code du serveur * (dicté par le parent) peut lever. [lock] = false lève le verrou à distance. */ private suspend fun parentalLock(lock: Boolean): Pair { settings.setParentalLocked(lock) return if (lock) { runCatching { context.startActivity(ParentalLockActivity.intent(context)) } AppLog.w("Commande", "Verrouillage parental activé (code du serveur requis pour déverrouiller)") "done" to "téléphone verrouillé" } else { AppLog.i("Commande", "Verrouillage parental levé par le parent") "done" to "téléphone déverrouillé" } } /** 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)) { AppLog.w("Commande", "Verrouillage impossible : DataControl n'est pas administrateur de l'appareil") return "failed" to "DataControl n'est pas administrateur de l'appareil" } return runCatching { dpm.lockNow() }.fold( onSuccess = { AppLog.i("Commande", "Téléphone verrouillé / mis en veille"); "done" to "" }, onFailure = { AppLog.w("Commande", "Verrouillage refusé : ${it.message}"); "failed" to (it.message ?: "refusé") } ) } private fun setMuted(muted: Boolean): Pair { 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") return if (any) "done" to "" else "failed" to "aucun flux audio modifiable (accès « Ne pas déranger » ?)" } private companion object { const val WAIT_SECONDS = 25 } }