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
@@ -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()
}
}