diff --git a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt index 868b2115..11ce913a 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt @@ -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 = 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() private suspend fun persistLiveDevices(devices: List) { diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceTier.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceTier.kt index 4736040c..361414b7 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceTier.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceTier.kt @@ -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 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 b67ab13a..3527d23b 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 @@ -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) diff --git a/app/src/main/java/eu/darken/capod/reaction/core/popup/PopUpReaction.kt b/app/src/main/java/eu/darken/capod/reaction/core/popup/PopUpReaction.kt index d07e4743..ddebd9dc 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/popup/PopUpReaction.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/popup/PopUpReaction.kt @@ -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() - private fun monitorCase(): Flow = deviceMonitor.primaryDevice() + private fun monitorCase(): Flow = 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 = 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 = combine( - deviceMonitor.primaryDevice(), + deviceMonitor.primaryDeviceByTier, staleCheckTicker(), ) { device, _ -> isCaseOpenBroadcastFresh(device) } .distinctUntilChanged() diff --git a/app/src/test/java/eu/darken/capod/monitor/core/DeviceMonitorTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/DeviceMonitorTest.kt index c4b5112b..4e7a9f01 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/DeviceMonitorTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/DeviceMonitorTest.kt @@ -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 = emptyMap(), cache: Map = emptyMap(), profiles: List = emptyList(), + connected: List = 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(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.