fix(monitor): Show worn device in notification and popup

This commit is contained in:
darken
2026-06-30 23:29:34 +02:00
committed by Matthias Urhahn
parent a91ef44686
commit f626d70538
5 changed files with 76 additions and 9 deletions
@@ -212,6 +212,21 @@ class DeviceMonitor @Inject constructor(
dedupedLiveDevices + nonLiveDevices
}.replayingShare(appScope)
/**
* Connection-aware "primary" device for display surfaces (ongoing notification, case popup):
* ranks by [PodDevice.tierRank] (system-connected > live > offline) with the user's
* profile-list order as the tiebreaker. Unlike [primaryDevice], this does NOT blindly pick the
* first profile, so a worn/connected pod is surfaced even when a different profile sits higher
* in the list. Shared so multiple display collectors don't each re-run the combine.
*/
val primaryDeviceByTier: Flow<PodDevice?> = combine(
devices,
profilesRepo.profiles,
) { devices, profiles ->
val profileOrder = profiles.mapIndexed { index, profile -> profile.id to index }.toMap()
devices.primaryByTier(profileOrder)
}.replayingShare(appScope)
private val reportedPersistFailures = mutableSetOf<String>()
private suspend fun persistLiveDevices(devices: List<PodDevice>) {
@@ -5,8 +5,13 @@ package eu.darken.capod.monitor.core
* lower rank = higher priority. System-connected devices come first, then
* any live device (BLE or AAP), then profiled-but-offline.
*
* Distinct from [DeviceMonitor.primaryDevice] which is intentionally
* non-tier-ranked for reaction flows that want "any profiled device".
* Backs [DeviceMonitor.primaryDeviceByTier], used by display surfaces (ongoing notification,
* case popup) that should follow the worn/connected pod.
*
* Distinct from [DeviceMonitor.primaryDevice], which is intentionally non-tier-ranked: address-
* and eligibility-gated reaction flows (auto-connect targets a not-yet-connected device; sleep and
* conversation match by the event's source address) must keep "first profiled device" semantics,
* which tier ranking would break.
*/
fun PodDevice.tierRank(): Int = when {
isSystemConnected -> 0
@@ -32,7 +32,6 @@ import eu.darken.capod.monitor.core.MonitorCoroutineScope
import eu.darken.capod.monitor.core.MonitorModeResolver
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.ble.BlePodMonitor
import eu.darken.capod.monitor.core.primaryDevice
import eu.darken.capod.monitor.ui.MonitorNotifications
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
@@ -211,7 +210,7 @@ class MonitorService : Service() {
return
}
val deviceFlow = deviceMonitor.primaryDevice()
val deviceFlow = deviceMonitor.primaryDeviceByTier
.setupCommonEventHandlers(TAG) { "BlePodMonitor" }
.distinctUntilChangedBy { it?.toNotificationKey() }
.throttleLatest(1000)
@@ -11,7 +11,6 @@ import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.flow.withPrevious
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.primaryDevice
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
@@ -35,7 +34,7 @@ class PopUpReaction @Inject constructor(
private val caseCoolDowns = java.util.concurrent.ConcurrentHashMap<String, Instant>()
private fun monitorCase(): Flow<Event> = deviceMonitor.primaryDevice()
private fun monitorCase(): Flow<Event> = deviceMonitor.primaryDeviceByTier
.distinctUntilChangedBy {
// Re-emit on profile changes (eligibility), raw BLE changes (content), AND the derived
// lid state. The latter is essential: caseLidState is recovered from history, so it can
@@ -122,7 +121,7 @@ class PopUpReaction @Inject constructor(
private fun monitorConnection(): Flow<Event> = combine(
bluetoothManager.connectedDevices,
deviceMonitor.primaryDevice().distinctUntilChangedBy {
deviceMonitor.primaryDeviceByTier.distinctUntilChangedBy {
Triple(it?.profileId, it?.reactions?.showPopUpOnConnection, it?.rawDataHex)
},
) { devices, broadcast ->
@@ -208,7 +207,7 @@ class PopUpReaction @Inject constructor(
* handles the normal close; a redundant Hide here is harmless ([PopUpWindow.close] is idempotent).
*/
private fun monitorCaseStaleClose(): Flow<Event> = combine(
deviceMonitor.primaryDevice(),
deviceMonitor.primaryDeviceByTier,
staleCheckTicker(),
) { device, _ -> isCaseOpenBroadcastFresh(device) }
.distinctUntilChanged()
@@ -2,6 +2,7 @@ package eu.darken.capod.monitor.core
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.bluetooth.BluetoothDevice2
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
@@ -139,6 +140,7 @@ class DeviceMonitorTest : BaseTest() {
aap: Map<BluetoothAddress, AapPodState> = emptyMap(),
cache: Map<String, CachedDeviceState> = emptyMap(),
profiles: List<DeviceProfile> = emptyList(),
connected: List<BluetoothAddress> = emptyList(),
scope: CoroutineScope,
): DeviceMonitor {
val blePodMonitor: BlePodMonitor = mockk {
@@ -158,7 +160,9 @@ class DeviceMonitorTest : BaseTest() {
}
val aapLifecycleManager: AapLifecycleManager = mockk(relaxed = true)
val bluetoothManager: BluetoothManager2 = mockk {
every { connectedDevices } returns MutableStateFlow(emptyList())
every { connectedDevices } returns MutableStateFlow(
connected.map { addr -> mockk<BluetoothDevice2>(relaxed = true) { every { address } returns addr } },
)
}
return DeviceMonitor(
@@ -192,6 +196,51 @@ class DeviceMonitorTest : BaseTest() {
device.isAapReady shouldBe true
}
/**
* primaryDeviceByTier must follow the worn/connected pod, not profile-list order. Both profiles
* are live (so [DeviceMonitor.devices] lists them in profile order, first-profile-first), but
* only the second profile is system-connected. The old order-only selector would pick the first
* profile; the tier-ranked selector must pick the system-connected second profile.
* Regression for the persistent notification + case popup showing a stale, out-of-range device.
*/
@Test
fun `primaryDeviceByTier prefers system-connected device over earlier-listed profile`() =
runTest(testDispatcher) {
val firstProfile = AppleDeviceProfile(
label = "First (out of range)",
model = PodModel.AIRPODS_MAX,
address = "AA:AA:AA:AA:AA:AA",
)
val secondProfile = AppleDeviceProfile(
label = "Second (worn)",
model = PodModel.AIRPODS_PRO2_USBC,
address = "BB:BB:BB:BB:BB:BB",
)
val monitor = createMonitor(
ble = listOf(
mockLiveDualBlePodWithProfile(firstProfile),
mockLiveDualBlePodWithProfile(secondProfile),
),
profiles = listOf(firstProfile, secondProfile),
connected = listOf(secondProfile.address!!),
scope = backgroundScope,
)
// Sanity: order-only selection would yield the first profile.
monitor.devices.first().first().profileId shouldBe firstProfile.id
// Collect the settled emission: connectedDevices starts with an onStart empty list, so
// the first combined value sees no system-connected device. Read the last value once the
// real connected set has propagated.
var primary: PodDevice? = null
val job = backgroundScope.launch { monitor.primaryDeviceByTier.collect { primary = it } }
advanceUntilIdle()
job.cancel()
// Tier-ranked selection follows the system-connected (worn) profile instead.
primary?.profileId shouldBe secondProfile.id
}
/**
* Regression test for https://github.com/d4rken-org/capod/issues/483 — settings disappeared
* when BLE went stale even though the AAP socket was still healthy.