- 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
176 lines
7.7 KiB
Kotlin
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
|
|
}
|
|
}
|