From 0089f839e396fd31e8dbf7024c6f31a0a4c1ebf1 Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Sun, 16 Aug 2026 17:21:51 +0000 Subject: [PATCH] Add Android alert effect playback --- .../alerts/AndroidAlertEffectPlayerTest.kt | 23 +++++ app/src/main/AndroidManifest.xml | 1 + .../alerts/AndroidAlertEffectPlayer.kt | 86 +++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 app/src/androidTest/java/se/ajpanton/notificationsmaster/alerts/AndroidAlertEffectPlayerTest.kt create mode 100644 app/src/main/java/se/ajpanton/notificationsmaster/alerts/AndroidAlertEffectPlayer.kt diff --git a/app/src/androidTest/java/se/ajpanton/notificationsmaster/alerts/AndroidAlertEffectPlayerTest.kt b/app/src/androidTest/java/se/ajpanton/notificationsmaster/alerts/AndroidAlertEffectPlayerTest.kt new file mode 100644 index 0000000..182f0bd --- /dev/null +++ b/app/src/androidTest/java/se/ajpanton/notificationsmaster/alerts/AndroidAlertEffectPlayerTest.kt @@ -0,0 +1,23 @@ +package se.ajpanton.notificationsmaster.alerts + +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class AndroidAlertEffectPlayerTest { + @Test + fun vibrationCompletesWithoutLeakingPlayback() { + val completion = CountDownLatch(1) + val player = AndroidAlertEffectPlayer(InstrumentationRegistry.getInstrumentation().targetContext) + + player.play( + QueuedAlert("test", AlertSnapshot(null, listOf(0, 10), false, false)), + completion::countDown, + ) + + assertTrue(completion.await(2, TimeUnit.SECONDS)) + player.stop() + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 94258ba..1efa01b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + Unit)? = null + + override fun play(alert: QueuedAlert, onFinished: () -> Unit) = synchronized(this) { + stopLocked() + val token = generation + completion = onFinished + val snapshot = alert.snapshot + val vibrationDuration = snapshot.vibrationPattern.sum() + remainingEffects = (if (snapshot.soundUri == null) 0 else 1) + (if (vibrationDuration > 0) 1 else 0) + snapshot.soundUri?.let { startSound(Uri.parse(it), token) } + if (vibrationDuration > 0) { + vibrator.vibrate( + VibrationEffect.createWaveform(snapshot.vibrationPattern.toLongArray(), -1), + VibrationAttributes.Builder().setUsage(VibrationAttributes.USAGE_NOTIFICATION).build(), + ) + handler.postDelayed({ finishEffect(token) }, vibrationDuration) + } + if (remainingEffects == 0) finishEffect(token) + } + + override fun stop() = synchronized(this) { stopLocked() } + + private fun startSound(uri: Uri, token: Long) { + val mediaPlayer = MediaPlayer() + try { + player = mediaPlayer + mediaPlayer.setAudioAttributes( + AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_NOTIFICATION).build(), + ) + mediaPlayer.setDataSource(context, uri) + mediaPlayer.setOnCompletionListener { finishSound(token, mediaPlayer) } + mediaPlayer.setOnErrorListener { _, _, _ -> + finishSound(token, mediaPlayer) + true + } + mediaPlayer.prepareAsync() + } catch (_: Exception) { + if (player === mediaPlayer) player = null + mediaPlayer.release() + finishEffect(token) + } + } + + private fun finishSound(token: Long, mediaPlayer: MediaPlayer): Unit = synchronized(this) { + if (player !== mediaPlayer) return + player = null + mediaPlayer.release() + finishEffect(token) + } + + private fun finishEffect(token: Long): Unit = synchronized(this) { + if (token != generation || remainingEffects == 0) return + if (--remainingEffects != 0) return + completion.also { completion = null }?.invoke() + } + + private fun stopLocked() { + generation++ + remainingEffects = 0 + completion = null + handler.removeCallbacksAndMessages(null) + player?.release() + player = null + vibrator.cancel() + } +}