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
@@ -61,6 +61,7 @@ import eu.darken.capod.main.ui.overview.cards.NoProfilesCard
import eu.darken.capod.main.ui.overview.cards.PermissionCard
import eu.darken.capod.main.ui.overview.cards.ReactionsMovedHintCard
import eu.darken.capod.main.ui.overview.cards.SinglePodsCard
import eu.darken.capod.main.ui.overview.cards.TroubleshootSuggestionCard
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
import eu.darken.capod.monitor.core.PodDevice
@@ -172,6 +173,7 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
},
onManageDevices = { vm.goToDeviceManager() },
onSettings = { vm.goToSettings() },
onTroubleShooter = { vm.goToTroubleShooter() },
onUpgrade = { vm.onUpgrade() },
onToggleUnmatched = { vm.toggleUnmatchedDevices() },
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
@@ -194,6 +196,7 @@ fun OverviewScreen(
onBluetoothSettings: () -> Unit,
onManageDevices: () -> Unit,
onSettings: () -> Unit,
onTroubleShooter: () -> Unit = {},
onUpgrade: () -> Unit,
onToggleUnmatched: () -> Unit,
onAncModeChange: (PodDevice, AapSetting.AncMode.Value) -> Unit = { _, _ -> },
@@ -351,6 +354,13 @@ fun OverviewScreen(
}
}
// 4c. Troubleshooter suggestion — connected via audio but no live data is reaching us
if (state.showTroubleshootSuggestion) {
item(key = "troubleshoot_suggestion") {
TroubleshootSuggestionCard(onTroubleShooter = onTroubleShooter)
}
}
// 5. Monitoring active card
if (state.profiles.isNotEmpty() && state.profiledDevices.isEmpty() && !state.shouldShowUnmatchedSection) {
item(key = "monitoring_active") {
@@ -39,6 +39,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
@@ -83,12 +84,45 @@ class OverviewViewModel @Inject constructor(
private data class OverviewUiSettings(
val reactionsHintDismissed: Boolean,
val hideUnmatchedDevices: Boolean,
val showTroubleshootSuggestion: Boolean,
)
/**
* True when a profile is connected to the system (audio) but CAPod has *no* live data for any
* device — the classic "broadcasts are being dropped by the ROM" symptom (e.g. some HyperOS
* phones, see #603). Surfaced as a hint pointing at the Troubleshooter, which can probe and
* persist a working compatibility combo. Debounced so it doesn't flash during the brief window
* between an audio connection and the first BLE broadcast. Suppressed whenever any pod is live,
* so it never claims "no data" while data is visibly arriving (incl. the #603 duplicate state).
*/
private val troubleshootSuggestion = combineFlows(
profilesRepo.profiles,
bluetoothManager.connectedDevices.onStart { emit(emptyList()) },
deviceMonitor.devices,
permissionTool.missingScanPermissions,
) { profiles, connectedDevices, devices, missingScanPermissions ->
val connectedAddresses = connectedDevices.mapTo(mutableSetOf()) { it.address }
val anyProfileConnected = profiles.any { it.address != null && it.address in connectedAddresses }
val anyLivePod = devices.any { it.isLive }
missingScanPermissions.isEmpty() && anyProfileConnected && !anyLivePod
}
.distinctUntilChanged()
.flatMapLatest { conditionMet ->
if (conditionMet) flow {
delay(TROUBLESHOOT_SUGGESTION_DELAY_MS)
emit(true)
} else flowOf(false)
}
.onStart { emit(false) }
.distinctUntilChanged()
private val overviewUiSettings = combineFlows(
generalSettings.reactionsHintDismissed.flow,
generalSettings.hideUnmatchedDevices.flow,
) { reactionsHintDismissed, hideUnmatched -> OverviewUiSettings(reactionsHintDismissed, hideUnmatched) }
troubleshootSuggestion,
) { reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion ->
OverviewUiSettings(reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion)
}
init {
// When the persistent "hide unmatched" setting is enabled, reset the in-session expand
@@ -175,6 +209,7 @@ class OverviewViewModel @Inject constructor(
userExpandedIds = prunedExpandedIds,
showReactionsHint = hadLegacyReactionData && !uiSettings.reactionsHintDismissed,
hideUnmatchedDevices = uiSettings.hideUnmatchedDevices,
showTroubleshootSuggestion = uiSettings.showTroubleshootSuggestion,
)
}.asLiveState()
@@ -192,6 +227,7 @@ class OverviewViewModel @Inject constructor(
val userExpandedIds: Set<String> = emptySet(),
val showReactionsHint: Boolean = false,
val hideUnmatchedDevices: Boolean = false,
val showTroubleshootSuggestion: Boolean = false,
) {
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
@@ -255,6 +291,11 @@ class OverviewViewModel @Inject constructor(
navTo(Nav.Settings.Index)
}
fun goToTroubleShooter() {
log(TAG, INFO) { "goToTroubleShooter()" }
navTo(Nav.Main.TroubleShooter)
}
fun goToDeviceManager() {
log(TAG, INFO) { "goToDeviceManager()" }
navTo(Nav.Main.DeviceManager)
@@ -315,6 +356,14 @@ class OverviewViewModel @Inject constructor(
companion object {
private const val FREE_DEVICE_LIMIT = 1
/**
* How long the "connected but no live data" condition must hold before the troubleshooter
* hint appears, so it doesn't flash during the gap between an audio connection and the first
* BLE broadcast.
*/
private const val TROUBLESHOOT_SUGGESTION_DELAY_MS = 15_000L
private val TAG = logTag("Overview", "VM")
}
}
@@ -0,0 +1,71 @@
package eu.darken.capod.main.ui.overview.cards
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Troubleshoot
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
@Composable
fun TroubleshootSuggestionCard(onTroubleShooter: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.TwoTone.Troubleshoot,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(end = 8.dp),
)
Text(
text = stringResource(R.string.overview_troubleshoot_suggestion_label),
style = MaterialTheme.typography.titleMedium,
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.overview_troubleshoot_suggestion_description),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onTroubleShooter,
modifier = Modifier.align(Alignment.End),
) {
Text(text = stringResource(R.string.overview_troubleshoot_suggestion_action))
}
}
}
}
@Preview2
@Composable
private fun TroubleshootSuggestionCardPreview() = PreviewWrapper {
TroubleshootSuggestionCard(onTroubleShooter = {})
}
+3
View File
@@ -222,6 +222,9 @@
<string name="overview_bluetooth_disabled_description">Bluetooth is disabled, enable it ;)</string>
<string name="overview_monitoring_active_label">Monitoring for devices</string>
<string name="overview_monitoring_active_description">Make sure your device is nearby and active.</string>
<string name="overview_troubleshoot_suggestion_label">Connected, but no data</string>
<string name="overview_troubleshoot_suggestion_description">Your device is connected, but CAPod isn\'t receiving any live data from it. Your phone may need a compatibility option — the troubleshooter can try to find one automatically.</string>
<string name="overview_troubleshoot_suggestion_action">Run troubleshooter</string>
<string name="overview_unmatched_devices_label">Unmatched devices</string>
<plurals name="overview_unmatched_devices_count">
<item quantity="one">%d device without matching profile</item>
@@ -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
}
}
}