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