From 8f0ec5a37474c3e9356633d234f734bcd9b9cee8 Mon Sep 17 00:00:00 2001 From: darken Date: Sun, 2 Aug 2026 13:44:49 +0200 Subject: [PATCH] fix(overview): Signal when background monitoring is off An auto-created profile without a paired Bluetooth device resolves to MANUAL mode, so nothing runs in the background while the dashboard claimed to be monitoring. The dashboard now states that background monitoring is off and offers to pick a paired device, and the per-card banner names what a missing paired device costs. Closes #658 --- .../capod/screenshots/ScreenshotContent.kt | 2 + .../compose/preview/MockPodDataProvider.kt | 7 +- .../capod/main/ui/overview/OverviewScreen.kt | 44 +++++++++++- .../main/ui/overview/OverviewViewModel.kt | 30 ++++++++- .../cards/BackgroundMonitoringOffCard.kt | 67 +++++++++++++++++++ .../components/MissingPairedDeviceBanner.kt | 19 ++++-- app/src/main/res/values/strings.xml | 4 ++ 7 files changed, 165 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/eu/darken/capod/main/ui/overview/cards/BackgroundMonitoringOffCard.kt diff --git a/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt b/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt index 91c44049..32919de6 100644 --- a/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt +++ b/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt @@ -19,6 +19,7 @@ import eu.darken.capod.common.compose.PreviewWrapper import eu.darken.capod.common.compose.preview.MOCK_NOW import eu.darken.capod.common.compose.preview.MockPodDataProvider import eu.darken.capod.common.theming.CapodTheme +import eu.darken.capod.main.core.MonitorMode import eu.darken.capod.main.ui.overview.OverviewScreen import eu.darken.capod.main.ui.overview.OverviewViewModel import eu.darken.capod.monitor.core.battery.BatteryEstimate @@ -85,6 +86,7 @@ internal fun DashboardContent(showAap: Boolean = false) = PreviewWrapper { devices = devices, isDebug = false, isBluetoothEnabled = true, + effectiveMode = MonitorMode.AUTOMATIC, profiles = listOf( MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2), MockPodDataProvider.profile("AirPods Max", PodModel.AIRPODS_MAX), diff --git a/app/src/main/java/eu/darken/capod/common/compose/preview/MockPodDataProvider.kt b/app/src/main/java/eu/darken/capod/common/compose/preview/MockPodDataProvider.kt index a0315a7c..18b8cfde 100644 --- a/app/src/main/java/eu/darken/capod/common/compose/preview/MockPodDataProvider.kt +++ b/app/src/main/java/eu/darken/capod/common/compose/preview/MockPodDataProvider.kt @@ -149,9 +149,14 @@ object MockPodDataProvider { // --- Profile helpers --- - fun profile(label: String, model: PodModel): AppleDeviceProfile = AppleDeviceProfile( + fun profile( + label: String, + model: PodModel, + address: String? = "AA:BB:CC:DD:EE:FF", + ): AppleDeviceProfile = AppleDeviceProfile( label = label, model = model, + address = address, ) // --- Dual pod with Apple keys --- 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 331a74df..c51b1acb 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 @@ -53,6 +53,8 @@ import eu.darken.capod.common.error.ErrorEventHandler import eu.darken.capod.common.navigation.NavigationEventHandler import eu.darken.capod.common.permissions.Permission import eu.darken.capod.common.upgrade.UpgradeRepo +import eu.darken.capod.main.core.MonitorMode +import eu.darken.capod.main.ui.overview.cards.BackgroundMonitoringOffCard import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledCard import eu.darken.capod.main.ui.overview.cards.DeviceLimitUpgradeCard import eu.darken.capod.main.ui.overview.cards.DualPodsCard @@ -186,6 +188,9 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) { onToggleDeviceExpansion = { device -> device.profileId?.let { vm.toggleDeviceExpansion(it) } }, + onSetupPairedDevice = { + currentState.soleProfileId?.let { vm.goToEditProfile(it) } ?: vm.goToDeviceManager() + }, ) } @@ -204,6 +209,7 @@ fun OverviewScreen( onDeviceSettings: (PodDevice) -> Unit = {}, onEditProfile: (PodDevice) -> Unit = {}, onToggleDeviceExpansion: (PodDevice) -> Unit = {}, + onSetupPairedDevice: () -> Unit = {}, ) { Scaffold( topBar = { @@ -308,6 +314,12 @@ fun OverviewScreen( // 4. Profiled device cards (limited to 1 for free users) if (!state.isScanBlocked && state.isBluetoothEnabled) { + if (state.monitoringStatus == OverviewViewModel.MonitoringStatus.BACKGROUND_OFF) { + item(key = "monitoring_off") { + BackgroundMonitoringOffCard(onSetupPairedDevice = onSetupPairedDevice) + } + } + if (state.showReactionsHint && state.visibleProfiledDevices.isNotEmpty()) { item(key = "reactions_hint") { ReactionsMovedHintCard() @@ -362,7 +374,7 @@ fun OverviewScreen( } // 5. Monitoring active card - if (state.profiles.isNotEmpty() && state.profiledDevices.isEmpty() && !state.shouldShowUnmatchedSection) { + if (state.monitoringStatus == OverviewViewModel.MonitoringStatus.SEARCHING) { item(key = "monitoring_active") { MonitoringActiveCard() } @@ -453,6 +465,7 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper { ), isDebug = false, isBluetoothEnabled = true, + effectiveMode = MonitorMode.AUTOMATIC, profiles = listOf( MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2), MockPodDataProvider.profile("AirPods Max", PodModel.AIRPODS_MAX), @@ -490,6 +503,7 @@ private fun OverviewScreenEmptyPreview() = PreviewWrapper { devices = emptyList(), isDebug = false, isBluetoothEnabled = true, + effectiveMode = MonitorMode.AUTOMATIC, profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)), upgradeInfo = MockPodDataProvider.fossInfo(), showUnmatchedDevices = false, @@ -513,6 +527,7 @@ private fun OverviewScreenNoProfilesPreview() = PreviewWrapper { devices = emptyList(), isDebug = false, isBluetoothEnabled = true, + effectiveMode = MonitorMode.MANUAL, profiles = emptyList(), upgradeInfo = MockPodDataProvider.gplayInfo(), showUnmatchedDevices = false, @@ -536,6 +551,7 @@ private fun OverviewScreenBluetoothOffPreview() = PreviewWrapper { devices = emptyList(), isDebug = false, isBluetoothEnabled = false, + effectiveMode = MonitorMode.AUTOMATIC, profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)), upgradeInfo = MockPodDataProvider.fossInfo(isPro = true), showUnmatchedDevices = false, @@ -549,6 +565,32 @@ private fun OverviewScreenBluetoothOffPreview() = PreviewWrapper { ) } +@Preview2 +@Composable +private fun OverviewScreenMonitoringOffPreview() = PreviewWrapper { + OverviewScreen( + state = OverviewViewModel.State( + now = SystemTimeSource.now(), + permissions = emptySet(), + devices = emptyList(), + isDebug = false, + isBluetoothEnabled = true, + effectiveMode = MonitorMode.MANUAL, + profiles = listOf( + MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2, address = null), + ), + upgradeInfo = MockPodDataProvider.fossInfo(), + showUnmatchedDevices = false, + ), + onRequestPermission = {}, + onBluetoothSettings = {}, + onManageDevices = {}, + onSettings = {}, + onUpgrade = {}, + onToggleUnmatched = {}, + ) +} + @Composable private fun ToolbarTitle(upgradeInfo: UpgradeRepo.Info) { val appName = stringResource(R.string.app_name) 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 b431b66e..20b32fce 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 @@ -33,6 +33,7 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import eu.darken.capod.profiles.core.DeviceProfile import eu.darken.capod.profiles.core.DeviceProfilesRepo +import eu.darken.capod.profiles.core.ProfileId import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.catch @@ -90,6 +91,7 @@ class OverviewViewModel @Inject constructor( val hideUnmatchedDevices: Boolean, val showTroubleshootSuggestion: Boolean, val batteryEstimates: Map, + val effectiveMode: MonitorMode, ) /** @@ -126,12 +128,14 @@ class OverviewViewModel @Inject constructor( generalSettings.hideUnmatchedDevices.flow, troubleshootSuggestion, batteryEstimator.estimates, - ) { reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion, batteryEstimates -> + monitorModeResolver.effectiveMode, + ) { reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion, batteryEstimates, effectiveMode -> OverviewUiSettings( reactionsHintDismissed = reactionsHintDismissed, hideUnmatchedDevices = hideUnmatched, showTroubleshootSuggestion = showTroubleshootSuggestion, batteryEstimates = batteryEstimates, + effectiveMode = effectiveMode, ) } @@ -214,6 +218,7 @@ class OverviewViewModel @Inject constructor( devices = devices, isDebug = isDebug, isBluetoothEnabled = isBluetoothEnabled, + effectiveMode = uiSettings.effectiveMode, profiles = profiles, upgradeInfo = upgradeInfo, showUnmatchedDevices = showUnmatched, @@ -227,12 +232,15 @@ class OverviewViewModel @Inject constructor( enum class BluetoothIconState { HIDDEN, DISABLED, NEARBY, CONNECTED } + enum class MonitoringStatus { HIDDEN, SEARCHING, BACKGROUND_OFF } + data class State( val now: Instant, val permissions: Set, val devices: List, val isDebug: Boolean, val isBluetoothEnabled: Boolean, + val effectiveMode: MonitorMode, val profiles: List, val upgradeInfo: UpgradeRepo.Info, val showUnmatchedDevices: Boolean, @@ -290,6 +298,22 @@ class OverviewViewModel @Inject constructor( else -> BluetoothIconState.HIDDEN } + /** + * [MonitoringStatus.BACKGROUND_OFF] is deliberately not conditioned on the absence of device + * cards: an address-less wildcard profile can still match an unrecognised Apple payload, and + * the resulting card carries neither the missing-paired-device banner nor an edit action. + */ + val monitoringStatus: MonitoringStatus + get() = when { + isScanBlocked || !isBluetoothEnabled -> MonitoringStatus.HIDDEN + profiles.isEmpty() -> MonitoringStatus.HIDDEN + effectiveMode == MonitorMode.MANUAL -> MonitoringStatus.BACKGROUND_OFF + profiledDevices.isEmpty() && !shouldShowUnmatchedSection -> MonitoringStatus.SEARCHING + else -> MonitoringStatus.HIDDEN + } + + val soleProfileId: ProfileId? get() = profiles.singleOrNull()?.id + fun isPinned(device: PodDevice, index: Int): Boolean = device.isSystemConnected || index == 0 @@ -336,6 +360,10 @@ class OverviewViewModel @Inject constructor( fun goToEditProfile(device: PodDevice) { val profileId = device.profileId ?: return + goToEditProfile(profileId) + } + + fun goToEditProfile(profileId: ProfileId) { log(TAG, INFO) { "goToEditProfile(profileId=$profileId)" } navTo(Nav.Main.DeviceProfileCreation(profileId = profileId)) } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/BackgroundMonitoringOffCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/BackgroundMonitoringOffCard.kt new file mode 100644 index 00000000..1cf8d9e2 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/BackgroundMonitoringOffCard.kt @@ -0,0 +1,67 @@ +package eu.darken.capod.main.ui.overview.cards + +import androidx.compose.foundation.layout.Column +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.DevicesOther +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 BackgroundMonitoringOffCard(onSetupPairedDevice: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Text( + text = stringResource(R.string.overview_monitoring_off_label), + style = MaterialTheme.typography.titleMedium, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = stringResource(R.string.overview_monitoring_off_description), + style = MaterialTheme.typography.bodyMedium, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = onSetupPairedDevice, + modifier = Modifier.align(Alignment.End), + ) { + Icon( + imageVector = Icons.TwoTone.DevicesOther, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp), + ) + Text(text = stringResource(R.string.overview_monitoring_off_action)) + } + } + } +} + +@Preview2 +@Composable +private fun BackgroundMonitoringOffCardPreview() = PreviewWrapper { + BackgroundMonitoringOffCard(onSetupPairedDevice = {}) +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/MissingPairedDeviceBanner.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/MissingPairedDeviceBanner.kt index b1774b08..ebdd5ada 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/MissingPairedDeviceBanner.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/MissingPairedDeviceBanner.kt @@ -1,5 +1,6 @@ package eu.darken.capod.main.ui.overview.cards.components +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -46,12 +47,20 @@ fun MissingPairedDeviceBanner( .padding(end = 10.dp) .size(20.dp), ) - Text( - text = stringResource(R.string.overview_card_missing_paired_device), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f), + Column( modifier = Modifier.weight(1f), - ) + ) { + Text( + text = stringResource(R.string.overview_card_missing_paired_device), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f), + ) + Text( + text = stringResource(R.string.overview_card_missing_paired_device_consequence), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1d079823..ecb948dd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -225,6 +225,9 @@ Bluetooth is disabled, enable it ;) Monitoring for devices Make sure your device is nearby and active. + Background monitoring is off + No profile has a paired Bluetooth device, so CAPod only updates while the app is open. Select one to enable background monitoring, the ongoing notification and reactions. + Select paired device 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 @@ -527,6 +530,7 @@ Double press to end call Open device settings This profile has no paired Bluetooth device. + Without one, device settings and auto-connect aren\'t available. Reactions are per device Auto-play, auto-pause and pop-ups are now configured per device. Tap the settings icon on a card while your headphones are connected.