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 + } + } }