Files
DataControl/app/src/main/java/com/jttools/datacontrol/remote/RemoteControl.kt
T
tuxgyverandClaude Opus 4.8 d4aa0e1962 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
2026-10-25 22:28:16 +01:00

176 lines
7.7 KiB
Kotlin

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<Long> = _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<String, String> = 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<String, String> {
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<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)) {
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<String, String> {
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
}
}