feat(overview): Suggest troubleshooter when a connected device sends no data

Adds a dashboard hint card pointing at the existing Troubleshooter when a profile is connected to the system (audio) but CAPod is receiving no live data — the symptom of a phone dropping AirPods BLE broadcasts (e.g. some HyperOS devices, #603). Debounced ~15s so it doesn't flash during the gap between an audio connection and the first broadcast, and suppressed whenever any pod is live so it never claims 'no data' while data is visibly arriving.
This commit is contained in:
Matthias Urhahn
2026-06-08 07:45:29 +02:00
committed by Matthias Urhahn
parent df2783d8a1
commit 81e2bc1dba
5 changed files with 219 additions and 1 deletions
@@ -13,6 +13,8 @@ import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.MonitorModeResolver
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.worker.MonitorControl
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import io.kotest.matchers.shouldBe
@@ -25,6 +27,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
@@ -554,4 +557,86 @@ class OverviewViewModelTest : BaseTest() {
event shouldBe Permission.BLUETOOTH
}
}
@Nested
inner class TroubleshootSuggestionTests {
private val connectedAddress = "AA:BB:CC:DD:EE:FF"
private fun connectedProfile(): DeviceProfile = AppleDeviceProfile(
label = "Test",
model = PodModel.AIRPODS_PRO2,
address = connectedAddress,
)
private fun connectedBtDevice() = mockk<BluetoothDevice2>(relaxed = true) {
every { address } returns connectedAddress
}
private fun setConnectedButNoLiveData() {
profilesFlow.value = listOf(connectedProfile())
connectedDevicesFlow.value = listOf(connectedBtDevice())
devicesFlow.value = emptyList()
}
@Test
fun `appears only after the debounce delay`() = runTest(testDispatcher) {
setConnectedButNoLiveData()
val vm = createViewModel()
var latest: OverviewViewModel.State? = null
backgroundScope.launch { vm.state.collect { latest = it } }
advanceTimeBy(14_000)
latest!!.showTroubleshootSuggestion shouldBe false
advanceTimeBy(2_000)
latest!!.showTroubleshootSuggestion shouldBe true
}
@Test
fun `suppressed while any pod is live even after the delay`() = runTest(testDispatcher) {
// #603 duplicate state: broadcasts ARE arriving (a live pod exists), so the card must
// not claim "no data" even though a profiled card may momentarily read as non-live.
profilesFlow.value = listOf(connectedProfile())
connectedDevicesFlow.value = listOf(connectedBtDevice())
devicesFlow.value = listOf(PodDevice(profileId = null, ble = mockk(relaxed = true), aap = null))
val vm = createViewModel()
var latest: OverviewViewModel.State? = null
backgroundScope.launch { vm.state.collect { latest = it } }
advanceTimeBy(20_000)
latest!!.showTroubleshootSuggestion shouldBe false
}
@Test
fun `not shown when no profile is system-connected`() = runTest(testDispatcher) {
profilesFlow.value = listOf(connectedProfile())
connectedDevicesFlow.value = emptyList()
devicesFlow.value = emptyList()
val vm = createViewModel()
var latest: OverviewViewModel.State? = null
backgroundScope.launch { vm.state.collect { latest = it } }
advanceTimeBy(20_000)
latest!!.showTroubleshootSuggestion shouldBe false
}
@Test
fun `hides immediately when live data returns before the delay`() = runTest(testDispatcher) {
setConnectedButNoLiveData()
val vm = createViewModel()
var latest: OverviewViewModel.State? = null
backgroundScope.launch { vm.state.collect { latest = it } }
advanceTimeBy(10_000)
latest!!.showTroubleshootSuggestion shouldBe false
// A broadcast arrives before the 15s window elapses — the pending suggestion is cancelled.
devicesFlow.value = listOf(PodDevice(profileId = null, ble = mockk(relaxed = true), aap = null))
advanceTimeBy(10_000)
latest!!.showTroubleshootSuggestion shouldBe false
}
}
}