From 81e2bc1dba5096e2a6b11f6c6afb4c45d7b8a67c Mon Sep 17 00:00:00 2001 From: Matthias Urhahn Date: Sun, 7 Jun 2026 14:42:52 +0200 Subject: [PATCH] feat(overview): Suggest troubleshooter when a connected device sends no data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../capod/main/ui/overview/OverviewScreen.kt | 10 +++ .../main/ui/overview/OverviewViewModel.kt | 51 ++++++++++- .../cards/TroubleshootSuggestionCard.kt | 71 ++++++++++++++++ app/src/main/res/values/strings.xml | 3 + .../main/ui/overview/OverviewViewModelTest.kt | 85 +++++++++++++++++++ 5 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/eu/darken/capod/main/ui/overview/cards/TroubleshootSuggestionCard.kt diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt index cc4614ea..3e46edc0 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt @@ -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") { diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt index 1ae8d71d..705f6152 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt @@ -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 = 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") } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/TroubleshootSuggestionCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/TroubleshootSuggestionCard.kt new file mode 100644 index 00000000..a1d87e8b --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/TroubleshootSuggestionCard.kt @@ -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 = {}) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a1026a12..13da108d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -222,6 +222,9 @@ Bluetooth is disabled, enable it ;) Monitoring for devices Make sure your device is nearby and active. + Connected, but no data + 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. + Run troubleshooter Unmatched devices %d device without matching profile diff --git a/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt index b1136bc0..a7204b90 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt @@ -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(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 + } + } }