From e91243e577bca3a679864d44637fe251c5540715 Mon Sep 17 00:00:00 2001 From: darken Date: Wed, 1 Apr 2026 21:33:48 +0200 Subject: [PATCH 1/9] feat: Add unified device state cache for persistent battery display Replace PodDeviceCache (raw BLE scan bytes) with DeviceStateCache that stores decoded combined device state (battery, charging, model) per profile. Battery values persist across app restarts with per-slot timestamps. Cached-only cards appear for offline devices with muted visuals and a staleness indicator. Fallback chain: AAP -> BLE -> cached. --- .../compose/preview/MockPodDataProvider.kt | 39 ++++ .../main/ui/overview/OverviewViewModel.kt | 4 +- .../main/ui/overview/cards/DualPodsCard.kt | 23 +- .../ui/overview/cards/PodCardComponents.kt | 63 +++--- .../main/ui/overview/cards/SinglePodsCard.kt | 23 +- .../capod/monitor/core/BlePodMonitor.kt | 26 --- .../capod/monitor/core/CachedDeviceState.kt | 31 +++ .../capod/monitor/core/DeviceMonitor.kt | 65 ++++-- .../monitor/core/DeviceMonitorExtensions.kt | 25 ++- .../capod/monitor/core/DeviceStateCache.kt | 118 ++++++++++ .../eu/darken/capod/monitor/core/PodDevice.kt | 55 +++-- .../capod/monitor/core/PodDeviceCache.kt | 84 -------- .../monitor/core/worker/MonitorService.kt | 7 + .../capod/profiles/core/DeviceProfilesRepo.kt | 7 +- .../reaction/core/DeviceStatePersister.kt | 92 ++++++++ app/src/main/res/values/strings.xml | 1 + .../main/ui/overview/OverviewViewModelTest.kt | 26 +-- .../capod/monitor/core/PodDeviceCacheTest.kt | 201 ++++++++++++++++++ .../capod/monitor/core/PodDeviceTest.kt | 80 +++---- .../reaction/core/DeviceStatePersisterTest.kt | 163 ++++++++++++++ 20 files changed, 902 insertions(+), 231 deletions(-) create mode 100644 app/src/main/java/eu/darken/capod/monitor/core/CachedDeviceState.kt create mode 100644 app/src/main/java/eu/darken/capod/monitor/core/DeviceStateCache.kt delete mode 100644 app/src/main/java/eu/darken/capod/monitor/core/PodDeviceCache.kt create mode 100644 app/src/main/java/eu/darken/capod/reaction/core/DeviceStatePersister.kt create mode 100644 app/src/test/java/eu/darken/capod/monitor/core/PodDeviceCacheTest.kt create mode 100644 app/src/test/java/eu/darken/capod/reaction/core/DeviceStatePersisterTest.kt 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 1bc1a2c4..ee36728e 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 @@ -4,6 +4,7 @@ import android.content.Context import eu.darken.capod.R import eu.darken.capod.common.bluetooth.BleScanResult import eu.darken.capod.common.upgrade.UpgradeRepo +import eu.darken.capod.monitor.core.CachedDeviceState import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.pods.core.apple.aap.AapPodState import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot @@ -165,35 +166,73 @@ object MockPodDataProvider { // --- PodDevice wrappers --- fun dualPodMonitored(): PodDevice = PodDevice( + profileId = "preview-dual", ble = airPodsProFullCharge(), aap = null, ) fun dualPodMonitoredMixed(): PodDevice = PodDevice( + profileId = "preview-dual-mixed", ble = airPodsProMixed(), aap = null, ) fun dualPodMonitoredWithKeys(): PodDevice = PodDevice( + profileId = "preview-dual-keys", ble = airPodsProWithKeys(), aap = null, ) fun dualPodMonitoredWithAap(): PodDevice = PodDevice( + profileId = "preview-dual-aap", ble = airPodsProWithKeys(), aap = AapPodState(connectionState = AapPodState.ConnectionState.READY), ) fun singlePodMonitored(): PodDevice = PodDevice( + profileId = "preview-single", ble = airPodsMax(), aap = null, ) fun unknownMonitored(): PodDevice = PodDevice( + profileId = null, ble = unknownDevice(), aap = null, ) + /** Cached-only dual pod — device fully offline, showing last known state. */ + fun dualPodCachedOnly(): PodDevice = PodDevice( + profileId = "preview-cached", + ble = null, + aap = null, + cached = CachedDeviceState( + profileId = "preview-cached", + model = PodModel.AIRPODS_PRO2, + address = "AA:BB:CC:DD:EE:FF", + left = CachedDeviceState.CachedBatterySlot(0.65f, MOCK_NOW.minusSeconds(3600)), + right = CachedDeviceState.CachedBatterySlot(0.50f, MOCK_NOW.minusSeconds(3600)), + case = CachedDeviceState.CachedBatterySlot(0.80f, MOCK_NOW.minusSeconds(3600)), + isLeftCharging = false, + isRightCharging = false, + isCaseCharging = false, + lastSeenAt = MOCK_NOW.minusSeconds(3600), + ), + ) + + /** Cached-only single pod — device fully offline, showing last known state. */ + fun singlePodCachedOnly(): PodDevice = PodDevice( + profileId = "preview-cached-single", + ble = null, + aap = null, + cached = CachedDeviceState( + profileId = "preview-cached-single", + model = PodModel.AIRPODS_MAX, + headset = CachedDeviceState.CachedBatterySlot(0.40f, MOCK_NOW.minusSeconds(7200)), + lastSeenAt = MOCK_NOW.minusSeconds(7200), + ), + ) + // --- UpgradeInfo --- fun fossInfo(isPro: Boolean = false): UpgradeRepo.Info = MockUpgradeInfo( 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 dfcc07e9..ebbb0e14 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 @@ -134,8 +134,8 @@ class OverviewViewModel @Inject constructor( val showUnmatchedDevices: Boolean, ) { val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking } - val profiledDevices: List get() = devices.filter { it.meta?.profile != null } - val unmatchedDevices: List get() = devices.filter { it.meta?.profile == null } + val profiledDevices: List get() = devices.filter { it.profileId != null } + val unmatchedDevices: List get() = devices.filter { it.profileId == null } } fun onPermissionResult(@Suppress("UNUSED_PARAMETER") granted: Boolean) { diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt index e7e55960..1038e92c 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt @@ -31,6 +31,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -42,6 +43,7 @@ import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.PreviewWrapper import eu.darken.capod.common.compose.preview.MockPodDataProvider import eu.darken.capod.monitor.core.PodDevice +import eu.darken.capod.monitor.core.cachedBatteryFormatted import eu.darken.capod.monitor.core.firstSeenFormatted import eu.darken.capod.monitor.core.getSignalQuality import eu.darken.capod.monitor.core.lastSeenFormatted @@ -74,7 +76,9 @@ fun DualPodsCard( elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp), ) { Column( - modifier = Modifier.padding(16.dp), + modifier = Modifier + .padding(16.dp) + .then(if (!device.isLive) Modifier.alpha(0.7f) else Modifier), ) { // Header Row( @@ -120,6 +124,7 @@ fun DualPodsCard( signalText = device.getSignalQuality(context), bleKeyState = device.bleKeyState, isAapConnected = device.isAapConnected, + isLive = device.isLive, ) } @@ -191,6 +196,16 @@ fun DualPodsCard( } } + // Cached battery indicator + if (device.isBatteryCached) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.battery_cached_label, device.cachedBatteryFormatted(now)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + // Connection state val stateDetection = device.ble as? HasStateDetection if (stateDetection != null) { @@ -415,3 +430,9 @@ private fun DualPodsCardWithKeysPreview() = PreviewWrapper { private fun DualPodsCardWithAapPreview() = PreviewWrapper { DualPodsCard(device = MockPodDataProvider.dualPodMonitoredWithAap(), showDebug = false, now = Instant.now()) } + +@Preview2 +@Composable +private fun DualPodsCardCachedOnlyPreview() = PreviewWrapper { + DualPodsCard(device = MockPodDataProvider.dualPodCachedOnly(), showDebug = false, now = Instant.now()) +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PodCardComponents.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PodCardComponents.kt index d37ba160..07179062 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PodCardComponents.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PodCardComponents.kt @@ -26,6 +26,7 @@ import androidx.compose.material.icons.twotone.KeyboardVoice import androidx.compose.material.icons.outlined.Key import androidx.compose.material.icons.twotone.Bluetooth import androidx.compose.material.icons.twotone.Key +import androidx.compose.material.icons.twotone.LinkOff import androidx.compose.material.icons.twotone.SettingsInputAntenna import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -163,6 +164,7 @@ fun SignalBadge( signalText: String, bleKeyState: BleKeyState = BleKeyState.NONE, isAapConnected: Boolean = false, + isLive: Boolean = true, modifier: Modifier = Modifier, ) { Surface( @@ -174,39 +176,48 @@ fun SignalBadge( modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically, ) { - if (bleKeyState != BleKeyState.NONE) { + if (!isLive) { Icon( - imageVector = if (bleKeyState == BleKeyState.IRK_AND_ENCRYPTED) Icons.TwoTone.Key else Icons.Outlined.Key, - contentDescription = stringResource( - if (bleKeyState == BleKeyState.IRK_AND_ENCRYPTED) R.string.signal_badge_key_encrypted_cd - else R.string.signal_badge_key_irk_cd - ), + imageVector = Icons.TwoTone.LinkOff, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + if (bleKeyState != BleKeyState.NONE) { + Icon( + imageVector = if (bleKeyState == BleKeyState.IRK_AND_ENCRYPTED) Icons.TwoTone.Key else Icons.Outlined.Key, + contentDescription = stringResource( + if (bleKeyState == BleKeyState.IRK_AND_ENCRYPTED) R.string.signal_badge_key_encrypted_cd + else R.string.signal_badge_key_irk_cd + ), + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(3.dp)) + } + if (isAapConnected) { + Icon( + imageVector = Icons.TwoTone.Bluetooth, + contentDescription = stringResource(R.string.signal_badge_aap_cd), + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(3.dp)) + } + Icon( + imageVector = Icons.TwoTone.SettingsInputAntenna, + contentDescription = null, modifier = Modifier.size(12.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.width(3.dp)) - } - if (isAapConnected) { - Icon( - imageVector = Icons.TwoTone.Bluetooth, - contentDescription = stringResource(R.string.signal_badge_aap_cd), - modifier = Modifier.size(12.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, + Text( + text = signalText, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Spacer(modifier = Modifier.width(3.dp)) } - Icon( - imageVector = Icons.TwoTone.SettingsInputAntenna, - contentDescription = null, - modifier = Modifier.size(12.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.width(3.dp)) - Text( - text = signalText, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) } } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt index cc7f3386..faba3ffb 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt @@ -31,6 +31,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -42,6 +43,7 @@ import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.PreviewWrapper import eu.darken.capod.common.compose.preview.MockPodDataProvider import eu.darken.capod.monitor.core.PodDevice +import eu.darken.capod.monitor.core.cachedBatteryFormatted import eu.darken.capod.monitor.core.firstSeenFormatted import eu.darken.capod.monitor.core.getSignalQuality import eu.darken.capod.monitor.core.lastSeenFormatted @@ -82,7 +84,9 @@ fun SinglePodsCard( elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp), ) { Column( - modifier = Modifier.padding(16.dp), + modifier = Modifier + .padding(16.dp) + .then(if (!device.isLive) Modifier.alpha(0.7f) else Modifier), ) { // Header Row( @@ -117,6 +121,7 @@ fun SinglePodsCard( signalText = device.getSignalQuality(context), bleKeyState = device.bleKeyState, isAapConnected = device.isAapConnected, + isLive = device.isLive, ) } @@ -214,6 +219,16 @@ fun SinglePodsCard( } } + // Cached battery indicator + if (device.isBatteryCached) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.battery_cached_label, device.cachedBatteryFormatted(now)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + // ANC mode selector val ancMode = device.ancMode if (device.isAapConnected && device.hasAncControl && ancMode != null) { @@ -245,3 +260,9 @@ private fun SinglePodsCardPreview() = PreviewWrapper { private fun SinglePodsCardDebugPreview() = PreviewWrapper { SinglePodsCard(device = MockPodDataProvider.singlePodMonitored(), showDebug = true, now = Instant.now()) } + +@Preview2 +@Composable +private fun SinglePodsCardCachedOnlyPreview() = PreviewWrapper { + SinglePodsCard(device = MockPodDataProvider.singlePodCachedOnly(), showDebug = false, now = Instant.now()) +} diff --git a/app/src/main/java/eu/darken/capod/monitor/core/BlePodMonitor.kt b/app/src/main/java/eu/darken/capod/monitor/core/BlePodMonitor.kt index 6eee861f..7def1e7e 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/BlePodMonitor.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/BlePodMonitor.kt @@ -46,7 +46,6 @@ class BlePodMonitor @Inject constructor( private val generalSettings: GeneralSettings, bluetoothManager: BluetoothManager2, private val debugSettings: DebugSettings, - private val podDeviceCache: PodDeviceCache, permissionTool: PermissionTool, private val profilesRepo: DeviceProfilesRepo, ) { @@ -183,34 +182,9 @@ class BlePodMonitor @Inject constructor( pods[it.identifier] = it } - newPods - .mapNotNull { - val profileId = it.device.meta.profile?.id ?: return@mapNotNull null - profileId to it.device.scanResult - } - .toMap() - .run { podDeviceCache.saveAll(this) } return pods } - suspend fun getDeviceForProfile(profileId: String): BlePodSnapshot? { - log(TAG) { "getDeviceForProfile(profileId=$profileId)" } - - val liveDevice = devices.firstOrNull()?.firstOrNull { device -> - device.meta.profile?.id == profileId - } - if (liveDevice != null) { - log(TAG) { "Found live device for profile $profileId: $liveDevice" } - return liveDevice - } - - val cachedDevice = podDeviceCache.load(profileId)?.let { - podFactory.createPod(it)?.device - } - log(TAG) { "Cached device for profile $profileId: $cachedDevice" } - return cachedDevice - } - companion object { private val TAG = logTag("Monitor", "PodMonitor") } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/CachedDeviceState.kt b/app/src/main/java/eu/darken/capod/monitor/core/CachedDeviceState.kt new file mode 100644 index 00000000..8df3b3f9 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/monitor/core/CachedDeviceState.kt @@ -0,0 +1,31 @@ +package eu.darken.capod.monitor.core + +import eu.darken.capod.common.serialization.InstantEpochMillisSerializer +import eu.darken.capod.pods.core.apple.PodModel +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.time.Instant + +@Serializable +data class CachedDeviceState( + @SerialName("profileId") val profileId: String, + @SerialName("model") val model: PodModel, + @SerialName("address") val address: String? = null, + @SerialName("left") val left: CachedBatterySlot? = null, + @SerialName("right") val right: CachedBatterySlot? = null, + @SerialName("case") val case: CachedBatterySlot? = null, + @SerialName("headset") val headset: CachedBatterySlot? = null, + @SerialName("isLeftCharging") val isLeftCharging: Boolean? = null, + @SerialName("isRightCharging") val isRightCharging: Boolean? = null, + @SerialName("isCaseCharging") val isCaseCharging: Boolean? = null, + @SerialName("isHeadsetCharging") val isHeadsetCharging: Boolean? = null, + @Serializable(with = InstantEpochMillisSerializer::class) + @SerialName("lastSeenAt") val lastSeenAt: Instant, +) { + @Serializable + data class CachedBatterySlot( + @SerialName("percent") val percent: Float, + @Serializable(with = InstantEpochMillisSerializer::class) + @SerialName("updatedAt") val updatedAt: Instant, + ) +} diff --git a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt index 800bf90c..919a9654 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt @@ -3,6 +3,7 @@ package eu.darken.capod.monitor.core import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.pods.core.apple.aap.AapConnectionManager +import eu.darken.capod.profiles.core.DeviceProfilesRepo import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.firstOrNull @@ -11,34 +12,66 @@ import javax.inject.Singleton /** * Single merge point: combines BLE scan data ([BlePodMonitor]) with AAP connection data - * ([AapConnectionManager]) into unified [PodDevice] objects. + * ([AapConnectionManager]) and cached device state ([DeviceStateCache]) into unified [PodDevice] objects. * + * Includes cached-only devices for profiles that have cached state but no live BLE data. * ViewModels should observe [devices] instead of accessing BlePodMonitor directly. */ @Singleton class DeviceMonitor @Inject constructor( private val blePodMonitor: BlePodMonitor, private val aapManager: AapConnectionManager, + private val deviceStateCache: DeviceStateCache, + private val profilesRepo: DeviceProfilesRepo, ) { - val devices: Flow> = blePodMonitor.devices - .combine(aapManager.allStates) { pods, aapStates -> - pods.map { pod -> - // AAP connections are keyed by bonded BR/EDR address (from profile), - // BLE scans use rotating RPAs. Bridge via the profile's bonded address. - val bondedAddress = pod.meta?.profile?.address - PodDevice( - ble = pod, - aap = bondedAddress?.let { aapStates[it] }, - ) - } + val devices: Flow> = combine( + blePodMonitor.devices, + aapManager.allStates, + deviceStateCache.cachedStates, + profilesRepo.profiles, + ) { pods, aapStates, cachedStates, profiles -> + // Live devices — BLE + AAP + cached fallback for missing fields + val liveDevices = pods.map { pod -> + val bondedAddress = pod.meta?.profile?.address + val profileId = pod.meta?.profile?.id + PodDevice( + profileId = profileId, + ble = pod, + aap = bondedAddress?.let { aapStates[it] }, + cached = profileId?.let { cachedStates[it] }, + ) } + // Cached-only devices — profiles with cache but no live BLE + val liveProfileIds = liveDevices.mapNotNull { it.profileId }.toSet() + val cachedOnlyDevices = profiles + .filter { it.id !in liveProfileIds } + .mapNotNull { profile -> + cachedStates[profile.id]?.let { + PodDevice(profileId = profile.id, ble = null, aap = null, cached = it) + } + } + + liveDevices + cachedOnlyDevices + } + suspend fun getDeviceForProfile(profileId: String): PodDevice? { log(TAG) { "getDeviceForProfile(profileId=$profileId)" } - val bleDevice = blePodMonitor.getDeviceForProfile(profileId) ?: return null - val bondedAddress = bleDevice.meta?.profile?.address - val aapState = bondedAddress?.let { aapManager.allStates.firstOrNull()?.get(it) } - return PodDevice(ble = bleDevice, aap = aapState) + + val liveDevice = devices.firstOrNull()?.firstOrNull { it.profileId == profileId } + if (liveDevice != null) { + log(TAG) { "Found live device for profile $profileId" } + return liveDevice + } + + val cached = deviceStateCache.load(profileId) + if (cached != null) { + log(TAG) { "Found cached state for profile $profileId" } + return PodDevice(profileId = profileId, ble = null, aap = null, cached = cached) + } + + log(TAG) { "No device found for profile $profileId" } + return null } companion object { diff --git a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitorExtensions.kt b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitorExtensions.kt index 0617c1d9..04f8567d 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitorExtensions.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitorExtensions.kt @@ -9,7 +9,7 @@ import java.time.Instant import kotlin.math.roundToInt fun DeviceMonitor.devicesWithProfiles(): Flow> = devices - .map { devices -> devices.filter { it.meta?.profile != null } } + .map { devices -> devices.filter { it.profileId != null } } fun DeviceMonitor.primaryDevice(): Flow = devicesWithProfiles().map { it.firstOrNull() } @@ -47,3 +47,26 @@ fun PodDevice.firstSeenFormatted(now: Instant): String { RelativeDateTimeFormatter.RelativeUnit.MINUTES ) } + +fun PodDevice.cachedBatteryFormatted(now: Instant): String { + val cachedAt = cachedBatteryAt ?: return "" + val formatter = RelativeDateTimeFormatter.getInstance() + val duration = Duration.between(cachedAt, now) + return when { + duration > Duration.ofHours(1) -> formatter.format( + duration.toHours().toDouble(), + RelativeDateTimeFormatter.Direction.LAST, + RelativeDateTimeFormatter.RelativeUnit.HOURS + ) + duration > Duration.ofMinutes(1) -> formatter.format( + duration.toMinutes().toDouble(), + RelativeDateTimeFormatter.Direction.LAST, + RelativeDateTimeFormatter.RelativeUnit.MINUTES + ) + else -> formatter.format( + duration.seconds.toDouble(), + RelativeDateTimeFormatter.Direction.LAST, + RelativeDateTimeFormatter.RelativeUnit.SECONDS + ) + } +} diff --git a/app/src/main/java/eu/darken/capod/monitor/core/DeviceStateCache.kt b/app/src/main/java/eu/darken/capod/monitor/core/DeviceStateCache.kt new file mode 100644 index 00000000..64fdc6d0 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/monitor/core/DeviceStateCache.kt @@ -0,0 +1,118 @@ +package eu.darken.capod.monitor.core + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import eu.darken.capod.common.coroutine.AppScope +import eu.darken.capod.common.coroutine.DispatcherProvider +import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR +import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.capod.common.debug.logging.asLog +import eu.darken.capod.common.debug.logging.log +import eu.darken.capod.common.debug.logging.logTag +import eu.darken.capod.common.serialization.SerializationCapod +import eu.darken.capod.profiles.core.ProfileId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DeviceStateCache @Inject constructor( + @ApplicationContext private val context: Context, + @AppScope private val appScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, + @SerializationCapod private val json: Json, +) { + private val cacheDir by lazy { + File(context.filesDir, "device_state_cache").apply { mkdirs() } + } + private val lock = Mutex() + + private val _cachedStates = MutableStateFlow>(emptyMap()) + val cachedStates: StateFlow> = _cachedStates + + init { + appScope.launch { loadAll() } + } + + private suspend fun loadAll() = withContext(dispatcherProvider.IO) { + lock.withLock { + val dir = cacheDir + if (!dir.exists()) return@withLock + + val loaded = mutableMapOf() + dir.listFiles()?.filter { it.name.startsWith("profile_") && it.name.endsWith(".json") }?.forEach { file -> + val profileId = file.name.removePrefix("profile_").removeSuffix(".json") + try { + val state = json.decodeFromString(file.readText()) + loaded[profileId] = state + } catch (e: Exception) { + log(TAG, ERROR) { "Failed to load cached state from ${file.name}: ${e.asLog()}, deleting" } + file.delete() + } + } + log(TAG, VERBOSE) { "loadAll(): loaded ${loaded.size} entries" } + _cachedStates.value = loaded + } + } + + private fun ProfileId.toCacheFile(): File = File(cacheDir, "profile_${this}.json") + + suspend fun save(id: ProfileId, state: CachedDeviceState) = withContext(dispatcherProvider.IO) { + lock.withLock { + log(TAG, VERBOSE) { "save(id=$id)" } + val file = id.toCacheFile() + try { + file.writeText(json.encodeToString(CachedDeviceState.serializer(), state)) + _cachedStates.value += (id to state) + } catch (e: Exception) { + log(TAG, ERROR) { "Failed to save state for $id: ${e.asLog()}" } + file.delete() + } + } + } + + suspend fun load(id: ProfileId): CachedDeviceState? = withContext(dispatcherProvider.IO) { + lock.withLock { + val cached = _cachedStates.value[id] + if (cached != null) return@withContext cached + + val file = id.toCacheFile() + if (!file.exists()) return@withContext null + try { + json.decodeFromString(file.readText()) + } catch (e: Exception) { + log(TAG, ERROR) { "Failed to load state for $id: ${e.asLog()}, deleting" } + file.delete() + null + } + } + } + + suspend fun delete(id: ProfileId) = withContext(dispatcherProvider.IO) { + lock.withLock { + log(TAG, VERBOSE) { "delete(id=$id)" } + id.toCacheFile().delete() + _cachedStates.value -= id + } + } + + suspend fun deleteAll() = withContext(dispatcherProvider.IO) { + lock.withLock { + log(TAG, VERBOSE) { "deleteAll()" } + cacheDir.listFiles()?.forEach { it.delete() } + _cachedStates.value = emptyMap() + } + } + + companion object { + private val TAG = logTag("Monitor", "DeviceStateCache") + } +} diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt index fde09bff..74ea4294 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt @@ -28,18 +28,22 @@ import java.time.Instant */ @Stable data class PodDevice( + val profileId: String?, internal val ble: BlePodSnapshot?, internal val aap: AapPodState?, + internal val cached: CachedDeviceState? = null, ) { - // Identity - val model: PodModel get() = ble?.model ?: PodModel.UNKNOWN + val model: PodModel get() = ble?.model ?: cached?.model ?: PodModel.UNKNOWN /** Bonded BR/EDR address (from profile). Used for AAP commands. */ - val address: BluetoothAddress? get() = ble?.meta?.profile?.address + val address: BluetoothAddress? get() = ble?.meta?.profile?.address ?: cached?.address /** BLE scan address (RPA, rotates). */ val bleAddress: BluetoothAddress? get() = ble?.address val identifier: BlePodSnapshot.Id? get() = ble?.identifier val meta: BlePodSnapshot.Meta? get() = ble?.meta + /** True when at least one live data source (BLE or AAP) is present. */ + val isLive: Boolean get() = ble != null || aap != null + // Capabilities from Model.Features val hasCase: Boolean get() = model.features.hasCase val hasDualPods: Boolean get() = model.features.hasDualPods @@ -48,7 +52,7 @@ data class PodDevice( val hasDualMicrophone: Boolean get() = ble is HasDualMicrophone // Signal / timing - val seenLastAt: Instant? get() = ble?.seenLastAt + val seenLastAt: Instant? get() = ble?.seenLastAt ?: cached?.lastSeenAt val seenFirstAt: Instant? get() = ble?.seenFirstAt val signalQuality: Float get() { @@ -70,31 +74,54 @@ data class PodDevice( } } - // Battery — AAP preferred, BLE fallback + // Battery — AAP preferred, BLE fallback, then cached val batteryLeft: Float? - get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent + get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent ?: cached?.left?.percent val batteryRight: Float? - get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent + get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent ?: cached?.right?.percent val batteryCase: Float? - get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent + get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent ?: cached?.case?.percent val batteryHeadset: Float? - get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent + get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent ?: cached?.headset?.percent - // Charging — AAP preferred, BLE fallback + /** True when at least one displayed battery value was filled from cache (not live). */ + val isBatteryCached: Boolean + get() { + if (cached == null) return false + val usedLeft = aap?.batteryLeft == null && (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent == null && cached.left != null + val usedRight = aap?.batteryRight == null && (ble as? DualBlePodSnapshot)?.batteryRightPodPercent == null && cached.right != null + val usedCase = aap?.batteryCase == null && (ble as? HasCase)?.batteryCasePercent == null && cached.case != null + val usedHeadset = aap?.batteryHeadset == null && (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent == null && cached.headset != null + return usedLeft || usedRight || usedCase || usedHeadset + } + + /** Oldest per-slot timestamp among battery values that fell through to cache. Null if all live. */ + val cachedBatteryAt: Instant? + get() { + if (cached == null) return null + return listOfNotNull( + cached.left?.updatedAt.takeIf { aap?.batteryLeft == null && (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent == null }, + cached.right?.updatedAt.takeIf { aap?.batteryRight == null && (ble as? DualBlePodSnapshot)?.batteryRightPodPercent == null }, + cached.case?.updatedAt.takeIf { aap?.batteryCase == null && (ble as? HasCase)?.batteryCasePercent == null }, + cached.headset?.updatedAt.takeIf { aap?.batteryHeadset == null && (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent == null }, + ).minOrNull() + } + + // Charging — AAP preferred, BLE fallback, then cached val isLeftPodCharging: Boolean? - get() = aap?.isLeftCharging ?: (ble as? HasChargeDetectionDual)?.isLeftPodCharging + get() = aap?.isLeftCharging ?: (ble as? HasChargeDetectionDual)?.isLeftPodCharging ?: cached?.isLeftCharging val isRightPodCharging: Boolean? - get() = aap?.isRightCharging ?: (ble as? HasChargeDetectionDual)?.isRightPodCharging + get() = aap?.isRightCharging ?: (ble as? HasChargeDetectionDual)?.isRightPodCharging ?: cached?.isRightCharging val isCaseCharging: Boolean? - get() = aap?.isCaseCharging ?: (ble as? HasCase)?.isCaseCharging + get() = aap?.isCaseCharging ?: (ble as? HasCase)?.isCaseCharging ?: cached?.isCaseCharging val isHeadsetBeingCharged: Boolean? - get() = aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged + get() = aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged ?: cached?.isHeadsetCharging // Resolved primary pod: AAP cmd 0x08 preferred, BLE bit 5 fallback. private val resolvedPrimaryPod: DualBlePodSnapshot.Pod? diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceCache.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceCache.kt deleted file mode 100644 index ca69209e..00000000 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceCache.kt +++ /dev/null @@ -1,84 +0,0 @@ -package eu.darken.capod.monitor.core - -import android.content.Context -import dagger.hilt.android.qualifiers.ApplicationContext -import eu.darken.capod.common.bluetooth.BleScanResult -import eu.darken.capod.common.coroutine.DispatcherProvider -import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR -import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE -import eu.darken.capod.common.debug.logging.asLog -import eu.darken.capod.common.debug.logging.log -import eu.darken.capod.common.debug.logging.logTag -import eu.darken.capod.common.serialization.SerializationCapod -import eu.darken.capod.profiles.core.ProfileId -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json -import java.io.File -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class PodDeviceCache @Inject constructor( - @ApplicationContext private val context: Context, - private val dispatcherProvider: DispatcherProvider, - @SerializationCapod private val json: Json, -) { - private val cacheDir by lazy { - File(context.cacheDir, "device_cache").apply { mkdirs() } - } - private val lock = Mutex() - - private fun ProfileId.toCacheFile(): File = File(cacheDir, "profile_${this}.json") - - suspend fun load(id: ProfileId): BleScanResult? = withContext(dispatcherProvider.IO) { - log(TAG, VERBOSE) { "load(id=$id)" } - val cacheFile = id.toCacheFile() - lock.withLock { - if (!cacheFile.exists()) return@withLock null - try { - val raw = cacheFile.readText() - json.decodeFromString(raw) - } catch (e: Exception) { - log(TAG, ERROR) { "Failed to read profile $id device: ${e.asLog()}, deleting corrupted cache file" } - cacheFile.delete() - null - } - } - } - - suspend fun saveAll(data: Map) { - log(TAG, VERBOSE) { "saveAll(): ${data.size} entries" } - lock.withLock { - data.forEach { (id, device) -> - log(TAG, VERBOSE) { "save(id=$id, device=$device)" } - val cacheFile = id.toCacheFile() - try { - val encoded = json.encodeToString(BleScanResult.serializer(), device) - cacheFile.writeText(encoded) - } catch (e: Exception) { - log(TAG, ERROR) { "Failed to save profile $id device $device: ${e.asLog()}" } - cacheFile.delete() - } - } - } - } - - suspend fun delete(id: ProfileId) { - log(TAG, VERBOSE) { "delete(): profileId=$id" } - lock.withLock { - val cacheFile = id.toCacheFile() - try { - cacheFile.delete() - } catch (e: Exception) { - log(TAG, ERROR) { "Failed to delete profile for $id: ${e.asLog()}" } - } - } - } - - companion object { - private val TAG = logTag("Monitor", "BlePodMonitor", "Cache") - } -} - diff --git a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt index 469a4f20..418f11fe 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt @@ -36,6 +36,7 @@ import eu.darken.capod.profiles.core.DeviceProfile import eu.darken.capod.profiles.core.DeviceProfilesRepo import eu.darken.capod.pods.core.apple.aap.AapConnectionManager import eu.darken.capod.reaction.core.aap.AapAutoConnect +import eu.darken.capod.reaction.core.DeviceStatePersister import eu.darken.capod.reaction.core.aap.AapKeyPersister import eu.darken.capod.reaction.core.autoconnect.AutoConnect import eu.darken.capod.reaction.core.playpause.PlayPause @@ -77,6 +78,7 @@ class MonitorService : Service() { @Inject lateinit var profilesRepo: DeviceProfilesRepo @Inject lateinit var aapAutoConnect: AapAutoConnect @Inject lateinit var aapKeyPersister: AapKeyPersister + @Inject lateinit var deviceStatePersister: DeviceStatePersister @Inject lateinit var aapConnectionManager: AapConnectionManager private val monitorScope = MonitorCoroutineScope() @@ -307,6 +309,11 @@ class MonitorService : Service() { .catch { log(TAG, WARN) { "aapKeyPersister failed:\n${it.asLog()}" } } .launchIn(monitorScope) + deviceStatePersister.monitor() + .setupCommonEventHandlers(TAG) { "deviceStatePersister" } + .catch { log(TAG, WARN) { "deviceStatePersister failed:\n${it.asLog()}" } } + .launchIn(monitorScope) + log(TAG, VERBOSE) { "Monitor job is active" } monitorJob.join() log(TAG, VERBOSE) { "Monitor job quit" } diff --git a/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesRepo.kt b/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesRepo.kt index eabd1759..ac7a3f94 100644 --- a/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesRepo.kt +++ b/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesRepo.kt @@ -8,7 +8,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.main.core.GeneralSettings -import eu.darken.capod.monitor.core.PodDeviceCache +import eu.darken.capod.monitor.core.DeviceStateCache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -25,7 +25,7 @@ class DeviceProfilesRepo @Inject constructor( @ApplicationContext private val context: Context, private val generalSettings: GeneralSettings, private val settings: DeviceProfilesSettings, - private val podDeviceCache: PodDeviceCache, + private val deviceStateCache: DeviceStateCache, ) { private val mutex = Mutex() @@ -85,7 +85,7 @@ class DeviceProfilesRepo @Inject constructor( val updatedProfiles = currentContainer.profiles.filter { it.id != profileId } settings.profiles.valueBlocking = DeviceProfilesContainer(updatedProfiles) log(VERBOSE) { "Removed device profile with ID: $profileId" } - podDeviceCache.delete(profileId) + deviceStateCache.delete(profileId) } suspend fun reorderProfiles(profiles: List) = mutex.withLock { @@ -95,6 +95,7 @@ class DeviceProfilesRepo @Inject constructor( suspend fun clear() { settings.profiles.valueBlocking = DeviceProfilesContainer(emptyList()) + deviceStateCache.deleteAll() } private fun checkAddressUniqueness(profile: DeviceProfile, existingProfiles: List) { diff --git a/app/src/main/java/eu/darken/capod/reaction/core/DeviceStatePersister.kt b/app/src/main/java/eu/darken/capod/reaction/core/DeviceStatePersister.kt new file mode 100644 index 00000000..11dde2e8 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/reaction/core/DeviceStatePersister.kt @@ -0,0 +1,92 @@ +package eu.darken.capod.reaction.core + +import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.capod.common.debug.logging.log +import eu.darken.capod.common.debug.logging.logTag +import eu.darken.capod.common.flow.setupCommonEventHandlers +import eu.darken.capod.monitor.core.CachedDeviceState +import eu.darken.capod.monitor.core.CachedDeviceState.CachedBatterySlot +import eu.darken.capod.monitor.core.DeviceMonitor +import eu.darken.capod.monitor.core.DeviceStateCache +import eu.darken.capod.monitor.core.PodDevice +import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot +import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot +import eu.darken.capod.pods.core.apple.ble.devices.HasCase +import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetection +import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetectionDual +import kotlinx.coroutines.flow.Flow +import java.time.Duration +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Persists combined device state (battery, charging, model) to [DeviceStateCache]. + * Reads from raw BLE/AAP sources to avoid re-persisting cached values. + */ +@Singleton +class DeviceStatePersister @Inject constructor( + private val deviceMonitor: DeviceMonitor, + private val deviceStateCache: DeviceStateCache, +) { + fun monitor(): Flow = deviceMonitor.devices + .onEach { devices -> + for (device in devices) { + if (!device.isLive) continue + val profileId = device.profileId ?: continue + + val now = Instant.now() + val existing = deviceStateCache.cachedStates.value[profileId] + + val liveLeft = device.aap?.batteryLeft ?: (device.ble as? DualBlePodSnapshot)?.batteryLeftPodPercent + val liveRight = device.aap?.batteryRight ?: (device.ble as? DualBlePodSnapshot)?.batteryRightPodPercent + val liveCase = device.aap?.batteryCase ?: (device.ble as? HasCase)?.batteryCasePercent + val liveHeadset = device.aap?.batteryHeadset ?: (device.ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent + + // At least one live battery must be non-null to be worth persisting + if (liveLeft == null && liveRight == null && liveCase == null && liveHeadset == null) continue + + val newState = CachedDeviceState( + profileId = profileId, + model = device.model, + address = device.address, + left = liveLeft?.let { CachedBatterySlot(it, now) } ?: existing?.left, + right = liveRight?.let { CachedBatterySlot(it, now) } ?: existing?.right, + case = liveCase?.let { CachedBatterySlot(it, now) } ?: existing?.case, + headset = liveHeadset?.let { CachedBatterySlot(it, now) } ?: existing?.headset, + isLeftCharging = device.aap?.isLeftCharging ?: (device.ble as? HasChargeDetectionDual)?.isLeftPodCharging ?: existing?.isLeftCharging, + isRightCharging = device.aap?.isRightCharging ?: (device.ble as? HasChargeDetectionDual)?.isRightPodCharging ?: existing?.isRightCharging, + isCaseCharging = device.aap?.isCaseCharging ?: (device.ble as? HasCase)?.isCaseCharging ?: existing?.isCaseCharging, + isHeadsetCharging = device.aap?.isHeadsetCharging ?: (device.ble as? HasChargeDetection)?.isHeadsetBeingCharged ?: existing?.isHeadsetCharging, + lastSeenAt = device.seenLastAt ?: now, + ) + + if (isSameState(existing, newState)) continue + + log(TAG, VERBOSE) { "Persisting state for $profileId (L=${liveLeft} R=${liveRight} C=${liveCase} H=${liveHeadset})" } + deviceStateCache.save(profileId, newState) + } + } + .map { } + .setupCommonEventHandlers(TAG) { "deviceStatePersister" } + + private fun isSameState(old: CachedDeviceState?, new: CachedDeviceState): Boolean { + if (old == null) return false + // Update lastSeenAt periodically so the staleness label stays fresh when device goes offline + if (Duration.between(old.lastSeenAt, new.lastSeenAt).abs() > Duration.ofMinutes(1)) return false + return old.left?.percent == new.left?.percent + && old.right?.percent == new.right?.percent + && old.case?.percent == new.case?.percent + && old.headset?.percent == new.headset?.percent + && old.isLeftCharging == new.isLeftCharging + && old.isRightCharging == new.isRightCharging + && old.isCaseCharging == new.isCaseCharging + && old.isHeadsetCharging == new.isHeadsetCharging + } + + companion object { + private val TAG = logTag("Reaction", "DeviceStatePersister") + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e25b957b..f34cf13c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -273,6 +273,7 @@ Last seen: %s First seen: %s + Last known \u00B7 %s Show notifications "Allow CAPod to show notifications about your AirPods, e.g. their current status while connected." 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 248baa46..e6edf51b 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 @@ -155,7 +155,7 @@ class OverviewViewModelTest : BaseTest() { @Test fun `devices passed through when permissions granted`() = runTest(testDispatcher) { - val device = PodDevice(ble = mockk(relaxed = true), aap = null) + val device = PodDevice(profileId = null, ble = mockk(relaxed = true), aap = null) devicesFlow.value = listOf(device) val vm = createViewModel() @@ -166,18 +166,14 @@ class OverviewViewModelTest : BaseTest() { @Test fun `profiledDevices returns only devices with non-null profile`() { - val withProfile = object : BlePodSnapshot.Meta { - override val profile: DeviceProfile = AppleDeviceProfile(label = "Test") - } - val withoutProfile = object : BlePodSnapshot.Meta { - override val profile: DeviceProfile? = null - } val profiled = PodDevice( - ble = mockk(relaxed = true) { every { meta } returns withProfile }, + profileId = "test-id", + ble = mockk(relaxed = true), aap = null, ) val unmatched = PodDevice( - ble = mockk(relaxed = true) { every { meta } returns withoutProfile }, + profileId = null, + ble = mockk(relaxed = true), aap = null, ) @@ -197,18 +193,14 @@ class OverviewViewModelTest : BaseTest() { @Test fun `unmatchedDevices returns only devices with null profile`() { - val withProfile = object : BlePodSnapshot.Meta { - override val profile: DeviceProfile = AppleDeviceProfile(label = "Test") - } - val withoutProfile = object : BlePodSnapshot.Meta { - override val profile: DeviceProfile? = null - } val profiled = PodDevice( - ble = mockk(relaxed = true) { every { meta } returns withProfile }, + profileId = "test-id", + ble = mockk(relaxed = true), aap = null, ) val unmatched = PodDevice( - ble = mockk(relaxed = true) { every { meta } returns withoutProfile }, + profileId = null, + ble = mockk(relaxed = true), aap = null, ) diff --git a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceCacheTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceCacheTest.kt new file mode 100644 index 00000000..41dba4fb --- /dev/null +++ b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceCacheTest.kt @@ -0,0 +1,201 @@ +package eu.darken.capod.monitor.core + +import eu.darken.capod.pods.core.apple.PodModel +import eu.darken.capod.pods.core.apple.aap.AapPodState +import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import java.time.Instant + +class PodDeviceCacheTest : BaseTest() { + + private val fiveMinAgo = Instant.parse("2026-03-31T11:55:00Z") + private val oneHourAgo = Instant.parse("2026-03-31T11:00:00Z") + + private val cachedState = CachedDeviceState( + profileId = "test-profile", + model = PodModel.AIRPODS_PRO3, + address = "AA:BB:CC:DD:EE:FF", + left = CachedDeviceState.CachedBatterySlot(0.8f, fiveMinAgo), + right = CachedDeviceState.CachedBatterySlot(0.7f, fiveMinAgo), + case = CachedDeviceState.CachedBatterySlot(0.5f, oneHourAgo), + headset = null, + isLeftCharging = false, + isRightCharging = false, + isCaseCharging = true, + lastSeenAt = fiveMinAgo, + ) + + /** + * DualApplePods extends DualBlePodSnapshot, HasCase, HasChargeDetectionDual, etc. + * Using it as the mock type ensures all interface casts in PodDevice work correctly. + */ + private fun mockDualPod( + leftBattery: Float? = null, + rightBattery: Float? = null, + caseBattery: Float? = null, + ): DualApplePods = mockk(relaxed = true) { + every { batteryLeftPodPercent } returns leftBattery + every { batteryRightPodPercent } returns rightBattery + every { batteryCasePercent } returns caseBattery + every { model } returns PodModel.AIRPODS_PRO3 + } + + @Nested + inner class CacheFallback { + + @Test + fun `battery falls back to cache when live sources are null`() { + val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = cachedState) + device.batteryLeft shouldBe 0.8f + device.batteryRight shouldBe 0.7f + device.batteryCase shouldBe 0.5f + device.batteryHeadset.shouldBeNull() + } + + @Test + fun `live BLE takes precedence over cache`() { + val device = PodDevice( + profileId = "test-profile", ble = mockDualPod(leftBattery = 0.9f, rightBattery = 0.6f, caseBattery = 0.3f), + aap = null, + cached = cachedState, + ) + device.batteryLeft shouldBe 0.9f + device.batteryRight shouldBe 0.6f + device.batteryCase shouldBe 0.3f + } + + @Test + fun `live AAP takes precedence over both BLE and cache`() { + val aapState = AapPodState( + batteries = mapOf( + AapPodState.BatteryType.LEFT to AapPodState.Battery(AapPodState.BatteryType.LEFT, 0.95f, AapPodState.ChargingState.NOT_CHARGING), + ) + ) + val device = PodDevice( + profileId = "test-profile", ble = mockDualPod(leftBattery = 0.5f), + aap = aapState, + cached = cachedState, + ) + device.batteryLeft shouldBe 0.95f // AAP wins + device.batteryRight shouldBe 0.7f // BLE null -> cache + device.batteryCase shouldBe 0.5f // BLE null -> cache + } + + @Test + fun `charging falls back to cache`() { + val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = cachedState) + device.isLeftPodCharging shouldBe false + device.isCaseCharging shouldBe true + } + + @Test + fun `model falls back to cache`() { + val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = cachedState) + device.model shouldBe PodModel.AIRPODS_PRO3 + } + + @Test + fun `seenLastAt falls back to cache`() { + val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = cachedState) + device.seenLastAt shouldBe fiveMinAgo + } + + @Test + fun `profileId falls back to cache`() { + val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = cachedState) + device.profileId shouldBe "test-profile" + } + } + + @Nested + inner class IsLive { + + @Test + fun `isLive true when BLE present`() { + val device = PodDevice(profileId = "test-profile", ble = mockDualPod(), aap = null, cached = cachedState) + device.isLive shouldBe true + } + + @Test + fun `isLive true when AAP present`() { + val device = PodDevice(profileId = "test-profile", ble = null, aap = AapPodState(), cached = cachedState) + device.isLive shouldBe true + } + + @Test + fun `isLive false when only cache`() { + val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = cachedState) + device.isLive shouldBe false + } + } + + @Nested + inner class StalenessDetection { + + @Test + fun `isBatteryCached true when all live null and cache has values`() { + val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = cachedState) + device.isBatteryCached shouldBe true + } + + @Test + fun `isBatteryCached false when all live sources have data`() { + val device = PodDevice( + profileId = "test-profile", ble = mockDualPod(leftBattery = 0.9f, rightBattery = 0.8f, caseBattery = 0.3f), + aap = null, + cached = cachedState, + ) + device.isBatteryCached shouldBe false + } + + @Test + fun `isBatteryCached true when BLE present but pod batteries are null`() { + val device = PodDevice( + profileId = "test-profile", ble = mockDualPod(caseBattery = 0.4f), + aap = null, + cached = cachedState, + ) + device.isBatteryCached shouldBe true + } + + @Test + fun `isBatteryCached false when no cache`() { + val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = null) + device.isBatteryCached shouldBe false + } + + @Test + fun `cachedBatteryAt returns oldest cached slot timestamp`() { + val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = cachedState) + device.cachedBatteryAt shouldBe oneHourAgo + } + + @Test + fun `cachedBatteryAt null when live data covers all slots`() { + val device = PodDevice( + profileId = "test-profile", ble = mockDualPod(leftBattery = 0.9f, rightBattery = 0.8f, caseBattery = 0.3f), + aap = null, + cached = cachedState, + ) + device.cachedBatteryAt.shouldBeNull() + } + + @Test + fun `cachedBatteryAt returns only timestamp of slots that fell through`() { + val device = PodDevice( + profileId = "test-profile", ble = mockDualPod(caseBattery = 0.4f), + aap = null, + cached = cachedState, + ) + device.cachedBatteryAt.shouldNotBeNull() + device.cachedBatteryAt shouldBe fiveMinAgo + } + } +} diff --git a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt index 633ba268..43430fb0 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt @@ -41,7 +41,7 @@ class PodDeviceTest : BaseTest() { @Test fun `BLE-only device exposes battery from BLE`() { - val device = PodDevice(ble = mockDualPod(leftBattery = 0.8f), aap = null) + val device = PodDevice(profileId = null, ble = mockDualPod(leftBattery = 0.8f), aap = null) device.batteryLeft shouldBe 0.8f device.isAapConnected shouldBe false } @@ -49,7 +49,7 @@ class PodDeviceTest : BaseTest() { @Test fun `capabilities come from model features`() { val device = PodDevice( - ble = mockDualPod(model = PodModel.AIRPODS_PRO3), + profileId = null, ble = mockDualPod(model = PodModel.AIRPODS_PRO3), aap = null, ) device.hasDualPods shouldBe true @@ -61,7 +61,7 @@ class PodDeviceTest : BaseTest() { @Test fun `Beats Solo 3 has no dual pods or case`() { val device = PodDevice( - ble = mockk(relaxed = true) { every { model } returns PodModel.BEATS_SOLO_3 }, + profileId = null, ble = mockk(relaxed = true) { every { model } returns PodModel.BEATS_SOLO_3 }, aap = null, ) device.hasDualPods shouldBe false @@ -79,7 +79,7 @@ class PodDeviceTest : BaseTest() { ), ), ) - val device = PodDevice(ble = mockDualPod(), aap = aap) + val device = PodDevice(profileId = null, ble = mockDualPod(), aap = aap) device.isAapConnected shouldBe true device.ancMode.shouldNotBeNull() device.ancMode!!.current shouldBe AapSetting.AncMode.Value.TRANSPARENCY @@ -87,13 +87,13 @@ class PodDeviceTest : BaseTest() { @Test fun `ANC mode is null when not AAP connected`() { - val device = PodDevice(ble = mockDualPod(), aap = null) + val device = PodDevice(profileId = null, ble = mockDualPod(), aap = null) device.ancMode.shouldBeNull() } @Test fun `null BLE gives UNKNOWN model`() { - val device = PodDevice(ble = null, aap = null) + val device = PodDevice(profileId = null, ble = null, aap = null) device.model shouldBe PodModel.UNKNOWN } @@ -102,7 +102,7 @@ class PodDeviceTest : BaseTest() { val id = BlePodSnapshot.Id() val meta = mockk(relaxed = true) val device = PodDevice( - ble = mockk(relaxed = true) { + profileId = null, ble = mockk(relaxed = true) { every { identifier } returns id every { this@mockk.meta } returns meta }, @@ -114,7 +114,7 @@ class PodDeviceTest : BaseTest() { @Test fun `identity properties null when BLE null`() { - val device = PodDevice(ble = null, aap = null) + val device = PodDevice(profileId = null, ble = null, aap = null) device.identifier.shouldBeNull() device.meta.shouldBeNull() } @@ -124,7 +124,7 @@ class PodDeviceTest : BaseTest() { val now = Instant.now() val earlier = now.minusSeconds(60) val device = PodDevice( - ble = mockk(relaxed = true) { + profileId = null, ble = mockk(relaxed = true) { every { seenLastAt } returns now every { seenFirstAt } returns earlier every { signalQuality } returns 0.75f @@ -140,7 +140,7 @@ class PodDeviceTest : BaseTest() { @Test fun `signal timing defaults when BLE null`() { - val device = PodDevice(ble = null, aap = null) + val device = PodDevice(profileId = null, ble = null, aap = null) device.seenLastAt.shouldBeNull() device.seenFirstAt.shouldBeNull() device.signalQuality shouldBe 0f @@ -155,7 +155,7 @@ class PodDeviceTest : BaseTest() { every { (this@mockk as HasChargeDetectionDual).isRightPodCharging } returns false every { (this@mockk as HasCase).isCaseCharging } returns true } - val device = PodDevice(ble = mock, aap = null) + val device = PodDevice(profileId = null, ble = mock, aap = null) device.isLeftPodCharging shouldBe true device.isRightPodCharging shouldBe false device.isCaseCharging shouldBe true @@ -170,7 +170,7 @@ class PodDeviceTest : BaseTest() { every { (this@mockk as HasEarDetection).isBeingWorn } returns false every { (this@mockk as HasEarDetectionDual).isEitherPodInEar } returns true } - val device = PodDevice(ble = mock, aap = null) + val device = PodDevice(profileId = null, ble = mock, aap = null) device.isLeftInEar shouldBe true device.isRightInEar shouldBe false device.isBeingWorn shouldBe false @@ -192,7 +192,7 @@ class PodDeviceTest : BaseTest() { ), ), ) - val device = PodDevice(ble = mock, aap = aap) + val device = PodDevice(profileId = null, ble = mock, aap = aap) device.isEitherPodInEar shouldBe true } @@ -203,7 +203,7 @@ class PodDeviceTest : BaseTest() { every { (this@mockk as HasEarDetectionDual).isEitherPodInEar } returns true } val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY) - val device = PodDevice(ble = mock, aap = aap) + val device = PodDevice(profileId = null, ble = mock, aap = aap) device.isEitherPodInEar shouldBe true } @@ -222,7 +222,7 @@ class PodDeviceTest : BaseTest() { ), ), ) - val device = PodDevice(ble = mock, aap = aap) + val device = PodDevice(profileId = null, ble = mock, aap = aap) device.isLeftInEar shouldBe true device.isRightInEar shouldBe false } @@ -242,7 +242,7 @@ class PodDeviceTest : BaseTest() { ), ), ) - val device = PodDevice(ble = mock, aap = aap) + val device = PodDevice(profileId = null, ble = mock, aap = aap) device.isLeftInEar shouldBe false device.isRightInEar shouldBe true } @@ -261,7 +261,7 @@ class PodDeviceTest : BaseTest() { ), ), ) - val device = PodDevice(ble = mock, aap = aap) + val device = PodDevice(profileId = null, ble = mock, aap = aap) device.isBeingWorn shouldBe true } @@ -279,7 +279,7 @@ class PodDeviceTest : BaseTest() { ), ), ) - val device = PodDevice(ble = mock, aap = aap) + val device = PodDevice(profileId = null, ble = mock, aap = aap) device.isBeingWorn shouldBe false } @@ -301,7 +301,7 @@ class PodDeviceTest : BaseTest() { AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(AapSetting.PrimaryPod.Pod.LEFT), ), ) - val device = PodDevice(ble = mock, aap = aap) + val device = PodDevice(profileId = null, ble = mock, aap = aap) device.isLeftInEar shouldBe true device.isRightInEar shouldBe false } @@ -322,7 +322,7 @@ class PodDeviceTest : BaseTest() { AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(AapSetting.PrimaryPod.Pod.LEFT), // AAP says LEFT ), ) - val device = PodDevice(ble = mock, aap = aap) + val device = PodDevice(profileId = null, ble = mock, aap = aap) device.isLeftInEar shouldBe true // AAP wins device.isRightInEar shouldBe false } @@ -343,7 +343,7 @@ class PodDeviceTest : BaseTest() { // No PrimaryPod setting — falls back to BLE ), ) - val device = PodDevice(ble = mock, aap = aap) + val device = PodDevice(profileId = null, ble = mock, aap = aap) device.isLeftInEar shouldBe false device.isRightInEar shouldBe true // BLE says RIGHT is primary } @@ -361,7 +361,7 @@ class PodDeviceTest : BaseTest() { AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(AapSetting.PrimaryPod.Pod.LEFT), ), ) - val device = PodDevice(ble = mock, aap = aap) + val device = PodDevice(profileId = null, ble = mock, aap = aap) device.isLeftPodMicrophone shouldBe true // AAP says LEFT device.isRightPodMicrophone shouldBe false } @@ -374,7 +374,7 @@ class PodDeviceTest : BaseTest() { every { isRightPodMicrophone } returns true } val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY) - val device = PodDevice(ble = mock, aap = aap) + val device = PodDevice(profileId = null, ble = mock, aap = aap) device.isLeftPodMicrophone shouldBe false device.isRightPodMicrophone shouldBe true // BLE fallback } @@ -394,7 +394,7 @@ class PodDeviceTest : BaseTest() { AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(AapSetting.PrimaryPod.Pod.RIGHT), ), ) - val device = PodDevice(ble = mock, aap = aap) + val device = PodDevice(profileId = null, ble = mock, aap = aap) device.isLeftPodMicrophone shouldBe false device.isRightPodMicrophone shouldBe true } @@ -414,7 +414,7 @@ class PodDeviceTest : BaseTest() { AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(AapSetting.PrimaryPod.Pod.LEFT), ), ) - val device = PodDevice(ble = mock, aap = aap) + val device = PodDevice(profileId = null, ble = mock, aap = aap) device.isLeftPodMicrophone shouldBe true device.isRightPodMicrophone shouldBe false } @@ -425,27 +425,27 @@ class PodDeviceTest : BaseTest() { connectionState = AapPodState.ConnectionState.READY, pendingAncMode = AapSetting.AncMode.Value.ADAPTIVE, ) - val device = PodDevice(ble = mockDualPod(), aap = aap) + val device = PodDevice(profileId = null, ble = mockDualPod(), aap = aap) device.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE } @Test fun `pendingAncMode null when no AAP`() { - val device = PodDevice(ble = mockDualPod(), aap = null) + val device = PodDevice(profileId = null, ble = mockDualPod(), aap = null) device.pendingAncMode.shouldBeNull() } @Test fun `pendingAncMode null when not set`() { val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY) - val device = PodDevice(ble = mockDualPod(), aap = aap) + val device = PodDevice(profileId = null, ble = mockDualPod(), aap = aap) device.pendingAncMode.shouldBeNull() } @Test fun `icon and label properties delegate to BLE`() { val device = PodDevice( - ble = mockk(relaxed = true) { + profileId = null, ble = mockk(relaxed = true) { every { model } returns PodModel.AIRPODS_PRO3 every { iconRes } returns 42 }, @@ -456,14 +456,14 @@ class PodDeviceTest : BaseTest() { @Test fun `rawDataHex empty when BLE null`() { - val device = PodDevice(ble = null, aap = null) + val device = PodDevice(profileId = null, ble = null, aap = null) device.rawDataHex shouldBe emptyList() } @Test fun `battery falls back to BLE when AAP battery is null`() { val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY) - val device = PodDevice(ble = mockDualPod(leftBattery = 0.8f), aap = aap) + val device = PodDevice(profileId = null, ble = mockDualPod(leftBattery = 0.8f), aap = aap) device.batteryLeft shouldBe 0.8f device.isAapConnected shouldBe true } @@ -476,7 +476,7 @@ class PodDeviceTest : BaseTest() { AapPodState.BatteryType.LEFT to AapPodState.Battery(AapPodState.BatteryType.LEFT, 0.79f, AapPodState.ChargingState.NOT_CHARGING), ), ) - val device = PodDevice(ble = mockDualPod(leftBattery = 0.8f), aap = aap) + val device = PodDevice(profileId = null, ble = mockDualPod(leftBattery = 0.8f), aap = aap) device.batteryLeft shouldBe 0.79f // AAP 1% granularity wins over BLE 10% } @@ -492,7 +492,7 @@ class PodDeviceTest : BaseTest() { AapPodState.BatteryType.LEFT to AapPodState.Battery(AapPodState.BatteryType.LEFT, 0.8f, AapPodState.ChargingState.CHARGING_OPTIMIZED), ), ) - val device = PodDevice(ble = mock, aap = aap) + val device = PodDevice(profileId = null, ble = mock, aap = aap) device.isLeftPodCharging shouldBe true // AAP CHARGING_OPTIMIZED counts as charging } @@ -508,7 +508,7 @@ class PodDeviceTest : BaseTest() { every { this@mockk.address } returns bleRpa every { meta } returns ApplePods.AppleMeta(profile = profile) } - val device = PodDevice(ble = ble, aap = null) + val device = PodDevice(profileId = null, ble = ble, aap = null) device.address shouldBe bondedAddress device.bleAddress shouldBe bleRpa } @@ -520,7 +520,7 @@ class PodDeviceTest : BaseTest() { every { model } returns PodModel.AIRPODS_PRO3 every { signalQuality } returns bleQuality } - return PodDevice(ble = ble, aap = aap) + return PodDevice(profileId = null, ble = ble, aap = aap) } @Test @@ -627,13 +627,13 @@ class PodDeviceTest : BaseTest() { @Test fun `bleKeyState - null BLE returns NONE`() { - val device = PodDevice(ble = null, aap = null) + val device = PodDevice(profileId = null, ble = null, aap = null) device.bleKeyState shouldBe BleKeyState.NONE } @Test fun `bleKeyState - non-Apple BLE returns NONE`() { - val device = PodDevice(ble = mockk(relaxed = true) { every { model } returns PodModel.UNKNOWN }, aap = null) + val device = PodDevice(profileId = null, ble = mockk(relaxed = true) { every { model } returns PodModel.UNKNOWN }, aap = null) device.bleKeyState shouldBe BleKeyState.NONE } @@ -644,7 +644,7 @@ class PodDeviceTest : BaseTest() { every { meta } returns ApplePods.AppleMeta(isIRKMatch = false) every { payload } returns ProximityPayload(public = ProximityPayload.Public(UByteArray(9)), private = null) } - val device = PodDevice(ble = ble, aap = null) + val device = PodDevice(profileId = null, ble = ble, aap = null) device.bleKeyState shouldBe BleKeyState.NONE } @@ -655,7 +655,7 @@ class PodDeviceTest : BaseTest() { every { meta } returns ApplePods.AppleMeta(isIRKMatch = true) every { payload } returns ProximityPayload(public = ProximityPayload.Public(UByteArray(9)), private = null) } - val device = PodDevice(ble = ble, aap = null) + val device = PodDevice(profileId = null, ble = ble, aap = null) device.bleKeyState shouldBe BleKeyState.IRK_ONLY } @@ -669,7 +669,7 @@ class PodDeviceTest : BaseTest() { private = ProximityPayload.Private(UByteArray(8)), ) } - val device = PodDevice(ble = ble, aap = null) + val device = PodDevice(profileId = null, ble = ble, aap = null) device.bleKeyState shouldBe BleKeyState.IRK_AND_ENCRYPTED } } diff --git a/app/src/test/java/eu/darken/capod/reaction/core/DeviceStatePersisterTest.kt b/app/src/test/java/eu/darken/capod/reaction/core/DeviceStatePersisterTest.kt new file mode 100644 index 00000000..064967d0 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/reaction/core/DeviceStatePersisterTest.kt @@ -0,0 +1,163 @@ +package eu.darken.capod.reaction.core + +import eu.darken.capod.monitor.core.CachedDeviceState +import eu.darken.capod.monitor.core.DeviceMonitor +import eu.darken.capod.monitor.core.DeviceStateCache +import eu.darken.capod.monitor.core.PodDevice +import eu.darken.capod.pods.core.apple.PodModel +import eu.darken.capod.pods.core.apple.ble.devices.ApplePods +import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods +import eu.darken.capod.profiles.core.AppleDeviceProfile +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import java.time.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +class DeviceStatePersisterTest : BaseTest() { + + private val testDispatcher = UnconfinedTestDispatcher() + + private lateinit var deviceMonitor: DeviceMonitor + private lateinit var deviceStateCache: DeviceStateCache + private lateinit var devicesFlow: MutableStateFlow> + private lateinit var cachedStatesFlow: MutableStateFlow> + + private val testProfile = AppleDeviceProfile( + id = "test-profile", + label = "Test AirPods", + model = PodModel.AIRPODS_PRO3, + address = "AA:BB:CC:DD:EE:FF", + ) + + @BeforeEach + fun setup() { + devicesFlow = MutableStateFlow(emptyList()) + cachedStatesFlow = MutableStateFlow(emptyMap()) + + deviceMonitor = mockk { + every { devices } returns devicesFlow + } + + deviceStateCache = mockk(relaxed = true) { + every { cachedStates } returns cachedStatesFlow + } + } + + private fun createPersister() = DeviceStatePersister( + deviceMonitor = deviceMonitor, + deviceStateCache = deviceStateCache, + ) + + private fun createLiveDevice( + leftBattery: Float? = 0.8f, + rightBattery: Float? = 0.7f, + caseBattery: Float? = 0.5f, + ): PodDevice { + val bleMeta = ApplePods.AppleMeta(profile = testProfile) + val blePod: DualApplePods = mockk(relaxed = true) { + every { meta } returns bleMeta + every { batteryLeftPodPercent } returns leftBattery + every { batteryRightPodPercent } returns rightBattery + every { batteryCasePercent } returns caseBattery + every { isLeftPodCharging } returns false + every { isRightPodCharging } returns false + every { isCaseCharging } returns false + every { model } returns PodModel.AIRPODS_PRO3 + every { address } returns "5A:3B:1C:2D:4E:6F" + every { seenLastAt } returns Instant.now() + } + return PodDevice(profileId = "test-profile", ble = blePod, aap = null, cached = null) + } + + @Nested + inner class Persistence { + + @Test + fun `persists state for live device with battery`() = runTest(testDispatcher) { + val persister = createPersister() + + val job = launch { persister.monitor().toList() } + devicesFlow.value = listOf(createLiveDevice()) + advanceUntilIdle() + + coVerify(exactly = 1) { deviceStateCache.save("test-profile", any()) } + + job.cancel() + } + + @Test + fun `skips device with all null live batteries`() = runTest(testDispatcher) { + val persister = createPersister() + + val job = launch { persister.monitor().toList() } + devicesFlow.value = listOf(createLiveDevice(leftBattery = null, rightBattery = null, caseBattery = null)) + advanceUntilIdle() + + coVerify(exactly = 0) { deviceStateCache.save(any(), any()) } + + job.cancel() + } + + @Test + fun `skips cached-only devices`() = runTest(testDispatcher) { + val persister = createPersister() + val cachedOnlyDevice = PodDevice( + profileId = "test-profile", ble = null, + aap = null, + cached = CachedDeviceState( + profileId = "test-profile", + model = PodModel.AIRPODS_PRO3, + left = CachedDeviceState.CachedBatterySlot(0.5f, Instant.now()), + lastSeenAt = Instant.now(), + ), + ) + + val job = launch { persister.monitor().toList() } + devicesFlow.value = listOf(cachedOnlyDevice) + advanceUntilIdle() + + coVerify(exactly = 0) { deviceStateCache.save(any(), any()) } + + job.cancel() + } + + @Test + fun `skips write when values unchanged`() = runTest(testDispatcher) { + val persister = createPersister() + + val existingCached = CachedDeviceState( + profileId = "test-profile", + model = PodModel.AIRPODS_PRO3, + left = CachedDeviceState.CachedBatterySlot(0.8f, Instant.now()), + right = CachedDeviceState.CachedBatterySlot(0.7f, Instant.now()), + case = CachedDeviceState.CachedBatterySlot(0.5f, Instant.now()), + isLeftCharging = false, + isRightCharging = false, + isCaseCharging = false, + isHeadsetCharging = false, + lastSeenAt = Instant.now(), + ) + cachedStatesFlow.value = mapOf("test-profile" to existingCached) + + val job = launch { persister.monitor().toList() } + devicesFlow.value = listOf(createLiveDevice()) + advanceUntilIdle() + + coVerify(exactly = 0) { deviceStateCache.save(any(), any()) } + + job.cancel() + } + } +} From 360067b07e88d605af0176ebed6edb5b83c3cc41 Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 2 Apr 2026 15:14:47 +0200 Subject: [PATCH 2/9] refactor: Move persisters to monitor package and fix stale BLE device eviction Move DeviceStatePersister and AapKeyPersister from reaction to monitor package since they are always-on infrastructure, not user-togglable reactions. Add periodic ticker to BlePodMonitor to force stale device eviction when BLE scanner produces no results, fixing cached card not appearing after disconnect. --- .../eu/darken/capod/monitor/core/BlePodMonitor.kt | 15 +++++++++++++-- .../core/DeviceStatePersister.kt | 4 ++-- .../core/aap/AapKeyPersister.kt | 4 ++-- .../capod/monitor/core/worker/MonitorService.kt | 4 ++-- .../core/DeviceStatePersisterTest.kt | 2 +- 5 files changed, 20 insertions(+), 9 deletions(-) rename app/src/main/java/eu/darken/capod/{reaction => monitor}/core/DeviceStatePersister.kt (97%) rename app/src/main/java/eu/darken/capod/{reaction => monitor}/core/aap/AapKeyPersister.kt (94%) rename app/src/test/java/eu/darken/capod/{reaction => monitor}/core/DeviceStatePersisterTest.kt (99%) diff --git a/app/src/main/java/eu/darken/capod/monitor/core/BlePodMonitor.kt b/app/src/main/java/eu/darken/capod/monitor/core/BlePodMonitor.kt index 7def1e7e..660baebb 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/BlePodMonitor.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/BlePodMonitor.kt @@ -1,6 +1,7 @@ package eu.darken.capod.monitor.core import android.bluetooth.le.ScanFilter +import eu.darken.capod.common.bluetooth.BleScanResult import eu.darken.capod.common.bluetooth.BleScanner import eu.darken.capod.common.bluetooth.BluetoothManager2 import eu.darken.capod.common.bluetooth.ScannerMode @@ -27,8 +28,10 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.retryWhen import kotlinx.coroutines.sync.Mutex @@ -65,7 +68,13 @@ class BlePodMonitor @Inject constructor( log(TAG, WARN) { "Bluetooth is not ready" } flowOf(null) } else { - createBleScanner() + val staleEvictionTicker: Flow> = flow { + while (true) { + delay(STALE_EVICTION_INTERVAL.toMillis()) + emit(emptyList()) + } + } + merge(createBleScanner(), staleEvictionTicker) } } .map { results -> results?.mapNotNull { podFactory.createPod(it) } } @@ -167,7 +176,7 @@ class BlePodMonitor @Inject constructor( val now = Instant.now() deviceCache.toList().forEach { (key, value) -> - if (Duration.between(value.seenLastAt, now) > Duration.ofSeconds(20)) { + if (Duration.between(value.seenLastAt, now) > STALE_DEVICE_TIMEOUT) { log(TAG, VERBOSE) { "Removing stale device from cache: $value" } deviceCache.remove(key) } @@ -187,5 +196,7 @@ class BlePodMonitor @Inject constructor( companion object { private val TAG = logTag("Monitor", "PodMonitor") + private val STALE_DEVICE_TIMEOUT = Duration.ofSeconds(20) + private val STALE_EVICTION_INTERVAL = Duration.ofSeconds(10) } } \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/reaction/core/DeviceStatePersister.kt b/app/src/main/java/eu/darken/capod/monitor/core/DeviceStatePersister.kt similarity index 97% rename from app/src/main/java/eu/darken/capod/reaction/core/DeviceStatePersister.kt rename to app/src/main/java/eu/darken/capod/monitor/core/DeviceStatePersister.kt index 11dde2e8..6f5cb882 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/DeviceStatePersister.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/DeviceStatePersister.kt @@ -1,4 +1,4 @@ -package eu.darken.capod.reaction.core +package eu.darken.capod.monitor.core import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE import eu.darken.capod.common.debug.logging.log @@ -87,6 +87,6 @@ class DeviceStatePersister @Inject constructor( } companion object { - private val TAG = logTag("Reaction", "DeviceStatePersister") + private val TAG = logTag("Monitor", "DeviceStatePersister") } } diff --git a/app/src/main/java/eu/darken/capod/reaction/core/aap/AapKeyPersister.kt b/app/src/main/java/eu/darken/capod/monitor/core/aap/AapKeyPersister.kt similarity index 94% rename from app/src/main/java/eu/darken/capod/reaction/core/aap/AapKeyPersister.kt rename to app/src/main/java/eu/darken/capod/monitor/core/aap/AapKeyPersister.kt index a71ff2ea..ad47da86 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/aap/AapKeyPersister.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/aap/AapKeyPersister.kt @@ -1,4 +1,4 @@ -package eu.darken.capod.reaction.core.aap +package eu.darken.capod.monitor.core.aap import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag @@ -47,6 +47,6 @@ class AapKeyPersister @Inject constructor( .setupCommonEventHandlers(TAG) { "keyPersister" } companion object { - private val TAG = logTag("Reaction", "AapKeyPersister") + private val TAG = logTag("Monitor", "AapKeyPersister") } } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt index 418f11fe..38f0e287 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt @@ -36,8 +36,8 @@ import eu.darken.capod.profiles.core.DeviceProfile import eu.darken.capod.profiles.core.DeviceProfilesRepo import eu.darken.capod.pods.core.apple.aap.AapConnectionManager import eu.darken.capod.reaction.core.aap.AapAutoConnect -import eu.darken.capod.reaction.core.DeviceStatePersister -import eu.darken.capod.reaction.core.aap.AapKeyPersister +import eu.darken.capod.monitor.core.DeviceStatePersister +import eu.darken.capod.monitor.core.aap.AapKeyPersister import eu.darken.capod.reaction.core.autoconnect.AutoConnect import eu.darken.capod.reaction.core.playpause.PlayPause import eu.darken.capod.reaction.core.popup.PopUpReaction diff --git a/app/src/test/java/eu/darken/capod/reaction/core/DeviceStatePersisterTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/DeviceStatePersisterTest.kt similarity index 99% rename from app/src/test/java/eu/darken/capod/reaction/core/DeviceStatePersisterTest.kt rename to app/src/test/java/eu/darken/capod/monitor/core/DeviceStatePersisterTest.kt index 064967d0..aeccf0ef 100644 --- a/app/src/test/java/eu/darken/capod/reaction/core/DeviceStatePersisterTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/DeviceStatePersisterTest.kt @@ -1,4 +1,4 @@ -package eu.darken.capod.reaction.core +package eu.darken.capod.monitor.core import eu.darken.capod.monitor.core.CachedDeviceState import eu.darken.capod.monitor.core.DeviceMonitor From 5ae15d828d879342521664e2484fd1699b9c8eb7 Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 2 Apr 2026 15:23:16 +0200 Subject: [PATCH 3/9] refactor: Use PodDevice charging properties instead of duplicating fallback logic --- .../capod/monitor/core/DeviceStatePersister.kt | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/eu/darken/capod/monitor/core/DeviceStatePersister.kt b/app/src/main/java/eu/darken/capod/monitor/core/DeviceStatePersister.kt index 6f5cb882..e4df4b40 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/DeviceStatePersister.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/DeviceStatePersister.kt @@ -4,16 +4,10 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.flow.setupCommonEventHandlers -import eu.darken.capod.monitor.core.CachedDeviceState import eu.darken.capod.monitor.core.CachedDeviceState.CachedBatterySlot -import eu.darken.capod.monitor.core.DeviceMonitor -import eu.darken.capod.monitor.core.DeviceStateCache -import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot import eu.darken.capod.pods.core.apple.ble.devices.HasCase -import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetection -import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetectionDual import kotlinx.coroutines.flow.Flow import java.time.Duration import kotlinx.coroutines.flow.map @@ -56,10 +50,10 @@ class DeviceStatePersister @Inject constructor( right = liveRight?.let { CachedBatterySlot(it, now) } ?: existing?.right, case = liveCase?.let { CachedBatterySlot(it, now) } ?: existing?.case, headset = liveHeadset?.let { CachedBatterySlot(it, now) } ?: existing?.headset, - isLeftCharging = device.aap?.isLeftCharging ?: (device.ble as? HasChargeDetectionDual)?.isLeftPodCharging ?: existing?.isLeftCharging, - isRightCharging = device.aap?.isRightCharging ?: (device.ble as? HasChargeDetectionDual)?.isRightPodCharging ?: existing?.isRightCharging, - isCaseCharging = device.aap?.isCaseCharging ?: (device.ble as? HasCase)?.isCaseCharging ?: existing?.isCaseCharging, - isHeadsetCharging = device.aap?.isHeadsetCharging ?: (device.ble as? HasChargeDetection)?.isHeadsetBeingCharged ?: existing?.isHeadsetCharging, + isLeftCharging = device.isLeftPodCharging, + isRightCharging = device.isRightPodCharging, + isCaseCharging = device.isCaseCharging, + isHeadsetCharging = device.isHeadsetBeingCharged, lastSeenAt = device.seenLastAt ?: now, ) From 94156b6200535fefd9e3614ae8bfa6e0fc5f9da0 Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 2 Apr 2026 15:37:33 +0200 Subject: [PATCH 4/9] fix: Show profile name on cached-only device cards Add label property to PodDevice, populated from the profile in DeviceMonitor. Cards now use device.label instead of reaching through BLE metadata, so cached-only cards display the profile name instead of '?'. --- .../capod/main/ui/overview/cards/DualPodsCard.kt | 2 +- .../capod/main/ui/overview/cards/SinglePodsCard.kt | 2 +- .../eu/darken/capod/monitor/core/DeviceMonitor.kt | 12 +++++++----- .../java/eu/darken/capod/monitor/core/PodDevice.kt | 1 + 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt index 1038e92c..1ddfe2d1 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt @@ -95,7 +95,7 @@ fun DualPodsCard( Column(modifier = Modifier.weight(1f)) { Text( - text = device.meta?.profile?.label ?: "?", + text = device.label ?: "?", style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt index faba3ffb..6cd9fddd 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt @@ -103,7 +103,7 @@ fun SinglePodsCard( Column(modifier = Modifier.weight(1f)) { Text( - text = device.meta?.profile?.label ?: "?", + text = device.label ?: "?", style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt index 919a9654..d35caa69 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt @@ -33,12 +33,13 @@ class DeviceMonitor @Inject constructor( // Live devices — BLE + AAP + cached fallback for missing fields val liveDevices = pods.map { pod -> val bondedAddress = pod.meta?.profile?.address - val profileId = pod.meta?.profile?.id + val profile = pod.meta?.profile PodDevice( - profileId = profileId, + profileId = profile?.id, + label = profile?.label, ble = pod, aap = bondedAddress?.let { aapStates[it] }, - cached = profileId?.let { cachedStates[it] }, + cached = profile?.id?.let { cachedStates[it] }, ) } @@ -48,7 +49,7 @@ class DeviceMonitor @Inject constructor( .filter { it.id !in liveProfileIds } .mapNotNull { profile -> cachedStates[profile.id]?.let { - PodDevice(profileId = profile.id, ble = null, aap = null, cached = it) + PodDevice(profileId = profile.id, label = profile.label, ble = null, aap = null, cached = it) } } @@ -67,7 +68,8 @@ class DeviceMonitor @Inject constructor( val cached = deviceStateCache.load(profileId) if (cached != null) { log(TAG) { "Found cached state for profile $profileId" } - return PodDevice(profileId = profileId, ble = null, aap = null, cached = cached) + val profileLabel = profilesRepo.profiles.firstOrNull()?.firstOrNull { it.id == profileId }?.label + return PodDevice(profileId = profileId, label = profileLabel, ble = null, aap = null, cached = cached) } log(TAG) { "No device found for profile $profileId" } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt index 74ea4294..b6e4cd53 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt @@ -29,6 +29,7 @@ import java.time.Instant @Stable data class PodDevice( val profileId: String?, + val label: String? = null, internal val ble: BlePodSnapshot?, internal val aap: AapPodState?, internal val cached: CachedDeviceState? = null, From e3693628c2e042e012de095e821488d7ba84f1a0 Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 2 Apr 2026 15:42:12 +0200 Subject: [PATCH 5/9] refactor: Use PodDevice properties instead of reaching through BLE metadata Replace device.meta?.profile?.address/id/label with device.address/profileId/label in AutoConnect, PopUpReaction, and BatteryGlanceWidget. --- .../eu/darken/capod/main/ui/widget/BatteryGlanceWidget.kt | 4 ++-- .../darken/capod/reaction/core/autoconnect/AutoConnect.kt | 2 +- .../eu/darken/capod/reaction/core/popup/PopUpReaction.kt | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/BatteryGlanceWidget.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/BatteryGlanceWidget.kt index c9f877f3..3fedd139 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/BatteryGlanceWidget.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/BatteryGlanceWidget.kt @@ -86,8 +86,8 @@ class BatteryGlanceWidget : GlanceAppWidget() { val isPro = upgradeInfo?.isPro ?: initialIsPro - val liveDevice = devices.firstOrNull { it.meta?.profile?.id == profileId } - val device = liveDevice ?: cachedDevice?.takeIf { it.meta?.profile?.id == profileId } + val liveDevice = devices.firstOrNull { it.profileId == profileId } + val device = liveDevice ?: cachedDevice?.takeIf { it.profileId == profileId } val profileLabel = profileId?.let { pid -> profiles.firstOrNull { it.id == pid }?.label diff --git a/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnect.kt b/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnect.kt index f952bd4e..4400c4f7 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnect.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnect.kt @@ -49,7 +49,7 @@ class AutoConnect @Inject constructor( .map { (connectedDevices, mainDevice) -> log(TAG, VERBOSE) { "mainPodDevice is $mainDevice" } - val mainDeviceAddr = mainDevice.meta?.profile?.address + val mainDeviceAddr = mainDevice.address if (mainDeviceAddr.isNullOrEmpty()) { log(TAG, WARN) { "mainDeviceAddress is null" } return@map diff --git a/app/src/main/java/eu/darken/capod/reaction/core/popup/PopUpReaction.kt b/app/src/main/java/eu/darken/capod/reaction/core/popup/PopUpReaction.kt index a79a381c..41dbb701 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/popup/PopUpReaction.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/popup/PopUpReaction.kt @@ -53,7 +53,7 @@ class PopUpReaction @Inject constructor( log(TAG, VERBOSE) { "previous-id=${previous?.identifier}, current-id=${current.identifier}" } val isSameDeviceOrProfile = previous?.identifier == current.identifier || - (previous?.meta?.profile?.id != null && previous.meta?.profile?.id == current.meta?.profile?.id) + (previous?.profileId != null && previous.profileId == current.profileId) val isSameDeviceWithCaseNowOpen = isSameDeviceOrProfile && previous?.caseLidState != current.caseLidState val isNewDeviceWithJustOpenedCase = !isSameDeviceOrProfile && previous?.caseLidState != current.caseLidState @@ -66,7 +66,7 @@ class PopUpReaction @Inject constructor( } private fun throttleCasePopUps(current: PodDevice): Event? { - val cooldownKey = current.meta?.profile?.id ?: current.identifier.toString() + val cooldownKey = current.profileId ?: current.identifier.toString() val now = Instant.now() val lastShown = caseCoolDowns[cooldownKey] @@ -110,7 +110,7 @@ class PopUpReaction @Inject constructor( deviceMonitor.primaryDevice().distinctUntilChangedBy { it?.rawDataHex }, ) { devices, broadcast -> log(TAG) { "$broadcast $devices " } - val primaryAddr = broadcast?.meta?.profile?.address + val primaryAddr = broadcast?.address val direct = devices.singleOrNull { it.address == primaryAddr }.also { log(TAG, VERBOSE) { "Connected main device is $it" } } From 24a84a49f19d2cdd438b9dbf34205d0f8d9d5cc1 Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 2 Apr 2026 15:59:40 +0200 Subject: [PATCH 6/9] refactor: Remove meta from PodDevice public API and delete unused PodMonitorExtensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All external consumers now use top-level PodDevice properties. BlePodMonitor extensions were dead code — all callers use DeviceMonitor equivalents. --- .../java/eu/darken/capod/monitor/core/DeviceMonitor.kt | 4 ++-- .../java/eu/darken/capod/monitor/core/PodDevice.kt | 1 - .../darken/capod/monitor/core/PodMonitorExtensions.kt | 10 ---------- .../java/eu/darken/capod/monitor/core/PodDeviceTest.kt | 8 ++------ 4 files changed, 4 insertions(+), 19 deletions(-) delete mode 100644 app/src/main/java/eu/darken/capod/monitor/core/PodMonitorExtensions.kt diff --git a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt index d35caa69..d6936300 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt @@ -32,8 +32,8 @@ class DeviceMonitor @Inject constructor( ) { pods, aapStates, cachedStates, profiles -> // Live devices — BLE + AAP + cached fallback for missing fields val liveDevices = pods.map { pod -> - val bondedAddress = pod.meta?.profile?.address - val profile = pod.meta?.profile + val bondedAddress = pod.meta.profile?.address + val profile = pod.meta.profile PodDevice( profileId = profile?.id, label = profile?.label, diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt index b6e4cd53..ab5ed6a8 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt @@ -40,7 +40,6 @@ data class PodDevice( /** BLE scan address (RPA, rotates). */ val bleAddress: BluetoothAddress? get() = ble?.address val identifier: BlePodSnapshot.Id? get() = ble?.identifier - val meta: BlePodSnapshot.Meta? get() = ble?.meta /** True when at least one live data source (BLE or AAP) is present. */ val isLive: Boolean get() = ble != null || aap != null diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodMonitorExtensions.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodMonitorExtensions.kt deleted file mode 100644 index 4ffefc4f..00000000 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodMonitorExtensions.kt +++ /dev/null @@ -1,10 +0,0 @@ -package eu.darken.capod.monitor.core - -import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map - -fun BlePodMonitor.devicesWithProfiles(): Flow> = devices - .map { devices -> devices.filter { it.meta.profile != null } } - -fun BlePodMonitor.primaryDevice(): Flow = devicesWithProfiles().map { it.firstOrNull() } \ No newline at end of file diff --git a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt index 43430fb0..e1368d6c 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt @@ -98,25 +98,21 @@ class PodDeviceTest : BaseTest() { } @Test - fun `identity properties delegate to BLE`() { + fun `identifier delegates to BLE`() { val id = BlePodSnapshot.Id() - val meta = mockk(relaxed = true) val device = PodDevice( profileId = null, ble = mockk(relaxed = true) { every { identifier } returns id - every { this@mockk.meta } returns meta }, aap = null, ) device.identifier shouldBe id - device.meta shouldBe meta } @Test - fun `identity properties null when BLE null`() { + fun `identifier null when BLE null`() { val device = PodDevice(profileId = null, ble = null, aap = null) device.identifier.shouldBeNull() - device.meta.shouldBeNull() } @Test From 942e03af3606263ab88ffd73ecb9fd9d415ce33e Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 2 Apr 2026 16:36:12 +0200 Subject: [PATCH 7/9] refactor: Merge device state persistence into DeviceMonitor Eliminate DeviceStatePersister class by chaining persistence as a side effect in DeviceMonitor's flow. The flow is now shared via replayingShare(appScope) so persistence runs once per emission regardless of subscriber count, and works for BLE-only devices without MonitorService. --- app/src/main/java/eu/darken/capod/App.kt | 1 + .../capod/monitor/core/DeviceMonitor.kt | 65 +++++++ .../monitor/core/DeviceStatePersister.kt | 86 --------- .../monitor/core/worker/MonitorService.kt | 9 +- .../monitor/core/DeviceStatePersisterTest.kt | 163 ------------------ 5 files changed, 68 insertions(+), 256 deletions(-) delete mode 100644 app/src/main/java/eu/darken/capod/monitor/core/DeviceStatePersister.kt delete mode 100644 app/src/test/java/eu/darken/capod/monitor/core/DeviceStatePersisterTest.kt diff --git a/app/src/main/java/eu/darken/capod/App.kt b/app/src/main/java/eu/darken/capod/App.kt index 92caef01..94e54202 100644 --- a/app/src/main/java/eu/darken/capod/App.kt +++ b/app/src/main/java/eu/darken/capod/App.kt @@ -13,6 +13,7 @@ import eu.darken.capod.common.flow.throttleLatest import eu.darken.capod.common.upgrade.UpgradeRepo import eu.darken.capod.main.ui.widget.WidgetManager import eu.darken.capod.monitor.core.DeviceMonitor + import eu.darken.capod.monitor.core.devicesWithProfiles import kotlinx.coroutines.CoroutineScope diff --git a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt index d6936300..cc2e6b36 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt @@ -1,12 +1,23 @@ package eu.darken.capod.monitor.core +import eu.darken.capod.common.coroutine.AppScope +import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag +import eu.darken.capod.common.flow.replayingShare +import eu.darken.capod.monitor.core.CachedDeviceState.CachedBatterySlot import eu.darken.capod.pods.core.apple.aap.AapConnectionManager +import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot +import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot +import eu.darken.capod.pods.core.apple.ble.devices.HasCase import eu.darken.capod.profiles.core.DeviceProfilesRepo +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.onEach +import java.time.Duration +import java.time.Instant import javax.inject.Inject import javax.inject.Singleton @@ -15,10 +26,12 @@ import javax.inject.Singleton * ([AapConnectionManager]) and cached device state ([DeviceStateCache]) into unified [PodDevice] objects. * * Includes cached-only devices for profiles that have cached state but no live BLE data. + * Persists live device state to [DeviceStateCache] as a side effect. * ViewModels should observe [devices] instead of accessing BlePodMonitor directly. */ @Singleton class DeviceMonitor @Inject constructor( + @AppScope private val appScope: CoroutineScope, private val blePodMonitor: BlePodMonitor, private val aapManager: AapConnectionManager, private val deviceStateCache: DeviceStateCache, @@ -55,6 +68,58 @@ class DeviceMonitor @Inject constructor( liveDevices + cachedOnlyDevices } + .onEach { devices -> persistLiveDevices(devices) } + .replayingShare(appScope) + + private suspend fun persistLiveDevices(devices: List) { + for (device in devices) { + if (!device.isLive) continue + val profileId = device.profileId ?: continue + + val now = Instant.now() + val existing = deviceStateCache.cachedStates.value[profileId] + + val liveLeft = device.aap?.batteryLeft ?: (device.ble as? DualBlePodSnapshot)?.batteryLeftPodPercent + val liveRight = device.aap?.batteryRight ?: (device.ble as? DualBlePodSnapshot)?.batteryRightPodPercent + val liveCase = device.aap?.batteryCase ?: (device.ble as? HasCase)?.batteryCasePercent + val liveHeadset = device.aap?.batteryHeadset ?: (device.ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent + + if (liveLeft == null && liveRight == null && liveCase == null && liveHeadset == null) continue + + val newState = CachedDeviceState( + profileId = profileId, + model = device.model, + address = device.address, + left = liveLeft?.let { CachedBatterySlot(it, now) } ?: existing?.left, + right = liveRight?.let { CachedBatterySlot(it, now) } ?: existing?.right, + case = liveCase?.let { CachedBatterySlot(it, now) } ?: existing?.case, + headset = liveHeadset?.let { CachedBatterySlot(it, now) } ?: existing?.headset, + isLeftCharging = device.isLeftPodCharging, + isRightCharging = device.isRightPodCharging, + isCaseCharging = device.isCaseCharging, + isHeadsetCharging = device.isHeadsetBeingCharged, + lastSeenAt = device.seenLastAt ?: now, + ) + + if (isSameState(existing, newState)) continue + + log(TAG, VERBOSE) { "Persisting state for $profileId (L=$liveLeft R=$liveRight C=$liveCase H=$liveHeadset)" } + deviceStateCache.save(profileId, newState) + } + } + + private fun isSameState(old: CachedDeviceState?, new: CachedDeviceState): Boolean { + if (old == null) return false + if (Duration.between(old.lastSeenAt, new.lastSeenAt).abs() > Duration.ofMinutes(1)) return false + return old.left?.percent == new.left?.percent + && old.right?.percent == new.right?.percent + && old.case?.percent == new.case?.percent + && old.headset?.percent == new.headset?.percent + && old.isLeftCharging == new.isLeftCharging + && old.isRightCharging == new.isRightCharging + && old.isCaseCharging == new.isCaseCharging + && old.isHeadsetCharging == new.isHeadsetCharging + } suspend fun getDeviceForProfile(profileId: String): PodDevice? { log(TAG) { "getDeviceForProfile(profileId=$profileId)" } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/DeviceStatePersister.kt b/app/src/main/java/eu/darken/capod/monitor/core/DeviceStatePersister.kt deleted file mode 100644 index e4df4b40..00000000 --- a/app/src/main/java/eu/darken/capod/monitor/core/DeviceStatePersister.kt +++ /dev/null @@ -1,86 +0,0 @@ -package eu.darken.capod.monitor.core - -import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE -import eu.darken.capod.common.debug.logging.log -import eu.darken.capod.common.debug.logging.logTag -import eu.darken.capod.common.flow.setupCommonEventHandlers -import eu.darken.capod.monitor.core.CachedDeviceState.CachedBatterySlot -import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot -import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot -import eu.darken.capod.pods.core.apple.ble.devices.HasCase -import kotlinx.coroutines.flow.Flow -import java.time.Duration -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import java.time.Instant -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Persists combined device state (battery, charging, model) to [DeviceStateCache]. - * Reads from raw BLE/AAP sources to avoid re-persisting cached values. - */ -@Singleton -class DeviceStatePersister @Inject constructor( - private val deviceMonitor: DeviceMonitor, - private val deviceStateCache: DeviceStateCache, -) { - fun monitor(): Flow = deviceMonitor.devices - .onEach { devices -> - for (device in devices) { - if (!device.isLive) continue - val profileId = device.profileId ?: continue - - val now = Instant.now() - val existing = deviceStateCache.cachedStates.value[profileId] - - val liveLeft = device.aap?.batteryLeft ?: (device.ble as? DualBlePodSnapshot)?.batteryLeftPodPercent - val liveRight = device.aap?.batteryRight ?: (device.ble as? DualBlePodSnapshot)?.batteryRightPodPercent - val liveCase = device.aap?.batteryCase ?: (device.ble as? HasCase)?.batteryCasePercent - val liveHeadset = device.aap?.batteryHeadset ?: (device.ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent - - // At least one live battery must be non-null to be worth persisting - if (liveLeft == null && liveRight == null && liveCase == null && liveHeadset == null) continue - - val newState = CachedDeviceState( - profileId = profileId, - model = device.model, - address = device.address, - left = liveLeft?.let { CachedBatterySlot(it, now) } ?: existing?.left, - right = liveRight?.let { CachedBatterySlot(it, now) } ?: existing?.right, - case = liveCase?.let { CachedBatterySlot(it, now) } ?: existing?.case, - headset = liveHeadset?.let { CachedBatterySlot(it, now) } ?: existing?.headset, - isLeftCharging = device.isLeftPodCharging, - isRightCharging = device.isRightPodCharging, - isCaseCharging = device.isCaseCharging, - isHeadsetCharging = device.isHeadsetBeingCharged, - lastSeenAt = device.seenLastAt ?: now, - ) - - if (isSameState(existing, newState)) continue - - log(TAG, VERBOSE) { "Persisting state for $profileId (L=${liveLeft} R=${liveRight} C=${liveCase} H=${liveHeadset})" } - deviceStateCache.save(profileId, newState) - } - } - .map { } - .setupCommonEventHandlers(TAG) { "deviceStatePersister" } - - private fun isSameState(old: CachedDeviceState?, new: CachedDeviceState): Boolean { - if (old == null) return false - // Update lastSeenAt periodically so the staleness label stays fresh when device goes offline - if (Duration.between(old.lastSeenAt, new.lastSeenAt).abs() > Duration.ofMinutes(1)) return false - return old.left?.percent == new.left?.percent - && old.right?.percent == new.right?.percent - && old.case?.percent == new.case?.percent - && old.headset?.percent == new.headset?.percent - && old.isLeftCharging == new.isLeftCharging - && old.isRightCharging == new.isRightCharging - && old.isCaseCharging == new.isCaseCharging - && old.isHeadsetCharging == new.isHeadsetCharging - } - - companion object { - private val TAG = logTag("Monitor", "DeviceStatePersister") - } -} diff --git a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt index 38f0e287..3661860d 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt @@ -36,7 +36,7 @@ import eu.darken.capod.profiles.core.DeviceProfile import eu.darken.capod.profiles.core.DeviceProfilesRepo import eu.darken.capod.pods.core.apple.aap.AapConnectionManager import eu.darken.capod.reaction.core.aap.AapAutoConnect -import eu.darken.capod.monitor.core.DeviceStatePersister + import eu.darken.capod.monitor.core.aap.AapKeyPersister import eu.darken.capod.reaction.core.autoconnect.AutoConnect import eu.darken.capod.reaction.core.playpause.PlayPause @@ -78,7 +78,7 @@ class MonitorService : Service() { @Inject lateinit var profilesRepo: DeviceProfilesRepo @Inject lateinit var aapAutoConnect: AapAutoConnect @Inject lateinit var aapKeyPersister: AapKeyPersister - @Inject lateinit var deviceStatePersister: DeviceStatePersister + @Inject lateinit var aapConnectionManager: AapConnectionManager private val monitorScope = MonitorCoroutineScope() @@ -309,11 +309,6 @@ class MonitorService : Service() { .catch { log(TAG, WARN) { "aapKeyPersister failed:\n${it.asLog()}" } } .launchIn(monitorScope) - deviceStatePersister.monitor() - .setupCommonEventHandlers(TAG) { "deviceStatePersister" } - .catch { log(TAG, WARN) { "deviceStatePersister failed:\n${it.asLog()}" } } - .launchIn(monitorScope) - log(TAG, VERBOSE) { "Monitor job is active" } monitorJob.join() log(TAG, VERBOSE) { "Monitor job quit" } diff --git a/app/src/test/java/eu/darken/capod/monitor/core/DeviceStatePersisterTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/DeviceStatePersisterTest.kt deleted file mode 100644 index aeccf0ef..00000000 --- a/app/src/test/java/eu/darken/capod/monitor/core/DeviceStatePersisterTest.kt +++ /dev/null @@ -1,163 +0,0 @@ -package eu.darken.capod.monitor.core - -import eu.darken.capod.monitor.core.CachedDeviceState -import eu.darken.capod.monitor.core.DeviceMonitor -import eu.darken.capod.monitor.core.DeviceStateCache -import eu.darken.capod.monitor.core.PodDevice -import eu.darken.capod.pods.core.apple.PodModel -import eu.darken.capod.pods.core.apple.ble.devices.ApplePods -import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods -import eu.darken.capod.profiles.core.AppleDeviceProfile -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import testhelpers.BaseTest -import java.time.Instant - -@OptIn(ExperimentalCoroutinesApi::class) -class DeviceStatePersisterTest : BaseTest() { - - private val testDispatcher = UnconfinedTestDispatcher() - - private lateinit var deviceMonitor: DeviceMonitor - private lateinit var deviceStateCache: DeviceStateCache - private lateinit var devicesFlow: MutableStateFlow> - private lateinit var cachedStatesFlow: MutableStateFlow> - - private val testProfile = AppleDeviceProfile( - id = "test-profile", - label = "Test AirPods", - model = PodModel.AIRPODS_PRO3, - address = "AA:BB:CC:DD:EE:FF", - ) - - @BeforeEach - fun setup() { - devicesFlow = MutableStateFlow(emptyList()) - cachedStatesFlow = MutableStateFlow(emptyMap()) - - deviceMonitor = mockk { - every { devices } returns devicesFlow - } - - deviceStateCache = mockk(relaxed = true) { - every { cachedStates } returns cachedStatesFlow - } - } - - private fun createPersister() = DeviceStatePersister( - deviceMonitor = deviceMonitor, - deviceStateCache = deviceStateCache, - ) - - private fun createLiveDevice( - leftBattery: Float? = 0.8f, - rightBattery: Float? = 0.7f, - caseBattery: Float? = 0.5f, - ): PodDevice { - val bleMeta = ApplePods.AppleMeta(profile = testProfile) - val blePod: DualApplePods = mockk(relaxed = true) { - every { meta } returns bleMeta - every { batteryLeftPodPercent } returns leftBattery - every { batteryRightPodPercent } returns rightBattery - every { batteryCasePercent } returns caseBattery - every { isLeftPodCharging } returns false - every { isRightPodCharging } returns false - every { isCaseCharging } returns false - every { model } returns PodModel.AIRPODS_PRO3 - every { address } returns "5A:3B:1C:2D:4E:6F" - every { seenLastAt } returns Instant.now() - } - return PodDevice(profileId = "test-profile", ble = blePod, aap = null, cached = null) - } - - @Nested - inner class Persistence { - - @Test - fun `persists state for live device with battery`() = runTest(testDispatcher) { - val persister = createPersister() - - val job = launch { persister.monitor().toList() } - devicesFlow.value = listOf(createLiveDevice()) - advanceUntilIdle() - - coVerify(exactly = 1) { deviceStateCache.save("test-profile", any()) } - - job.cancel() - } - - @Test - fun `skips device with all null live batteries`() = runTest(testDispatcher) { - val persister = createPersister() - - val job = launch { persister.monitor().toList() } - devicesFlow.value = listOf(createLiveDevice(leftBattery = null, rightBattery = null, caseBattery = null)) - advanceUntilIdle() - - coVerify(exactly = 0) { deviceStateCache.save(any(), any()) } - - job.cancel() - } - - @Test - fun `skips cached-only devices`() = runTest(testDispatcher) { - val persister = createPersister() - val cachedOnlyDevice = PodDevice( - profileId = "test-profile", ble = null, - aap = null, - cached = CachedDeviceState( - profileId = "test-profile", - model = PodModel.AIRPODS_PRO3, - left = CachedDeviceState.CachedBatterySlot(0.5f, Instant.now()), - lastSeenAt = Instant.now(), - ), - ) - - val job = launch { persister.monitor().toList() } - devicesFlow.value = listOf(cachedOnlyDevice) - advanceUntilIdle() - - coVerify(exactly = 0) { deviceStateCache.save(any(), any()) } - - job.cancel() - } - - @Test - fun `skips write when values unchanged`() = runTest(testDispatcher) { - val persister = createPersister() - - val existingCached = CachedDeviceState( - profileId = "test-profile", - model = PodModel.AIRPODS_PRO3, - left = CachedDeviceState.CachedBatterySlot(0.8f, Instant.now()), - right = CachedDeviceState.CachedBatterySlot(0.7f, Instant.now()), - case = CachedDeviceState.CachedBatterySlot(0.5f, Instant.now()), - isLeftCharging = false, - isRightCharging = false, - isCaseCharging = false, - isHeadsetCharging = false, - lastSeenAt = Instant.now(), - ) - cachedStatesFlow.value = mapOf("test-profile" to existingCached) - - val job = launch { persister.monitor().toList() } - devicesFlow.value = listOf(createLiveDevice()) - advanceUntilIdle() - - coVerify(exactly = 0) { deviceStateCache.save(any(), any()) } - - job.cancel() - } - } -} From 30d7efeec2ff34dc4cf2afd34f7e34f035824899 Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 2 Apr 2026 16:40:56 +0200 Subject: [PATCH 8/9] Moving packages around --- .../compose/preview/MockPodDataProvider.kt | 8 +++---- .../capod/monitor/core/DeviceMonitor.kt | 11 +++++---- .../eu/darken/capod/monitor/core/PodDevice.kt | 13 +++++----- .../monitor/core/{ => ble}/BlePodMonitor.kt | 19 ++++++++------- .../core/{ => cache}/CachedDeviceState.kt | 4 ++-- .../core/{ => cache}/DeviceStateCache.kt | 24 ++++++++++--------- .../monitor/core/worker/MonitorService.kt | 7 +++--- .../capod/profiles/core/DeviceProfilesRepo.kt | 4 ++-- .../capod/reaction/core/aap/AapAutoConnect.kt | 4 ++-- .../ui/TroubleShooterViewModel.kt | 2 +- .../capod/monitor/core/PodDeviceCacheTest.kt | 1 + .../reaction/core/aap/AapAutoConnectTest.kt | 4 ++-- 12 files changed, 54 insertions(+), 47 deletions(-) rename app/src/main/java/eu/darken/capod/monitor/core/{ => ble}/BlePodMonitor.kt (91%) rename app/src/main/java/eu/darken/capod/monitor/core/{ => cache}/CachedDeviceState.kt (96%) rename app/src/main/java/eu/darken/capod/monitor/core/{ => cache}/DeviceStateCache.kt (82%) 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 ee36728e..99b7c095 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 @@ -4,20 +4,20 @@ import android.content.Context import eu.darken.capod.R import eu.darken.capod.common.bluetooth.BleScanResult import eu.darken.capod.common.upgrade.UpgradeRepo -import eu.darken.capod.monitor.core.CachedDeviceState import eu.darken.capod.monitor.core.PodDevice +import eu.darken.capod.monitor.core.cache.CachedDeviceState +import eu.darken.capod.pods.core.apple.PodModel import eu.darken.capod.pods.core.apple.aap.AapPodState import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot +import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot +import eu.darken.capod.pods.core.apple.ble.devices.ApplePods import eu.darken.capod.pods.core.apple.ble.devices.HasCase import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetection import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetectionDual import eu.darken.capod.pods.core.apple.ble.devices.HasDualMicrophone import eu.darken.capod.pods.core.apple.ble.devices.HasEarDetection import eu.darken.capod.pods.core.apple.ble.devices.HasEarDetectionDual -import eu.darken.capod.pods.core.apple.PodModel -import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot -import eu.darken.capod.pods.core.apple.ble.devices.ApplePods import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload import eu.darken.capod.pods.core.unknown.UnknownSnapshotBle import eu.darken.capod.profiles.core.AppleDeviceProfile diff --git a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt index cc2e6b36..b32d7276 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt @@ -5,7 +5,10 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.flow.replayingShare -import eu.darken.capod.monitor.core.CachedDeviceState.CachedBatterySlot +import eu.darken.capod.monitor.core.ble.BlePodMonitor +import eu.darken.capod.monitor.core.cache.CachedDeviceState +import eu.darken.capod.monitor.core.cache.CachedDeviceState.CachedBatterySlot +import eu.darken.capod.monitor.core.cache.DeviceStateCache import eu.darken.capod.pods.core.apple.aap.AapConnectionManager import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot @@ -22,11 +25,11 @@ import javax.inject.Inject import javax.inject.Singleton /** - * Single merge point: combines BLE scan data ([BlePodMonitor]) with AAP connection data - * ([AapConnectionManager]) and cached device state ([DeviceStateCache]) into unified [PodDevice] objects. + * Single merge point: combines BLE scan data ([eu.darken.capod.monitor.core.ble.BlePodMonitor]) with AAP connection data + * ([AapConnectionManager]) and cached device state ([eu.darken.capod.monitor.core.cache.DeviceStateCache]) into unified [PodDevice] objects. * * Includes cached-only devices for profiles that have cached state but no live BLE data. - * Persists live device state to [DeviceStateCache] as a side effect. + * Persists live device state to [eu.darken.capod.monitor.core.cache.DeviceStateCache] as a side effect. * ViewModels should observe [devices] instead of accessing BlePodMonitor directly. */ @Singleton diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt index ab5ed6a8..fadb3e59 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt @@ -4,20 +4,21 @@ import android.content.Context import androidx.compose.runtime.Stable import eu.darken.capod.R import eu.darken.capod.common.bluetooth.BluetoothAddress +import eu.darken.capod.monitor.core.cache.CachedDeviceState +import eu.darken.capod.pods.core.apple.PodModel +import eu.darken.capod.pods.core.apple.aap.AapPodState +import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot +import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot +import eu.darken.capod.pods.core.apple.ble.devices.ApplePods +import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods import eu.darken.capod.pods.core.apple.ble.devices.HasCase import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetection import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetectionDual import eu.darken.capod.pods.core.apple.ble.devices.HasDualMicrophone import eu.darken.capod.pods.core.apple.ble.devices.HasEarDetection import eu.darken.capod.pods.core.apple.ble.devices.HasEarDetectionDual -import eu.darken.capod.pods.core.apple.PodModel -import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot -import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods -import eu.darken.capod.pods.core.apple.ble.devices.ApplePods -import eu.darken.capod.pods.core.apple.aap.AapPodState -import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import java.time.Duration import java.time.Instant diff --git a/app/src/main/java/eu/darken/capod/monitor/core/BlePodMonitor.kt b/app/src/main/java/eu/darken/capod/monitor/core/ble/BlePodMonitor.kt similarity index 91% rename from app/src/main/java/eu/darken/capod/monitor/core/BlePodMonitor.kt rename to app/src/main/java/eu/darken/capod/monitor/core/ble/BlePodMonitor.kt index 660baebb..58532147 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/BlePodMonitor.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/ble/BlePodMonitor.kt @@ -1,4 +1,4 @@ -package eu.darken.capod.monitor.core +package eu.darken.capod.monitor.core.ble import android.bluetooth.le.ScanFilter import eu.darken.capod.common.bluetooth.BleScanResult @@ -8,8 +8,7 @@ import eu.darken.capod.common.bluetooth.ScannerMode import eu.darken.capod.common.bluetooth.onlyNewAndUnique import eu.darken.capod.common.coroutine.AppScope import eu.darken.capod.common.debug.DebugSettings -import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE -import eu.darken.capod.common.debug.logging.Logging.Priority.WARN +import eu.darken.capod.common.debug.logging.Logging import eu.darken.capod.common.debug.logging.asLog import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag @@ -26,7 +25,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf @@ -65,7 +63,7 @@ class BlePodMonitor @Inject constructor( } .flatMapLatest { isReady -> if (!isReady) { - log(TAG, WARN) { "Bluetooth is not ready" } + log(TAG, Logging.Priority.WARN) { "Bluetooth is not ready" } flowOf(null) } else { val staleEvictionTicker: Flow> = flow { @@ -84,10 +82,13 @@ class BlePodMonitor @Inject constructor( } .retryWhen { cause, attempt -> if (cause is SecurityException) { - log(TAG, WARN) { "PodMonitor failed due to missing permission, not retrying: ${cause.asLog()}" } + log( + TAG, + Logging.Priority.WARN + ) { "PodMonitor failed due to missing permission, not retrying: ${cause.asLog()}" } false } else { - log(TAG, WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" } + log(TAG, Logging.Priority.WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" } delay(3000) true } @@ -148,7 +149,7 @@ class BlePodMonitor @Inject constructor( .flatMapLatest { options -> val filters = when { options.showUnfiltered -> { - log(TAG, WARN) { "Using unfiltered scan mode" } + log(TAG, Logging.Priority.WARN) { "Using unfiltered scan mode" } setOf(ScanFilter.Builder().build()) } @@ -177,7 +178,7 @@ class BlePodMonitor @Inject constructor( val now = Instant.now() deviceCache.toList().forEach { (key, value) -> if (Duration.between(value.seenLastAt, now) > STALE_DEVICE_TIMEOUT) { - log(TAG, VERBOSE) { "Removing stale device from cache: $value" } + log(TAG, Logging.Priority.VERBOSE) { "Removing stale device from cache: $value" } deviceCache.remove(key) } } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/CachedDeviceState.kt b/app/src/main/java/eu/darken/capod/monitor/core/cache/CachedDeviceState.kt similarity index 96% rename from app/src/main/java/eu/darken/capod/monitor/core/CachedDeviceState.kt rename to app/src/main/java/eu/darken/capod/monitor/core/cache/CachedDeviceState.kt index 8df3b3f9..c07e99f3 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/CachedDeviceState.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/cache/CachedDeviceState.kt @@ -1,4 +1,4 @@ -package eu.darken.capod.monitor.core +package eu.darken.capod.monitor.core.cache import eu.darken.capod.common.serialization.InstantEpochMillisSerializer import eu.darken.capod.pods.core.apple.PodModel @@ -28,4 +28,4 @@ data class CachedDeviceState( @Serializable(with = InstantEpochMillisSerializer::class) @SerialName("updatedAt") val updatedAt: Instant, ) -} +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/monitor/core/DeviceStateCache.kt b/app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCache.kt similarity index 82% rename from app/src/main/java/eu/darken/capod/monitor/core/DeviceStateCache.kt rename to app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCache.kt index 64fdc6d0..66f6e9ec 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/DeviceStateCache.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCache.kt @@ -1,11 +1,10 @@ -package eu.darken.capod.monitor.core +package eu.darken.capod.monitor.core.cache import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext import eu.darken.capod.common.coroutine.AppScope import eu.darken.capod.common.coroutine.DispatcherProvider -import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR -import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.capod.common.debug.logging.Logging import eu.darken.capod.common.debug.logging.asLog import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag @@ -54,11 +53,14 @@ class DeviceStateCache @Inject constructor( val state = json.decodeFromString(file.readText()) loaded[profileId] = state } catch (e: Exception) { - log(TAG, ERROR) { "Failed to load cached state from ${file.name}: ${e.asLog()}, deleting" } + log( + TAG, + Logging.Priority.ERROR + ) { "Failed to load cached state from ${file.name}: ${e.asLog()}, deleting" } file.delete() } } - log(TAG, VERBOSE) { "loadAll(): loaded ${loaded.size} entries" } + log(TAG, Logging.Priority.VERBOSE) { "loadAll(): loaded ${loaded.size} entries" } _cachedStates.value = loaded } } @@ -67,13 +69,13 @@ class DeviceStateCache @Inject constructor( suspend fun save(id: ProfileId, state: CachedDeviceState) = withContext(dispatcherProvider.IO) { lock.withLock { - log(TAG, VERBOSE) { "save(id=$id)" } + log(TAG, Logging.Priority.VERBOSE) { "save(id=$id)" } val file = id.toCacheFile() try { file.writeText(json.encodeToString(CachedDeviceState.serializer(), state)) _cachedStates.value += (id to state) } catch (e: Exception) { - log(TAG, ERROR) { "Failed to save state for $id: ${e.asLog()}" } + log(TAG, Logging.Priority.ERROR) { "Failed to save state for $id: ${e.asLog()}" } file.delete() } } @@ -89,7 +91,7 @@ class DeviceStateCache @Inject constructor( try { json.decodeFromString(file.readText()) } catch (e: Exception) { - log(TAG, ERROR) { "Failed to load state for $id: ${e.asLog()}, deleting" } + log(TAG, Logging.Priority.ERROR) { "Failed to load state for $id: ${e.asLog()}, deleting" } file.delete() null } @@ -98,7 +100,7 @@ class DeviceStateCache @Inject constructor( suspend fun delete(id: ProfileId) = withContext(dispatcherProvider.IO) { lock.withLock { - log(TAG, VERBOSE) { "delete(id=$id)" } + log(TAG, Logging.Priority.VERBOSE) { "delete(id=$id)" } id.toCacheFile().delete() _cachedStates.value -= id } @@ -106,7 +108,7 @@ class DeviceStateCache @Inject constructor( suspend fun deleteAll() = withContext(dispatcherProvider.IO) { lock.withLock { - log(TAG, VERBOSE) { "deleteAll()" } + log(TAG, Logging.Priority.VERBOSE) { "deleteAll()" } cacheDir.listFiles()?.forEach { it.delete() } _cachedStates.value = emptyMap() } @@ -115,4 +117,4 @@ class DeviceStateCache @Inject constructor( companion object { private val TAG = logTag("Monitor", "DeviceStateCache") } -} +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt index 3661860d..32627e10 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt @@ -27,17 +27,16 @@ import eu.darken.capod.common.hasApiLevel import eu.darken.capod.main.core.GeneralSettings import eu.darken.capod.main.core.MonitorMode import eu.darken.capod.main.core.PermissionTool -import eu.darken.capod.monitor.core.BlePodMonitor import eu.darken.capod.monitor.core.DeviceMonitor import eu.darken.capod.monitor.core.MonitorCoroutineScope +import eu.darken.capod.monitor.core.aap.AapKeyPersister +import eu.darken.capod.monitor.core.ble.BlePodMonitor import eu.darken.capod.monitor.core.primaryDevice import eu.darken.capod.monitor.ui.MonitorNotifications +import eu.darken.capod.pods.core.apple.aap.AapConnectionManager import eu.darken.capod.profiles.core.DeviceProfile import eu.darken.capod.profiles.core.DeviceProfilesRepo -import eu.darken.capod.pods.core.apple.aap.AapConnectionManager import eu.darken.capod.reaction.core.aap.AapAutoConnect - -import eu.darken.capod.monitor.core.aap.AapKeyPersister import eu.darken.capod.reaction.core.autoconnect.AutoConnect import eu.darken.capod.reaction.core.playpause.PlayPause import eu.darken.capod.reaction.core.popup.PopUpReaction diff --git a/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesRepo.kt b/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesRepo.kt index ac7a3f94..438d13a2 100644 --- a/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesRepo.kt +++ b/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesRepo.kt @@ -4,11 +4,12 @@ import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext import eu.darken.capod.R import eu.darken.capod.common.coroutine.AppScope +import eu.darken.capod.common.datastore.valueBlocking import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.main.core.GeneralSettings -import eu.darken.capod.monitor.core.DeviceStateCache +import eu.darken.capod.monitor.core.cache.DeviceStateCache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -17,7 +18,6 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import javax.inject.Inject import javax.inject.Singleton -import eu.darken.capod.common.datastore.valueBlocking @Singleton class DeviceProfilesRepo @Inject constructor( diff --git a/app/src/main/java/eu/darken/capod/reaction/core/aap/AapAutoConnect.kt b/app/src/main/java/eu/darken/capod/reaction/core/aap/AapAutoConnect.kt index 0f121301..8a7f1811 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/aap/AapAutoConnect.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/aap/AapAutoConnect.kt @@ -6,7 +6,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.WARN import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.flow.setupCommonEventHandlers -import eu.darken.capod.monitor.core.BlePodMonitor +import eu.darken.capod.monitor.core.ble.BlePodMonitor import eu.darken.capod.pods.core.apple.PodModel import eu.darken.capod.pods.core.apple.aap.AapConnectionManager import eu.darken.capod.pods.core.apple.aap.AapPodState @@ -24,9 +24,9 @@ import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout -import kotlin.time.Duration.Companion.seconds import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Duration.Companion.seconds @Singleton class AapAutoConnect @Inject constructor( diff --git a/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterViewModel.kt b/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterViewModel.kt index c778a78b..09b0600a 100644 --- a/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterViewModel.kt +++ b/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterViewModel.kt @@ -13,8 +13,8 @@ import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.uix.ViewModel4 import eu.darken.capod.main.core.GeneralSettings -import eu.darken.capod.monitor.core.BlePodMonitor import eu.darken.capod.monitor.core.DeviceMonitor +import eu.darken.capod.monitor.core.ble.BlePodMonitor import eu.darken.capod.monitor.core.primaryDevice import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot import eu.darken.capod.pods.core.unknown.UnknownSnapshotBle diff --git a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceCacheTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceCacheTest.kt index 41dba4fb..53ff609e 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceCacheTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceCacheTest.kt @@ -1,5 +1,6 @@ package eu.darken.capod.monitor.core +import eu.darken.capod.monitor.core.cache.CachedDeviceState import eu.darken.capod.pods.core.apple.PodModel import eu.darken.capod.pods.core.apple.aap.AapPodState import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods diff --git a/app/src/test/java/eu/darken/capod/reaction/core/aap/AapAutoConnectTest.kt b/app/src/test/java/eu/darken/capod/reaction/core/aap/AapAutoConnectTest.kt index 8b573892..d46d1c69 100644 --- a/app/src/test/java/eu/darken/capod/reaction/core/aap/AapAutoConnectTest.kt +++ b/app/src/test/java/eu/darken/capod/reaction/core/aap/AapAutoConnectTest.kt @@ -2,12 +2,12 @@ package eu.darken.capod.reaction.core.aap import eu.darken.capod.common.bluetooth.BluetoothDevice2 import eu.darken.capod.common.bluetooth.BluetoothManager2 -import eu.darken.capod.monitor.core.BlePodMonitor -import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot +import eu.darken.capod.monitor.core.ble.BlePodMonitor import eu.darken.capod.pods.core.apple.PodModel import eu.darken.capod.pods.core.apple.aap.AapConnectionManager import eu.darken.capod.pods.core.apple.aap.AapPodState import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo +import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot import eu.darken.capod.profiles.core.AppleDeviceProfile import eu.darken.capod.profiles.core.DeviceProfile import eu.darken.capod.profiles.core.DeviceProfilesRepo From 7c378540765c9ef92aad6effd57519e2f5a90950 Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 2 Apr 2026 16:46:56 +0200 Subject: [PATCH 9/9] refactor: Extract toCachedState extension and add tests Move per-device persist logic from DeviceMonitor into a pure PodDevice.toCachedState() extension in the cache package. Add ToCachedStateTest with coverage for creates, skips, dedup, and slot preservation. Delete unused PodSorter. --- .../capod/monitor/core/DeviceMonitor.kt | 51 +------ .../monitor/core/DeviceMonitorExtensions.kt | 1 + .../eu/darken/capod/monitor/core/PodSorter.kt | 9 -- .../core/cache/DeviceStateCacheExtensions.kt | 63 ++++++++ .../monitor/core/cache/ToCachedStateTest.kt | 138 ++++++++++++++++++ 5 files changed, 205 insertions(+), 57 deletions(-) delete mode 100644 app/src/main/java/eu/darken/capod/monitor/core/PodSorter.kt create mode 100644 app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCacheExtensions.kt create mode 100644 app/src/test/java/eu/darken/capod/monitor/core/cache/ToCachedStateTest.kt diff --git a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt index b32d7276..9b970bb8 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt @@ -6,21 +6,15 @@ import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.flow.replayingShare import eu.darken.capod.monitor.core.ble.BlePodMonitor -import eu.darken.capod.monitor.core.cache.CachedDeviceState -import eu.darken.capod.monitor.core.cache.CachedDeviceState.CachedBatterySlot import eu.darken.capod.monitor.core.cache.DeviceStateCache +import eu.darken.capod.monitor.core.cache.toCachedState import eu.darken.capod.pods.core.apple.aap.AapConnectionManager -import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot -import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot -import eu.darken.capod.pods.core.apple.ble.devices.HasCase import eu.darken.capod.profiles.core.DeviceProfilesRepo import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.onEach -import java.time.Duration -import java.time.Instant import javax.inject.Inject import javax.inject.Singleton @@ -76,54 +70,15 @@ class DeviceMonitor @Inject constructor( private suspend fun persistLiveDevices(devices: List) { for (device in devices) { - if (!device.isLive) continue val profileId = device.profileId ?: continue - - val now = Instant.now() val existing = deviceStateCache.cachedStates.value[profileId] + val newState = device.toCachedState(existing) ?: continue - val liveLeft = device.aap?.batteryLeft ?: (device.ble as? DualBlePodSnapshot)?.batteryLeftPodPercent - val liveRight = device.aap?.batteryRight ?: (device.ble as? DualBlePodSnapshot)?.batteryRightPodPercent - val liveCase = device.aap?.batteryCase ?: (device.ble as? HasCase)?.batteryCasePercent - val liveHeadset = device.aap?.batteryHeadset ?: (device.ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent - - if (liveLeft == null && liveRight == null && liveCase == null && liveHeadset == null) continue - - val newState = CachedDeviceState( - profileId = profileId, - model = device.model, - address = device.address, - left = liveLeft?.let { CachedBatterySlot(it, now) } ?: existing?.left, - right = liveRight?.let { CachedBatterySlot(it, now) } ?: existing?.right, - case = liveCase?.let { CachedBatterySlot(it, now) } ?: existing?.case, - headset = liveHeadset?.let { CachedBatterySlot(it, now) } ?: existing?.headset, - isLeftCharging = device.isLeftPodCharging, - isRightCharging = device.isRightPodCharging, - isCaseCharging = device.isCaseCharging, - isHeadsetCharging = device.isHeadsetBeingCharged, - lastSeenAt = device.seenLastAt ?: now, - ) - - if (isSameState(existing, newState)) continue - - log(TAG, VERBOSE) { "Persisting state for $profileId (L=$liveLeft R=$liveRight C=$liveCase H=$liveHeadset)" } + log(TAG, VERBOSE) { "Persisting state for $profileId" } deviceStateCache.save(profileId, newState) } } - private fun isSameState(old: CachedDeviceState?, new: CachedDeviceState): Boolean { - if (old == null) return false - if (Duration.between(old.lastSeenAt, new.lastSeenAt).abs() > Duration.ofMinutes(1)) return false - return old.left?.percent == new.left?.percent - && old.right?.percent == new.right?.percent - && old.case?.percent == new.case?.percent - && old.headset?.percent == new.headset?.percent - && old.isLeftCharging == new.isLeftCharging - && old.isRightCharging == new.isRightCharging - && old.isCaseCharging == new.isCaseCharging - && old.isHeadsetCharging == new.isHeadsetCharging - } - suspend fun getDeviceForProfile(profileId: String): PodDevice? { log(TAG) { "getDeviceForProfile(profileId=$profileId)" } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitorExtensions.kt b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitorExtensions.kt index 04f8567d..eed95c24 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitorExtensions.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitorExtensions.kt @@ -70,3 +70,4 @@ fun PodDevice.cachedBatteryFormatted(now: Instant): String { ) } } + diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodSorter.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodSorter.kt deleted file mode 100644 index 4722c106..00000000 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodSorter.kt +++ /dev/null @@ -1,9 +0,0 @@ -package eu.darken.capod.monitor.core - -import dagger.Reusable -import javax.inject.Inject - -@Reusable -class PodSorter @Inject constructor( - -) \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCacheExtensions.kt b/app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCacheExtensions.kt new file mode 100644 index 00000000..617a3531 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCacheExtensions.kt @@ -0,0 +1,63 @@ +package eu.darken.capod.monitor.core.cache + +import eu.darken.capod.monitor.core.PodDevice +import eu.darken.capod.monitor.core.cache.CachedDeviceState.CachedBatterySlot +import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot +import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot +import eu.darken.capod.pods.core.apple.ble.devices.HasCase +import java.time.Duration +import java.time.Instant + +/** + * Creates a [CachedDeviceState] from this live device's raw BLE/AAP battery data. + * Returns null if: + * - The device is not live (cached-only) + * - The device has no profile + * - All live battery values are null + * - The state hasn't changed from [existing] (dedup) + */ +fun PodDevice.toCachedState( + existing: CachedDeviceState?, + now: Instant = Instant.now(), +): CachedDeviceState? { + if (!isLive) return null + val pid = profileId ?: return null + + val liveLeft = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent + val liveRight = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent + val liveCase = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent + val liveHeadset = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent + + if (liveLeft == null && liveRight == null && liveCase == null && liveHeadset == null) return null + + val newState = CachedDeviceState( + profileId = pid, + model = model, + address = address, + left = liveLeft?.let { CachedBatterySlot(it, now) } ?: existing?.left, + right = liveRight?.let { CachedBatterySlot(it, now) } ?: existing?.right, + case = liveCase?.let { CachedBatterySlot(it, now) } ?: existing?.case, + headset = liveHeadset?.let { CachedBatterySlot(it, now) } ?: existing?.headset, + isLeftCharging = isLeftPodCharging, + isRightCharging = isRightPodCharging, + isCaseCharging = isCaseCharging, + isHeadsetCharging = isHeadsetBeingCharged, + lastSeenAt = seenLastAt ?: now, + ) + + if (existing != null && !hasStateChanged(existing, newState)) return null + + return newState +} + +private fun hasStateChanged(old: CachedDeviceState, new: CachedDeviceState): Boolean { + if (Duration.between(old.lastSeenAt, new.lastSeenAt).abs() > Duration.ofMinutes(1)) return true + return old.left?.percent != new.left?.percent + || old.right?.percent != new.right?.percent + || old.case?.percent != new.case?.percent + || old.headset?.percent != new.headset?.percent + || old.isLeftCharging != new.isLeftCharging + || old.isRightCharging != new.isRightCharging + || old.isCaseCharging != new.isCaseCharging + || old.isHeadsetCharging != new.isHeadsetCharging +} diff --git a/app/src/test/java/eu/darken/capod/monitor/core/cache/ToCachedStateTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/cache/ToCachedStateTest.kt new file mode 100644 index 00000000..c6c8aef6 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/monitor/core/cache/ToCachedStateTest.kt @@ -0,0 +1,138 @@ +package eu.darken.capod.monitor.core.cache + +import eu.darken.capod.monitor.core.PodDevice +import eu.darken.capod.pods.core.apple.PodModel +import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import java.time.Instant + +class ToCachedStateTest : BaseTest() { + + private val now = Instant.parse("2026-04-02T12:00:00Z") + + private fun mockDualPod( + leftBattery: Float? = null, + rightBattery: Float? = null, + caseBattery: Float? = null, + ): DualApplePods = mockk(relaxed = true) { + every { batteryLeftPodPercent } returns leftBattery + every { batteryRightPodPercent } returns rightBattery + every { batteryCasePercent } returns caseBattery + every { model } returns PodModel.AIRPODS_PRO3 + every { seenLastAt } returns now + } + + private fun liveDevice( + leftBattery: Float? = 0.8f, + rightBattery: Float? = 0.7f, + caseBattery: Float? = 0.5f, + ) = PodDevice( + profileId = "test-profile", + ble = mockDualPod(leftBattery, rightBattery, caseBattery), + aap = null, + ) + + @Nested + inner class Creates { + + @Test + fun `creates cached state from live device with battery`() { + val result = liveDevice().toCachedState(existing = null, now = now) + result.shouldNotBeNull() + result.profileId shouldBe "test-profile" + result.left?.percent shouldBe 0.8f + result.right?.percent shouldBe 0.7f + result.case?.percent shouldBe 0.5f + result.lastSeenAt shouldBe now + } + + @Test + fun `preserves existing slots when live value is null`() { + val existing = CachedDeviceState( + profileId = "test-profile", + model = PodModel.AIRPODS_PRO3, + left = CachedDeviceState.CachedBatterySlot(0.9f, now.minusSeconds(60)), + lastSeenAt = now.minusSeconds(120), + ) + val result = liveDevice(leftBattery = null, caseBattery = 0.6f).toCachedState(existing, now) + result.shouldNotBeNull() + result.left?.percent shouldBe 0.9f + result.case?.percent shouldBe 0.6f + } + } + + @Nested + inner class Skips { + + @Test + fun `returns null for cached-only device`() { + val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = mockk(relaxed = true)) + device.toCachedState(existing = null, now = now).shouldBeNull() + } + + @Test + fun `returns null when no profile`() { + val device = PodDevice(profileId = null, ble = mockDualPod(caseBattery = 0.5f), aap = null) + device.toCachedState(existing = null, now = now).shouldBeNull() + } + + @Test + fun `returns null when all battery values null`() { + liveDevice(leftBattery = null, rightBattery = null, caseBattery = null) + .toCachedState(existing = null, now = now) + .shouldBeNull() + } + + @Test + fun `returns null when state unchanged`() { + val existing = CachedDeviceState( + profileId = "test-profile", + model = PodModel.AIRPODS_PRO3, + left = CachedDeviceState.CachedBatterySlot(0.8f, now), + right = CachedDeviceState.CachedBatterySlot(0.7f, now), + case = CachedDeviceState.CachedBatterySlot(0.5f, now), + isLeftCharging = false, + isRightCharging = false, + isCaseCharging = false, + isHeadsetCharging = false, + lastSeenAt = now, + ) + liveDevice().toCachedState(existing, now).shouldBeNull() + } + + @Test + fun `returns new state when battery changed`() { + val existing = CachedDeviceState( + profileId = "test-profile", + model = PodModel.AIRPODS_PRO3, + left = CachedDeviceState.CachedBatterySlot(0.9f, now), + lastSeenAt = now, + ) + liveDevice(leftBattery = 0.8f).toCachedState(existing, now).shouldNotBeNull() + } + + @Test + fun `returns new state when lastSeenAt drifted over 1 minute`() { + val existing = CachedDeviceState( + profileId = "test-profile", + model = PodModel.AIRPODS_PRO3, + left = CachedDeviceState.CachedBatterySlot(0.8f, now.minusSeconds(120)), + right = CachedDeviceState.CachedBatterySlot(0.7f, now.minusSeconds(120)), + case = CachedDeviceState.CachedBatterySlot(0.5f, now.minusSeconds(120)), + isLeftCharging = false, + isRightCharging = false, + isCaseCharging = false, + isHeadsetCharging = false, + lastSeenAt = now.minusSeconds(120), + ) + liveDevice().toCachedState(existing, now).shouldNotBeNull() + } + } +}