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:
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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user