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
@@ -1,6 +1,13 @@
|
||||
package com.jttools.datacontrol.guard
|
||||
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.graphics.PixelFormat
|
||||
import android.os.Build
|
||||
import android.view.Gravity
|
||||
import android.view.WindowManager
|
||||
import android.widget.TextView
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
@@ -13,6 +20,8 @@ import android.telecom.TelecomManager
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import com.jttools.datacontrol.log.AppLog
|
||||
import com.jttools.datacontrol.remote.RemoteControl
|
||||
import com.jttools.datacontrol.remote.RemoteSync
|
||||
import com.jttools.datacontrol.service.MonitorStateHolder
|
||||
import com.jttools.datacontrol.service.NetworkTransport
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
@@ -22,8 +31,11 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.ByteArrayOutputStream
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* Détecte l'application au premier plan (service d'accessibilité, sans lire le contenu des écrans)
|
||||
@@ -35,6 +47,10 @@ class AppGuardService : AccessibilityService() {
|
||||
|
||||
@Inject lateinit var engine: GuardEngine
|
||||
@Inject lateinit var stateHolder: MonitorStateHolder
|
||||
@Inject lateinit var remoteControl: RemoteControl
|
||||
@Inject lateinit var remoteSync: RemoteSync
|
||||
|
||||
private var shareBanner: TextView? = null
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var exempt: Set<String> = emptySet()
|
||||
@@ -65,6 +81,94 @@ class AppGuardService : AccessibilityService() {
|
||||
currentPkg?.let { enforce(it) }
|
||||
}
|
||||
}
|
||||
startScreenShareLoop()
|
||||
}
|
||||
|
||||
/**
|
||||
* Partage d'écran demandé par le parent : capture l'écran (API 30+) et l'envoie au serveur, tant que
|
||||
* [RemoteControl.screenShareUntil] n'est pas atteint. Un bandeau « Écran partagé » reste affiché.
|
||||
*/
|
||||
private fun startScreenShareLoop() = scope.launch {
|
||||
while (isActive) {
|
||||
if (!remoteControl.screenSharing) {
|
||||
hideBanner()
|
||||
delay(1_000)
|
||||
continue
|
||||
}
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
AppLog.w("Écran", "Partage d'écran indisponible (Android 11 minimum)")
|
||||
remoteSync.pushEvent("screen_unsupported", "Partage d'écran impossible : Android 11 minimum")
|
||||
delay(5_000)
|
||||
continue
|
||||
}
|
||||
showBanner()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val jpeg = captureJpeg()
|
||||
if (jpeg != null) remoteSync.pushScreen(jpeg)
|
||||
}
|
||||
delay(SCREEN_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
@androidx.annotation.RequiresApi(Build.VERSION_CODES.R)
|
||||
private suspend fun captureJpeg(): ByteArray? {
|
||||
val bitmap = suspendCancellableCoroutine<Bitmap?> { cont ->
|
||||
runCatching {
|
||||
takeScreenshot(android.view.Display.DEFAULT_DISPLAY, mainExecutor,
|
||||
object : TakeScreenshotCallback {
|
||||
override fun onSuccess(result: ScreenshotResult) {
|
||||
val bmp = runCatching {
|
||||
Bitmap.wrapHardwareBuffer(result.hardwareBuffer, result.colorSpace)
|
||||
?.copy(Bitmap.Config.ARGB_8888, false)
|
||||
}.getOrNull()
|
||||
result.hardwareBuffer.close()
|
||||
if (cont.isActive) cont.resume(bmp)
|
||||
}
|
||||
override fun onFailure(errorCode: Int) {
|
||||
if (cont.isActive) cont.resume(null)
|
||||
}
|
||||
})
|
||||
}.onFailure { if (cont.isActive) cont.resume(null) }
|
||||
} ?: return null
|
||||
return runCatching {
|
||||
val scaled = scaleDown(bitmap, MAX_WIDTH)
|
||||
if (scaled !== bitmap) bitmap.recycle()
|
||||
ByteArrayOutputStream().use { out ->
|
||||
scaled.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out)
|
||||
scaled.recycle()
|
||||
out.toByteArray()
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun scaleDown(bmp: Bitmap, maxWidth: Int): Bitmap {
|
||||
if (bmp.width <= maxWidth) return bmp
|
||||
val h = bmp.height * maxWidth / bmp.width
|
||||
return Bitmap.createScaledBitmap(bmp, maxWidth, h, true)
|
||||
}
|
||||
|
||||
private fun showBanner() {
|
||||
if (shareBanner != null) return
|
||||
val wm = getSystemService(WindowManager::class.java)
|
||||
val tv = TextView(this).apply {
|
||||
text = getString(com.jttools.datacontrol.R.string.screen_shared_banner)
|
||||
setBackgroundColor(Color.parseColor("#C62828"))
|
||||
setTextColor(Color.WHITE)
|
||||
setPadding(24, 12, 24, 12)
|
||||
textSize = 13f
|
||||
}
|
||||
val lp = WindowManager.LayoutParams(
|
||||
WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE,
|
||||
PixelFormat.TRANSLUCENT
|
||||
).apply { gravity = Gravity.TOP }
|
||||
runCatching { wm.addView(tv, lp); shareBanner = tv }
|
||||
}
|
||||
|
||||
private fun hideBanner() {
|
||||
shareBanner?.let { runCatching { getSystemService(WindowManager::class.java).removeView(it) } }
|
||||
shareBanner = null
|
||||
}
|
||||
|
||||
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
|
||||
@@ -152,6 +256,7 @@ class AppGuardService : AccessibilityService() {
|
||||
override fun onInterrupt() = Unit
|
||||
|
||||
override fun onDestroy() {
|
||||
hideBanner()
|
||||
runCatching { unregisterReceiver(screenReceiver) }
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
@@ -160,6 +265,9 @@ class AppGuardService : AccessibilityService() {
|
||||
companion object {
|
||||
private const val CHECK_INTERVAL_MS = 10_000L
|
||||
private const val EXEMPT_TTL_MS = 5 * 60_000L
|
||||
private const val SCREEN_INTERVAL_MS = 1_500L
|
||||
private const val MAX_WIDTH = 720
|
||||
private const val JPEG_QUALITY = 50
|
||||
|
||||
fun isEnabled(context: Context): Boolean {
|
||||
val enabled = Settings.Secure.getString(
|
||||
|
||||
@@ -38,6 +38,16 @@ class FamilyApi(private val apiBase: String, private val token: String) {
|
||||
request("POST", "$apiBase/api/device/apps", token, JSONObject().put("apps", apps))
|
||||
}
|
||||
|
||||
/** Attente longue des commandes du parent (verrouillage, son, partage d'écran). */
|
||||
fun commands(waitSeconds: Int): JSONArray =
|
||||
JSONObject(request("GET", "$apiBase/api/device/commands?wait=$waitSeconds", token, null, waitSeconds * 1000 + 5000))
|
||||
.optJSONArray("commands") ?: JSONArray()
|
||||
|
||||
/** Envoie une image de l'écran (JPEG) pendant un partage. */
|
||||
fun screen(jpeg: ByteArray) {
|
||||
request("POST", "$apiBase/api/device/screen", token, jpeg, "image/jpeg")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CONNECT_TIMEOUT_MS = 10_000
|
||||
private const val READ_TIMEOUT_MS = 15_000
|
||||
@@ -50,24 +60,31 @@ class FamilyApi(private val apiBase: String, private val token: String) {
|
||||
return request("POST", "${serverUrl.trimEnd('/')}/api/pair", null, body)
|
||||
}
|
||||
|
||||
fun request(method: String, url: String, token: String?, body: JSONObject?): String {
|
||||
fun request(method: String, url: String, token: String?, body: JSONObject?, readTimeoutMs: Int = READ_TIMEOUT_MS): String {
|
||||
return request(method, url, token, body?.toString()?.toByteArray(Charsets.UTF_8),
|
||||
"application/json; charset=utf-8", readTimeoutMs)
|
||||
}
|
||||
|
||||
fun request(method: String, url: String, token: String?, body: ByteArray?, contentType: String,
|
||||
readTimeoutMs: Int = READ_TIMEOUT_MS): String {
|
||||
val conn = try {
|
||||
(URL(url).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = method
|
||||
connectTimeout = CONNECT_TIMEOUT_MS
|
||||
readTimeout = READ_TIMEOUT_MS
|
||||
readTimeout = readTimeoutMs
|
||||
setRequestProperty("Accept", "application/json")
|
||||
token?.let { setRequestProperty("Authorization", "Bearer $it") }
|
||||
if (body != null) {
|
||||
doOutput = true
|
||||
setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
||||
setRequestProperty("Content-Type", contentType)
|
||||
setFixedLengthStreamingMode(body.size)
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
throw ApiException(0, "Serveur injoignable : ${e.message}")
|
||||
}
|
||||
try {
|
||||
if (body != null) conn.outputStream.use { it.write(body.toString().toByteArray(Charsets.UTF_8)) }
|
||||
if (body != null) conn.outputStream.use { it.write(body) }
|
||||
val code = conn.responseCode
|
||||
val text = (if (code in 200..299) conn.inputStream else conn.errorStream)
|
||||
?.bufferedReader()?.use { it.readText() }.orEmpty()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,20 @@ class RemoteSync @Inject constructor(
|
||||
return FamilyApi(link.apiBase, token)
|
||||
}
|
||||
|
||||
/** Attend les commandes du parent (bloquant jusqu'à [waitSeconds] s). null si le tunnel est coupé ou en erreur. */
|
||||
suspend fun pollCommands(waitSeconds: Int): JSONArray? = withContext(Dispatchers.IO) {
|
||||
if (!tunnel.up.value) return@withContext null
|
||||
val api = api() ?: return@withContext null
|
||||
try { api.commands(waitSeconds) } catch (_: ApiException) { null }
|
||||
}
|
||||
|
||||
/** Envoie une image de l'écran pendant un partage. */
|
||||
suspend fun pushScreen(jpeg: ByteArray): Boolean = withContext(Dispatchers.IO) {
|
||||
if (!tunnel.up.value) return@withContext false
|
||||
val api = api() ?: return@withContext false
|
||||
try { api.screen(jpeg); true } catch (_: ApiException) { false }
|
||||
}
|
||||
|
||||
/** Associe le téléphone au serveur : clés WireGuard générées ici, la clé privée ne quitte jamais l'appareil. */
|
||||
suspend fun pair(input: PairingInput, deviceName: String): Result<FamilyLink> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
|
||||
@@ -52,6 +52,7 @@ class DataMonitorService : LifecycleService() {
|
||||
@Inject lateinit var remoteSync: RemoteSync
|
||||
@Inject lateinit var guardEngine: GuardEngine
|
||||
@Inject lateinit var tunnel: TunnelManager
|
||||
@Inject lateinit var remoteControl: com.jttools.datacontrol.remote.RemoteControl
|
||||
|
||||
private var latestSettings: AppSettings = AppSettings()
|
||||
private var latestTransport: NetworkTransport = NetworkTransport.NONE
|
||||
@@ -106,6 +107,7 @@ class DataMonitorService : LifecycleService() {
|
||||
}
|
||||
guardEngine.start()
|
||||
deviceReporter.start(lifecycleScope)
|
||||
remoteControl.start(lifecycleScope)
|
||||
lifecycleScope.launch {
|
||||
connectivityObserver.observe().collect {
|
||||
latestTransport = it
|
||||
|
||||
@@ -32,6 +32,7 @@ object AboutContent {
|
||||
AboutEntry("Journal de diagnostic", "État du tunnel, mode appliqué et, si le service d'accessibilité est activé, chaque application utilisée. Conservé 15 jours, avec copie et partage."),
|
||||
AboutEntry("Mise à jour intégrée", "Recherche et installation de la dernière version publiée, depuis l'onglet Journal de cette page."),
|
||||
AboutEntry("Mise à jour poussée par le parent", "Depuis l'espace parents : la nouvelle version arrive par le tunnel, son empreinte est vérifiée, puis elle s'installe toute seule en mode propriétaire (sinon, touchez la notification « Mise à jour de DataControl »)."),
|
||||
AboutEntry("Actions à distance du parent", "Depuis l'espace parents : verrouiller / mettre en veille, couper ou rétablir le son, et voir l'écran en direct. Pendant un partage d'écran, un bandeau rouge « Écran partagé avec le parent » reste affiché sur le téléphone et le partage s'arrête tout seul au bout de 5 minutes."),
|
||||
AboutEntry("Reprise automatique", "La surveillance et le tunnel redémarrent après un redémarrage de l'appareil, et sont relancés périodiquement si le système les a arrêtés.")
|
||||
))
|
||||
)
|
||||
@@ -109,7 +110,8 @@ object AboutContent {
|
||||
)),
|
||||
AboutSection("Ce que voit le serveur", listOf(
|
||||
AboutEntry("Noms de domaine et volume", "Comme tout le trafic passe par le serveur du parent, celui-ci voit les sites demandés (journal DNS conservé 7 jours) et le volume échangé."),
|
||||
AboutEntry("Pas le contenu", "Les échanges chiffrés des applications (HTTPS) restent illisibles pour le serveur ; le service d'accessibilité ne lit pas le contenu des écrans.")
|
||||
AboutEntry("Pas le contenu", "Les échanges chiffrés des applications (HTTPS) restent illisibles pour le serveur ; le service d'accessibilité ne lit pas le contenu des écrans."),
|
||||
AboutEntry("Partage d'écran visible", "Le parent ne peut voir l'écran que pendant un partage qu'il déclenche ; le téléphone l'affiche alors en permanence (bandeau rouge) et le partage s'arrête seul au bout de 5 minutes. Aucune capture en dehors de ces moments.")
|
||||
)),
|
||||
AboutSection("Privilèges et licence", listOf(
|
||||
AboutEntry("Rien d'activé à votre insu", "VPN, position, administrateur d'appareil et accessibilité passent chacun par une autorisation explicite d'Android."),
|
||||
|
||||
@@ -135,4 +135,5 @@
|
||||
<string name="family_owner_release">Retirer le mode propriétaire</string>
|
||||
<string name="family_owner_release_error">Retrait impossible : voir le journal.</string>
|
||||
<string name="family_owner_off">Inactif. Pour l\'activer (une seule fois, téléphone branché au PC, tous les comptes retirés le temps de la commande) :</string>
|
||||
<string name="screen_shared_banner">🔴 Écran partagé avec le parent</string>
|
||||
</resources>
|
||||
@@ -4,4 +4,5 @@
|
||||
android:accessibilityFeedbackType="feedbackGeneric"
|
||||
android:canRetrieveWindowContent="false"
|
||||
android:description="@string/guard_service_description"
|
||||
android:notificationTimeout="100" />
|
||||
android:notificationTimeout="100"
|
||||
android:canTakeScreenshot="true" />
|
||||
@@ -1,4 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<device-admin>
|
||||
<uses-policies />
|
||||
<uses-policies>
|
||||
<force-lock />
|
||||
</uses-policies>
|
||||
</device-admin>
|
||||
Reference in new issue
Block a user