diff --git a/app/src/main/java/eu/darken/capod/common/MediaControl.kt b/app/src/main/java/eu/darken/capod/common/MediaControl.kt index d12fcaf4..362e3c8e 100644 --- a/app/src/main/java/eu/darken/capod/common/MediaControl.kt +++ b/app/src/main/java/eu/darken/capod/common/MediaControl.kt @@ -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() { diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt index d933505f..26a90030 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt @@ -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)) diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ReactionsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ReactionsCard.kt index b75ba246..5de46790 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ReactionsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ReactionsCard.kt @@ -140,6 +140,7 @@ internal fun ReactionsCard( checked = sleepDet.enabled, onCheckedChange = onSleepDetectionChange, enabled = enabled, + requiresUpgrade = !isPro, ) if (sleepDet.enabled) { SettingsInfoBox( diff --git a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt index 8a1eb4b9..0cfff685 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt @@ -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" } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt index aa7304db..e7306229 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt @@ -64,6 +64,15 @@ class AapConnectionManager @Inject constructor( private val _stemPressEvents = MutableSharedFlow>(extraBufferCapacity = 32) val stemPressEvents: SharedFlow> = _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(extraBufferCapacity = 16) + val sleepEvents: SharedFlow = _sleepEvents.asSharedFlow() + /** Emits when a SetAncMode(OFF) command was rejected by the device (inferred by the engine). */ private val _offRejectedEvents = MutableSharedFlow(extraBufferCapacity = 16) val offRejectedEvents: SharedFlow = _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 { diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapConnection.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapConnection.kt index da3b4ee3..d6c1b653 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapConnection.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapConnection.kt @@ -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 get() = engine.state val keysReceived: SharedFlow get() = engine.keysReceived val stemPressEvents: SharedFlow get() = engine.stemPressEvents + val sleepEvents: SharedFlow get() = engine.sleepEvents val offRejected: SharedFlow get() = engine.offRejected val settingRejected: SharedFlow get() = engine.settingRejected diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt index 8e2078cd..bdf16983 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt @@ -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(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST) val stemPressEvents: SharedFlow = _stemPressEvents.asSharedFlow() + private val _sleepEvents = + MutableSharedFlow(extraBufferCapacity = 4, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val sleepEvents: SharedFlow = _sleepEvents.asSharedFlow() + private val _offRejected = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) val offRejected: SharedFlow = _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 -> { diff --git a/app/src/main/java/eu/darken/capod/reaction/core/sleep/SleepReaction.kt b/app/src/main/java/eu/darken/capod/reaction/core/sleep/SleepReaction.kt new file mode 100644 index 00000000..b9627333 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/reaction/core/sleep/SleepReaction.kt @@ -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() + + fun monitor(): Flow = 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 + } +} diff --git a/app/src/main/java/eu/darken/capod/reaction/core/sleep/SleepReactionNotifications.kt b/app/src/main/java/eu/darken/capod/reaction/core/sleep/SleepReactionNotifications.kt new file mode 100644 index 00000000..45952bc9 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/reaction/core/sleep/SleepReactionNotifications.kt @@ -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 + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 85e17093..6f7dfd20 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -536,6 +536,9 @@ Allow Off as a selectable noise control mode. When disabled, stems skip Off while cycling. Sleep Detection Automatically pause audio when you fall asleep + Sleep Detection Auto-Pause + Paused by Sleep Detection + %1$s reported you fell asleep, so your music was paused. You can disable this in the device settings. Rename Device name Rename diff --git a/app/src/test/java/eu/darken/capod/common/MediaControlTest.kt b/app/src/test/java/eu/darken/capod/common/MediaControlTest.kt index 2069f46c..47338687 100644 --- a/app/src/test/java/eu/darken/capod/common/MediaControlTest.kt +++ b/app/src/test/java/eu/darken/capod/common/MediaControlTest.kt @@ -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()) } + } } diff --git a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt index 6ea193fc..c1084a9a 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt @@ -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()) } + } + + @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)) } + } } diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt index f8b5d894..6ecc06cd 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt @@ -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 ─────────────────────────────────────────── diff --git a/app/src/test/java/eu/darken/capod/reaction/core/sleep/SleepReactionTest.kt b/app/src/test/java/eu/darken/capod/reaction/core/sleep/SleepReactionTest.kt new file mode 100644 index 00000000..2c99738c --- /dev/null +++ b/app/src/test/java/eu/darken/capod/reaction/core/sleep/SleepReactionTest.kt @@ -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 + private lateinit var devicesFlow: MutableStateFlow> + 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() + } +}