mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
fix(monitor): Show worn device in notification and popup
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user