From ed8be296ba6ce985195623eb17ebee627a37b077 Mon Sep 17 00:00:00 2001 From: tuxgyver Date: Sun, 25 Oct 2026 15:31:36 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20faire=20sonner=20le=20t=C3=A9l=C3=A9pho?= =?UTF-8?q?ne=20=C3=A0=20distance=20et=20accus=C3=A9=20de=20r=C3=A9ception?= =?UTF-8?q?=20des=20commandes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01R9CBCVP5oDu2sfoYPisNCQ --- app/src/main/AndroidManifest.xml | 6 + .../jttools/datacontrol/remote/FamilyApi.kt | 6 + .../datacontrol/remote/RemoteControl.kt | 59 ++++--- .../jttools/datacontrol/remote/RemoteSync.kt | 7 + .../com/jttools/datacontrol/remote/Ringer.kt | 162 ++++++++++++++++++ 5 files changed, 217 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/com/jttools/datacontrol/remote/Ringer.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1949ed0..43c8f7d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ + @@ -118,6 +119,11 @@ android:name=".remote.InstallResultReceiver" android:exported="false" /> + + + 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é") - } + /** Renvoie (statut, détail) pour l'audit : "done" ou "failed". */ + private fun handle(cmd: JSONObject): Pair = 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)") + "done" to "partage démarré" } + "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 { 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 + return "failed" to "DataControl n'est pas administrateur de l'appareil" } - runCatching { dpm.lockNow() } - .onSuccess { AppLog.i("Commande", "Téléphone verrouillé / mis en veille") } - .onFailure { AppLog.w("Commande", "Verrouillage refusé : ${it.message}") } + 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) { + private fun setMuted(muted: Boolean): Pair { val am = context.getSystemService(AudioManager::class.java) val dir = if (muted) AudioManager.ADJUST_MUTE else AudioManager.ADJUST_UNMUTE val streams = intArrayOf( @@ -117,6 +129,7 @@ class RemoteControl @Inject constructor( 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 { diff --git a/app/src/main/java/com/jttools/datacontrol/remote/RemoteSync.kt b/app/src/main/java/com/jttools/datacontrol/remote/RemoteSync.kt index d09f76a..b323667 100644 --- a/app/src/main/java/com/jttools/datacontrol/remote/RemoteSync.kt +++ b/app/src/main/java/com/jttools/datacontrol/remote/RemoteSync.kt @@ -60,6 +60,13 @@ class RemoteSync @Inject constructor( 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. */ suspend fun pair(input: PairingInput, deviceName: String): Result = withContext(Dispatchers.IO) { runCatching { diff --git a/app/src/main/java/com/jttools/datacontrol/remote/Ringer.kt b/app/src/main/java/com/jttools/datacontrol/remote/Ringer.kt new file mode 100644 index 0000000..d1c69c0 --- /dev/null +++ b/app/src/main/java/com/jttools/datacontrol/remote/Ringer.kt @@ -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 { + 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) + } +}