mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
feat(reaction): Pause media when AirPods detect sleep
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()) }
|
||||
}
|
||||
}
|
||||
|
||||
+41
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user