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)
+ }
+}