feat(reaction): Pause media when AirPods detect sleep

This commit is contained in:
darken
2026-04-25 10:21:30 +02:00
committed by Matthias Urhahn
parent f35fe968c6
commit 26b72726a7
14 changed files with 514 additions and 3 deletions
@@ -32,14 +32,25 @@ class MediaControl @Inject constructor(
clearRecentCapPause()
}
suspend fun sendPause() {
/**
* Dispatches a MEDIA_PAUSE key event if music is currently playing.
*
* Returns `true` when a key event was actually dispatched (and the 15-second
* [wasRecentlyPausedByCap] window was set), `false` when the call was a no-op because
* nothing was playing. Callers that need to distinguish "we actually paused" from "there
* was nothing to pause" — e.g. the sleep reaction, which gates its notification and
* cooldown on a real pause — should branch on the return value rather than checking
* [isPlaying] themselves to avoid a check-then-act race with the audio system.
*/
suspend fun sendPause(): Boolean {
log(TAG, INFO) { "sendPause()" }
if (!audioManager.isMusicActive) {
log(TAG, INFO) { "Music is not playing, not sending pause" }
return
return false
}
sendKey(KeyEvent.KEYCODE_MEDIA_PAUSE)
markRecentCapPause()
return true
}
suspend fun sendPlayPause() {
@@ -294,7 +294,14 @@ class DeviceSettingsViewModel @Inject constructor(
}
}
fun setSleepDetection(enabled: Boolean) = send(AapCommand.SetSleepDetection(enabled))
fun setSleepDetection(enabled: Boolean) = launch {
log(TAG, INFO) { "setSleepDetection($enabled)" }
if (enabled && !upgradeRepo.isPro()) {
navTo(Nav.Main.Upgrade)
return@launch
}
sendInternal(AapCommand.SetSleepDetection(enabled))
}
fun setDynamicEndOfCharge(enabled: Boolean) = send(AapCommand.SetDynamicEndOfCharge(enabled))
@@ -140,6 +140,7 @@ internal fun ReactionsCard(
checked = sleepDet.enabled,
onCheckedChange = onSleepDetectionChange,
enabled = enabled,
requiresUpgrade = !isPro,
)
if (sleepDet.enabled) {
SettingsInfoBox(
@@ -40,6 +40,7 @@ import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.reaction.core.autoconnect.AutoConnect
import eu.darken.capod.reaction.core.playpause.PlayPause
import eu.darken.capod.reaction.core.popup.PopUpReaction
import eu.darken.capod.reaction.core.sleep.SleepReaction
import eu.darken.capod.reaction.ui.popup.PopUpWindow
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
@@ -73,6 +74,7 @@ class MonitorService : Service() {
@Inject lateinit var playPause: PlayPause
@Inject lateinit var autoConnect: AutoConnect
@Inject lateinit var popUpReaction: PopUpReaction
@Inject lateinit var sleepReaction: SleepReaction
@Inject lateinit var popUpWindow: PopUpWindow
@Inject lateinit var profilesRepo: DeviceProfilesRepo
@Inject lateinit var aapConnectionManager: AapConnectionManager
@@ -302,6 +304,11 @@ class MonitorService : Service() {
.catch { log(TAG, WARN) { "autoConnect failed:\n${it.asLog()}" } }
.launchIn(monitorScope)
sleepReaction.monitor()
.setupCommonEventHandlers(TAG) { "sleepReaction" }
.catch { log(TAG, WARN) { "sleepReaction failed:\n${it.asLog()}" } }
.launchIn(monitorScope)
log(TAG, VERBOSE) { "Monitor job is active" }
monitorJob.join()
log(TAG, VERBOSE) { "Monitor job quit" }
@@ -64,6 +64,15 @@ class AapConnectionManager @Inject constructor(
private val _stemPressEvents = MutableSharedFlow<Pair<BluetoothAddress, StemPressEvent>>(extraBufferCapacity = 32)
val stemPressEvents: SharedFlow<Pair<BluetoothAddress, StemPressEvent>> = _stemPressEvents.asSharedFlow()
/**
* Emits when a connected device fires a Sleep Detection update (AAP opcode 0x57).
* Address-only — the opaque payload stays logged at engine level per the "never suppress
* protocol logging" convention; downstream consumers (SleepReaction) only need the origin
* address to decide whether to act.
*/
private val _sleepEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
val sleepEvents: SharedFlow<BluetoothAddress> = _sleepEvents.asSharedFlow()
/** Emits when a SetAncMode(OFF) command was rejected by the device (inferred by the engine). */
private val _offRejectedEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
val offRejectedEvents: SharedFlow<BluetoothAddress> = _offRejectedEvents.asSharedFlow()
@@ -123,6 +132,14 @@ class AapConnectionManager @Inject constructor(
}
}
// Forward sleep events from this connection (child coroutine). Address-only —
// the opaque payload stays logged at the engine layer.
launch {
connection.sleepEvents.collect {
_sleepEvents.tryEmit(address)
}
}
// Forward OFF-rejection events from this connection (child coroutine)
launch {
connection.offRejected.collect {
@@ -13,6 +13,7 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
import eu.darken.capod.pods.core.apple.aap.protocol.AapFramer
import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket
import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
import kotlinx.coroutines.CoroutineScope
@@ -45,6 +46,7 @@ internal class AapConnection(
val state: StateFlow<AapPodState> get() = engine.state
val keysReceived: SharedFlow<KeyExchangeResult> get() = engine.keysReceived
val stemPressEvents: SharedFlow<StemPressEvent> get() = engine.stemPressEvents
val sleepEvents: SharedFlow<AapSleepEvent> get() = engine.sleepEvents
val offRejected: SharedFlow<Unit> get() = engine.offRejected
val settingRejected: SharedFlow<AapCommand> get() = engine.settingRejected
@@ -13,6 +13,7 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessageType
import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
import kotlinx.coroutines.CoroutineScope
@@ -49,6 +50,10 @@ internal class AapSessionEngine(
MutableSharedFlow<StemPressEvent>(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val stemPressEvents: SharedFlow<StemPressEvent> = _stemPressEvents.asSharedFlow()
private val _sleepEvents =
MutableSharedFlow<AapSleepEvent>(extraBufferCapacity = 4, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val sleepEvents: SharedFlow<AapSleepEvent> = _sleepEvents.asSharedFlow()
private val _offRejected =
MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val offRejected: SharedFlow<Unit> = _offRejected.asSharedFlow()
@@ -246,6 +251,7 @@ internal class AapSessionEngine(
_state.value = _state.value.copy(lastMessageAt = timeSource.now())
val hex = update.event.rawPayload.joinToString(" ") { "%02X".format(it) }
log(TAG, INFO) { "Sleep event: ${update.event.rawPayload.size}B payload=[$hex]" }
_sleepEvents.tryEmit(update.event)
}
is AapInboundUpdate.DynamicEndOfChargeEvent -> {
@@ -0,0 +1,68 @@
package eu.darken.capod.reaction.core.sleep
import eu.darken.capod.common.MediaControl
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.primaryDevice
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SleepReaction @Inject constructor(
private val aapManager: AapConnectionManager,
private val deviceMonitor: DeviceMonitor,
private val mediaControl: MediaControl,
private val notifications: SleepReactionNotifications,
private val timeSource: TimeSource,
) {
private val cooldowns = ConcurrentHashMap<BluetoothAddress, Long>()
fun monitor(): Flow<Unit> = aapManager.sleepEvents
.onEach { address -> handle(address) }
.map { }
.setupCommonEventHandlers(TAG) { "sleepReaction" }
private suspend fun handle(address: BluetoothAddress) {
val now = timeSource.elapsedRealtime()
val last = cooldowns[address]
if (last != null && now - last < COOLDOWN_MS) {
log(TAG) { "Sleep event from $address suppressed by cooldown" }
return
}
val primary = deviceMonitor.primaryDevice().first()
if (primary?.address != address) {
log(TAG) { "Sleep event from $address ignored — not primary device (primary=${primary?.address})" }
return
}
// Use sendPause's return value as the atomic check+act: true means we really paused
// something, false means nothing was playing. Gating the cooldown and notification on
// this closes the race where audio could stop between an upfront isPlaying check and
// the key dispatch, and avoids burning the 5-minute window on no-ops.
val paused = mediaControl.sendPause()
if (!paused) {
log(TAG) { "Sleep event from $address ignored — nothing was playing" }
return
}
cooldowns[address] = now
val label = primary.label ?: primary.model.label
log(TAG, INFO) { "Sleep detected on $address ($label) — paused media, notifying" }
notifications.show(label)
}
companion object {
private val TAG = logTag("Reaction", "Sleep")
private const val COOLDOWN_MS = 5L * 60L * 1000L
}
}
@@ -0,0 +1,66 @@
package eu.darken.capod.reaction.core.sleep
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.notifications.PendingIntentCompat
import eu.darken.capod.main.ui.MainActivity
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SleepReactionNotifications @Inject constructor(
@ApplicationContext private val context: Context,
private val notificationManager: NotificationManager,
) {
init {
notificationManager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.reaction_sleep_channel_label),
NotificationManager.IMPORTANCE_LOW,
)
)
}
fun show(deviceLabel: String) {
if (!notificationManager.areNotificationsEnabled()) {
log(TAG, WARN) { "Notifications disabled — sleep pause will be silent" }
return
}
val openPi = PendingIntent.getActivity(
context,
PENDING_INTENT_REQUEST_CODE,
Intent(context, MainActivity::class.java),
PendingIntentCompat.FLAG_IMMUTABLE,
)
val text = context.getString(R.string.reaction_sleep_notification_text, deviceLabel)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.device_earbuds_generic_both)
.setContentTitle(context.getString(R.string.reaction_sleep_notification_title))
.setContentText(text)
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setContentIntent(openPi)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
notificationManager.notify(NOTIFICATION_ID, notification)
}
companion object {
private val TAG = logTag("Reaction", "Sleep", "Notifications")
private val CHANNEL_ID = "${BuildConfigWrap.APPLICATION_ID}.notification.channel.reaction.sleep"
private const val NOTIFICATION_ID = 3
private const val PENDING_INTENT_REQUEST_CODE = 0
}
}
+3
View File
@@ -536,6 +536,9 @@
<string name="device_settings_allow_off_description">Allow Off as a selectable noise control mode. When disabled, stems skip Off while cycling.</string>
<string name="device_settings_sleep_detection_label">Sleep Detection</string>
<string name="device_settings_sleep_detection_description">Automatically pause audio when you fall asleep</string>
<string name="reaction_sleep_channel_label">Sleep Detection Auto-Pause</string>
<string name="reaction_sleep_notification_title">Paused by Sleep Detection</string>
<string name="reaction_sleep_notification_text">%1$s reported you fell asleep, so your music was paused. You can disable this in the device settings.</string>
<string name="device_settings_rename_label">Rename</string>
<string name="device_settings_rename_hint">Device name</string>
<string name="device_settings_rename_confirm">Rename</string>
@@ -59,4 +59,28 @@ class MediaControlTest : BaseTest() {
verify(exactly = 2) { audioManager.dispatchMediaKeyEvent(any()) }
}
@Test
fun `sendPause returns true and dispatches when music is active`() = runTest {
every { audioManager.isMusicActive } returns true
val dispatched = mediaControl.sendPause()
assertTrue(dispatched)
assertTrue(mediaControl.wasRecentlyPausedByCap)
verify(exactly = 2) { audioManager.dispatchMediaKeyEvent(any()) }
}
@Test
fun `sendPause returns false and is a no-op when no music is active`() = runTest {
every { audioManager.isMusicActive } returns false
val dispatched = mediaControl.sendPause()
assertFalse(dispatched)
// Critical: the 15-second cap-pause window must NOT open for a no-op pause, otherwise
// an unrelated sendPlay would treat it as "we just paused, resume from it".
assertFalse(mediaControl.wasRecentlyPausedByCap)
verify(exactly = 0) { audioManager.dispatchMediaKeyEvent(any()) }
}
}
@@ -478,4 +478,45 @@ class DeviceSettingsViewModelTest : BaseTest() {
coVerify(exactly = 0) { aapManager.sendCommand(any(), AapCommand.SetAllowOffOption(false)) }
coVerify(exactly = 0) { aapManager.sendCommand(any(), AapCommand.SetListeningModeCycle(0x0E)) }
}
@Test
fun `setSleepDetection(true) as Pro sends command`() = runVmTest {
every { upgradeInfoFlow.value.isPro } returns true
val vm = createViewModel()
vm.initialize(testAddress)
vm.state.first()
vm.setSleepDetection(true)
coVerify(exactly = 1) { aapManager.sendCommand(testAddress, AapCommand.SetSleepDetection(true)) }
}
@Test
fun `setSleepDetection(true) as non-Pro sends no command`() = runVmTest {
every { upgradeInfoFlow.value.isPro } returns false
val vm = createViewModel()
vm.initialize(testAddress)
vm.state.first()
vm.setSleepDetection(true)
coVerify(exactly = 0) { aapManager.sendCommand(any(), any<AapCommand.SetSleepDetection>()) }
}
@Test
fun `setSleepDetection(false) as non-Pro still sends command`() = runVmTest {
// Disabling must work regardless of pro status so users who enabled it
// before a subscription expired can still turn it off.
every { upgradeInfoFlow.value.isPro } returns false
val vm = createViewModel()
vm.initialize(testAddress)
vm.state.first()
vm.setSleepDetection(false)
coVerify(exactly = 1) { aapManager.sendCommand(testAddress, AapCommand.SetSleepDetection(false)) }
}
}
@@ -7,6 +7,7 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.nulls.shouldBeNull
@@ -473,6 +474,27 @@ class AapSessionEngineTest : BaseTest() {
emitted.shouldNotBeNull()
emitted!!.pressType shouldBe StemPressEvent.PressType.SINGLE
}
@Test
fun `sleep event emits event alongside hex log`() = runTest(UnconfinedTestDispatcher()) {
val payload = byteArrayOf(0x01, 0x02, 0x03, 0x04)
val profile = mockProfile {
every { decodeSleepEvent(any()) } returns AapSleepEvent(rawPayload = payload)
}
val engine = AapSessionEngine(profile, timeSource)
engine.start(this as TestScope)
var emitted: AapSleepEvent? = null
val job = launch {
emitted = engine.sleepEvents.first()
}
engine.processMessage(dummyMessage(commandType = 0x0057))
job.join()
emitted.shouldNotBeNull()
emitted!!.rawPayload shouldBe payload
}
}
// ── Inference ───────────────────────────────────────────
@@ -0,0 +1,236 @@
package eu.darken.capod.reaction.core.sleep
import eu.darken.capod.common.MediaControl
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.TestTimeSource
import java.time.Duration
class SleepReactionTest : BaseTest() {
private val primaryAddress: BluetoothAddress = "AA:BB:CC:DD:EE:FF"
private val otherAddress: BluetoothAddress = "11:22:33:44:55:66"
private lateinit var incomingSleepFlow: MutableSharedFlow<BluetoothAddress>
private lateinit var devicesFlow: MutableStateFlow<List<PodDevice>>
private lateinit var aapManager: AapConnectionManager
private lateinit var deviceMonitor: DeviceMonitor
private lateinit var mediaControl: MediaControl
private lateinit var notifications: SleepReactionNotifications
private lateinit var timeSource: TestTimeSource
private fun mockPodDevice(address: BluetoothAddress, label: String? = "Custom Label"): PodDevice {
val addrValue = address
val labelValue = label
return mockk(relaxed = true) {
every { profileId } returns addrValue
every { this@mockk.address } returns addrValue
every { this@mockk.label } returns labelValue
every { model } returns PodModel.AIRPODS_PRO3
}
}
@BeforeEach
fun setup() {
incomingSleepFlow = MutableSharedFlow(extraBufferCapacity = 16)
devicesFlow = MutableStateFlow(listOf(mockPodDevice(primaryAddress)))
aapManager = mockk(relaxed = true) {
every { sleepEvents } returns incomingSleepFlow
}
deviceMonitor = mockk(relaxed = true) {
every { devices } returns devicesFlow
}
mediaControl = mockk(relaxed = true) {
coEvery { sendPause() } returns true
}
notifications = mockk(relaxed = true)
timeSource = TestTimeSource()
}
private fun createReaction(): SleepReaction = SleepReaction(
aapManager = aapManager,
deviceMonitor = deviceMonitor,
mediaControl = mediaControl,
notifications = notifications,
timeSource = timeSource,
)
private fun TestScope.launchReaction(): kotlinx.coroutines.Job =
createReaction().monitor().launchIn(this)
@Test
fun `primary device match triggers pause and notification`() = runTest(UnconfinedTestDispatcher()) {
val job = launchReaction()
incomingSleepFlow.emit(primaryAddress)
runCurrent()
coVerify(exactly = 1) { mediaControl.sendPause() }
verify(exactly = 1) { notifications.show("Custom Label") }
job.cancel()
}
@Test
fun `falls back to model label when profile label is null`() = runTest(UnconfinedTestDispatcher()) {
devicesFlow.value = listOf(mockPodDevice(primaryAddress, label = null))
val job = launchReaction()
incomingSleepFlow.emit(primaryAddress)
runCurrent()
verify(exactly = 1) { notifications.show(PodModel.AIRPODS_PRO3.label) }
job.cancel()
}
@Test
fun `non-primary device is ignored`() = runTest(UnconfinedTestDispatcher()) {
val job = launchReaction()
incomingSleepFlow.emit(otherAddress)
runCurrent()
coVerify(exactly = 0) { mediaControl.sendPause() }
verify(exactly = 0) { notifications.show(any()) }
job.cancel()
}
@Test
fun `cooldown suppresses second trigger within 5 minutes`() = runTest(UnconfinedTestDispatcher()) {
val job = launchReaction()
incomingSleepFlow.emit(primaryAddress)
runCurrent()
coVerify(exactly = 1) { mediaControl.sendPause() }
timeSource.advanceBy(Duration.ofMinutes(4))
incomingSleepFlow.emit(primaryAddress)
runCurrent()
coVerify(exactly = 1) { mediaControl.sendPause() }
verify(exactly = 1) { notifications.show(any()) }
job.cancel()
}
@Test
fun `cooldown expires after 5 minutes`() = runTest(UnconfinedTestDispatcher()) {
val job = launchReaction()
incomingSleepFlow.emit(primaryAddress)
runCurrent()
timeSource.advanceBy(Duration.ofMinutes(5).plusSeconds(1))
incomingSleepFlow.emit(primaryAddress)
runCurrent()
coVerify(exactly = 2) { mediaControl.sendPause() }
verify(exactly = 2) { notifications.show(any()) }
job.cancel()
}
@Test
fun `no primary device is ignored`() = runTest(UnconfinedTestDispatcher()) {
devicesFlow.value = emptyList()
val job = launchReaction()
incomingSleepFlow.emit(primaryAddress)
runCurrent()
coVerify(exactly = 0) { mediaControl.sendPause() }
verify(exactly = 0) { notifications.show(any()) }
job.cancel()
}
@Test
fun `cooldown is per-device`() = runTest(UnconfinedTestDispatcher()) {
// Each device has its own cooldown — a fresh address within another device's cooldown
// window still triggers.
val job = launchReaction()
incomingSleepFlow.emit(primaryAddress)
runCurrent()
coVerify(exactly = 1) { mediaControl.sendPause() }
devicesFlow.value = listOf(mockPodDevice(otherAddress, label = "B"))
incomingSleepFlow.emit(otherAddress)
runCurrent()
coVerify(exactly = 2) { mediaControl.sendPause() }
verify(exactly = 1) { notifications.show("Custom Label") }
verify(exactly = 1) { notifications.show("B") }
job.cancel()
}
@Test
fun `sendPause returning false skips the notification`() = runTest(UnconfinedTestDispatcher()) {
coEvery { mediaControl.sendPause() } returns false
val job = launchReaction()
incomingSleepFlow.emit(primaryAddress)
runCurrent()
// sendPause is still attempted — the reaction relies on its return as the atomic check.
coVerify(exactly = 1) { mediaControl.sendPause() }
verify(exactly = 0) { notifications.show(any()) }
job.cancel()
}
@Test
fun `sendPause returning false does not consume cooldown`() = runTest(UnconfinedTestDispatcher()) {
// First event fires while nothing is playing → sendPause returns false → no cooldown
// recorded. Second event 30s later while music IS playing must still fire even though
// we're well inside what would have been the 5-minute window.
coEvery { mediaControl.sendPause() } returns false
val job = launchReaction()
incomingSleepFlow.emit(primaryAddress)
runCurrent()
verify(exactly = 0) { notifications.show(any()) }
coEvery { mediaControl.sendPause() } returns true
timeSource.advanceBy(Duration.ofSeconds(30))
incomingSleepFlow.emit(primaryAddress)
runCurrent()
coVerify(exactly = 2) { mediaControl.sendPause() }
verify(exactly = 1) { notifications.show("Custom Label") }
job.cancel()
}
@Test
fun `non-primary event never consumes cooldown`() = runTest(UnconfinedTestDispatcher()) {
// A burst of non-primary events should not silently burn the cooldown — otherwise a
// later primary-device event could be incorrectly suppressed.
val job = launchReaction()
incomingSleepFlow.emit(otherAddress)
incomingSleepFlow.emit(otherAddress)
incomingSleepFlow.emit(otherAddress)
runCurrent()
// Now the real primary-device event arrives immediately after — must fire.
incomingSleepFlow.emit(primaryAddress)
runCurrent()
coVerify(exactly = 1) { mediaControl.sendPause() }
verify(exactly = 1) { notifications.show("Custom Label") }
job.cancel()
}
}