feat: faire sonner le téléphone à distance et accusé de réception des commandes

Nouvelle action « Faire sonner » (commandes ring / ring_stop) : sonnerie
d'alarme à plein volume même en silencieux, vibration et notification
« touchez pour arrêter », arrêt automatique au bout de la durée demandée.

Chaque commande reçue est désormais acquittée au serveur (exécutée ou en
échec, avec le détail), ce qui complète l'audit côté espace parents.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ
This commit is contained in:
tuxgyverandClaude Opus 4.8 committed 2026-10-25 15:31:36 +01:00
1 parent 7af250cc1c
commit ed8be296ba
5 files changed
+217 -23

No files matched your search

+6
View File
@@ -7,6 +7,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
@@ -118,6 +119,11 @@
android:name=".remote.InstallResultReceiver" android:name=".remote.InstallResultReceiver"
android:exported="false" /> android:exported="false" />
<!-- Arrêt de la sonnerie « Faire sonner » depuis la notification. -->
<receiver
android:name=".remote.RingStopReceiver"
android:exported="false" />
<receiver <receiver
android:name=".service.BootReceiver" android:name=".service.BootReceiver"
android:exported="true" android:exported="true"
@@ -48,6 +48,12 @@ class FamilyApi(private val apiBase: String, private val token: String) {
request("POST", "$apiBase/api/device/screen", token, jpeg, "image/jpeg") request("POST", "$apiBase/api/device/screen", token, jpeg, "image/jpeg")
} }
/** Signale au serveur qu'une commande a été exécutée (audit). */
fun commandAck(id: String, status: String, detail: String) {
request("POST", "$apiBase/api/device/command-ack", token,
JSONObject().put("id", id).put("status", status).put("detail", detail.take(300)))
}
companion object { companion object {
private const val CONNECT_TIMEOUT_MS = 10_000 private const val CONNECT_TIMEOUT_MS = 10_000
private const val READ_TIMEOUT_MS = 15_000 private const val READ_TIMEOUT_MS = 15_000
@@ -33,7 +33,8 @@ import javax.inject.Singleton
class RemoteControl @Inject constructor( class RemoteControl @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val remote: RemoteSync, private val remote: RemoteSync,
private val settings: SettingsRepository private val settings: SettingsRepository,
private val ringer: Ringer
) { ) {
/** Instant (temps monotone) jusqu'auquel l'écran est partagé ; 0 = pas de partage. */ /** Instant (temps monotone) jusqu'auquel l'écran est partagé ; 0 = pas de partage. */
private val _screenShareUntil = MutableStateFlow(0L) private val _screenShareUntil = MutableStateFlow(0L)
@@ -58,8 +59,11 @@ class RemoteControl @Inject constructor(
continue continue
} }
for (i in 0 until commands.length()) { for (i in 0 until commands.length()) {
runCatching { handle(commands.getJSONObject(i)) } val cmd = commands.getJSONObject(i)
.onFailure { AppLog.w("Commande", "Commande ignorée : ${it.message}") } val outcome = runCatching { handle(cmd) }
val (status, detail) = outcome.getOrNull()
?: ("failed" to (outcome.exceptionOrNull()?.message ?: "erreur"))
remote.ackCommand(cmd.optString("id"), status, detail)
} }
} }
} }
@@ -76,35 +80,43 @@ class RemoteControl @Inject constructor(
} }
} }
private suspend fun handle(cmd: JSONObject) { /** Renvoie (statut, détail) pour l'audit : "done" ou "failed". */
when (cmd.optString("type")) { private fun handle(cmd: JSONObject): Pair<String, String> = when (cmd.optString("type")) {
"lock" -> lock() "lock" -> lock()
"mute" -> setMuted(true) "mute" -> setMuted(true)
"unmute" -> setMuted(false) "unmute" -> setMuted(false)
"screen_start" -> { "screen_start" -> {
val seconds = cmd.optInt("seconds", 120).coerceIn(10, 300) val seconds = cmd.optInt("seconds", 120).coerceIn(10, 300)
_screenShareUntil.value = SystemClock.elapsedRealtime() + seconds * 1000L _screenShareUntil.value = SystemClock.elapsedRealtime() + seconds * 1000L
AppLog.i("Commande", "Partage d'écran démarré ($seconds s)") AppLog.i("Commande", "Partage d'écran démarré ($seconds s)")
} "done" to "partage démarré"
"screen_stop" -> {
_screenShareUntil.value = 0L
AppLog.i("Commande", "Partage d'écran arrêté")
}
} }
"screen_stop" -> {
_screenShareUntil.value = 0L
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"
} }
private fun lock() { private fun lock(): Pair<String, String> {
val dpm = context.getSystemService(DevicePolicyManager::class.java) val dpm = context.getSystemService(DevicePolicyManager::class.java)
if (!AdminController.isActive(context)) { if (!AdminController.isActive(context)) {
AppLog.w("Commande", "Verrouillage impossible : DataControl n'est pas administrateur de l'appareil") AppLog.w("Commande", "Verrouillage impossible : DataControl n'est pas administrateur de l'appareil")
return return "failed" to "DataControl n'est pas administrateur de l'appareil"
} }
runCatching { dpm.lockNow() } return runCatching { dpm.lockNow() }.fold(
.onSuccess { AppLog.i("Commande", "Téléphone verrouillé / mis en veille") } onSuccess = { AppLog.i("Commande", "Téléphone verrouillé / mis en veille"); "done" to "" },
.onFailure { AppLog.w("Commande", "Verrouillage refusé : ${it.message}") } onFailure = { AppLog.w("Commande", "Verrouillage refusé : ${it.message}"); "failed" to (it.message ?: "refusé") }
)
} }
private fun setMuted(muted: Boolean) { private fun setMuted(muted: Boolean): Pair<String, String> {
val am = context.getSystemService(AudioManager::class.java) val am = context.getSystemService(AudioManager::class.java)
val dir = if (muted) AudioManager.ADJUST_MUTE else AudioManager.ADJUST_UNMUTE val dir = if (muted) AudioManager.ADJUST_MUTE else AudioManager.ADJUST_UNMUTE
val streams = intArrayOf( val streams = intArrayOf(
@@ -117,6 +129,7 @@ class RemoteControl @Inject constructor(
if (runCatching { am.adjustStreamVolume(s, dir, 0) }.isSuccess) any = true 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") 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 { private companion object {
@@ -60,6 +60,13 @@ class RemoteSync @Inject constructor(
try { api.screen(jpeg); true } catch (_: ApiException) { false } try { api.screen(jpeg); true } catch (_: ApiException) { false }
} }
/** Accuse réception/exécution d'une commande (audit côté parent). */
suspend fun ackCommand(id: String, status: String, detail: String = "") = withContext(Dispatchers.IO) {
if (id.isBlank() || !tunnel.up.value) return@withContext
val api = api() ?: return@withContext
runCatching { api.commandAck(id, status, detail) }
}
/** Associe le téléphone au serveur : clés WireGuard générées ici, la clé privée ne quitte jamais l'appareil. */ /** 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) { suspend fun pair(input: PairingInput, deviceName: String): Result<FamilyLink> = withContext(Dispatchers.IO) {
runCatching { runCatching {
@@ -0,0 +1,162 @@
package com.jttools.datacontrol.remote
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.MediaPlayer
import android.media.RingtoneManager
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import androidx.core.app.NotificationCompat
import com.jttools.datacontrol.R
import com.jttools.datacontrol.log.AppLog
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import java.util.Timer
import java.util.TimerTask
import javax.inject.Inject
import javax.inject.Singleton
/**
* « Faire sonner » demandé par le parent : joue une sonnerie d'alarme à plein volume, même si le
* téléphone est en silencieux (le flux d'alarme n'est pas concerné par le mode silencieux), vibre et
* affiche une notification « Appuyez pour arrêter ». S'arrête tout seul au bout de la durée demandée,
* à la demande du parent (commande ring_stop), ou quand l'enfant touche la notification.
*/
@Singleton
class Ringer @Inject constructor(
@ApplicationContext private val context: Context
) {
private var player: MediaPlayer? = null
private var previousVolume = -1
private var stopTimer: Timer? = null
@Synchronized
fun start(seconds: Int): Pair<String, String> {
stop(silent = true) // repart proprement si une sonnerie était déjà en cours
val am = context.getSystemService(AudioManager::class.java)
val uri = RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_ALARM)
?: RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_RINGTONE)
?: return "failed" to "aucune sonnerie disponible"
return try {
previousVolume = am.getStreamVolume(AudioManager.STREAM_ALARM)
am.setStreamVolume(AudioManager.STREAM_ALARM, am.getStreamMaxVolume(AudioManager.STREAM_ALARM), 0)
player = MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ALARM)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
)
setDataSource(context, uri)
isLooping = true
prepare()
start()
}
vibrate(true)
notify(context)
val secs = seconds.coerceIn(5, 300)
stopTimer = Timer().apply {
schedule(object : TimerTask() {
override fun run() = stop(silent = false)
}, secs * 1000L)
}
AppLog.i("Commande", "Sonnerie déclenchée ($secs s)")
"done" to "sonnerie déclenchée"
} catch (e: Exception) {
AppLog.e("Commande", "Sonnerie impossible", e)
stop(silent = true)
"failed" to (e.message ?: "sonnerie impossible")
}
}
@Synchronized
fun stop(silent: Boolean) {
stopTimer?.cancel(); stopTimer = null
runCatching { player?.stop() }
runCatching { player?.release() }
player = null
vibrate(false)
if (previousVolume >= 0) {
runCatching {
context.getSystemService(AudioManager::class.java)
.setStreamVolume(AudioManager.STREAM_ALARM, previousVolume, 0)
}
previousVolume = -1
}
context.getSystemService(NotificationManager::class.java).cancel(NOTIF_ID)
if (!silent) AppLog.i("Commande", "Sonnerie arrêtée")
}
private fun vibrate(on: Boolean) {
val vib = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
context.getSystemService(VibratorManager::class.java)?.defaultVibrator
} else {
@Suppress("DEPRECATION") context.getSystemService(Vibrator::class.java)
} ?: return
if (!on) { runCatching { vib.cancel() }; return }
runCatching {
val pattern = longArrayOf(0, 600, 400)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vib.vibrate(VibrationEffect.createWaveform(pattern, 0))
} else {
@Suppress("DEPRECATION") vib.vibrate(pattern, 0)
}
}
}
private fun notify(context: Context) {
val nm = context.getSystemService(NotificationManager::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
nm.createNotificationChannel(
NotificationChannel(CHANNEL, "Sonnerie", NotificationManager.IMPORTANCE_HIGH).apply {
setSound(null, null) // le son est joué par le lecteur (flux d'alarme)
enableVibration(false)
}
)
}
val stop = PendingIntent.getBroadcast(
context, 0, Intent(context, RingStopReceiver::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
nm.notify(NOTIF_ID, NotificationCompat.Builder(context, CHANNEL)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("DataControl sonne")
.setContentText("Demandé par le parent — touchez pour arrêter.")
.setPriority(NotificationCompat.PRIORITY_MAX)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setOngoing(true)
.setAutoCancel(true)
.setContentIntent(stop)
.setFullScreenIntent(stop, true)
.build())
}
companion object {
private const val CHANNEL = "datacontrol_ring"
private const val NOTIF_ID = 3102
}
}
/** Arrêt de la sonnerie depuis la notification (touche de l'enfant). */
class RingStopReceiver : BroadcastReceiver() {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface Deps {
fun ringer(): Ringer
}
override fun onReceive(context: Context, intent: Intent) {
EntryPointAccessors.fromApplication(context.applicationContext, Deps::class.java).ringer().stop(silent = false)
}
}