Add Android alert effect playback

This commit is contained in:
ajp_anton
2026-08-16 17:21:51 +00:00
parent de09b283ad
commit 0089f839e3
3 changed files with 110 additions and 0 deletions
@@ -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()
}
}
+1
View File
@@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Required for the all-installed-apps filter; this app is not distributed through Google Play. -->
<uses-permission
@@ -0,0 +1,86 @@
package se.ajpanton.notificationsmaster.alerts
import android.content.Context
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.os.VibrationAttributes
import android.os.VibrationEffect
import android.os.VibratorManager
/** Plays resolved notification-class effects and releases all platform resources on stop. */
class AndroidAlertEffectPlayer(context: Context) : AlertEffectPlayer {
private val context = context.applicationContext
private val handler = Handler(Looper.getMainLooper())
private val vibrator = context.getSystemService(VibratorManager::class.java).defaultVibrator
private var generation = 0L
private var remainingEffects = 0
private var player: MediaPlayer? = null
private var completion: (() -> 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()
}
}