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/common/compose/preview/MockPodDataProvider.kt b/app/src/main/java/eu/darken/capod/common/compose/preview/MockPodDataProvider.kt index 1bc1a2c4..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 @@ -5,18 +5,19 @@ 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.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 @@ -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..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 @@ -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( @@ -91,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, @@ -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..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 @@ -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( @@ -99,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, @@ -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/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/monitor/core/DeviceMonitor.kt b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt index 800bf90c..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 @@ -1,44 +1,102 @@ 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.ble.BlePodMonitor +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.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 javax.inject.Inject import javax.inject.Singleton /** - * Single merge point: combines BLE scan data ([BlePodMonitor]) with AAP connection data - * ([AapConnectionManager]) 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 [eu.darken.capod.monitor.core.cache.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, + 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 profile = pod.meta.profile + PodDevice( + profileId = profile?.id, + label = profile?.label, + ble = pod, + aap = bondedAddress?.let { aapStates[it] }, + cached = profile?.id?.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, label = profile.label, ble = null, aap = null, cached = it) + } + } + + liveDevices + cachedOnlyDevices + } + .onEach { devices -> persistLiveDevices(devices) } + .replayingShare(appScope) + + private suspend fun persistLiveDevices(devices: List) { + for (device in devices) { + val profileId = device.profileId ?: continue + val existing = deviceStateCache.cachedStates.value[profileId] + val newState = device.toCachedState(existing) ?: continue + + log(TAG, VERBOSE) { "Persisting state for $profileId" } + deviceStateCache.save(profileId, newState) + } + } + 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" } + 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" } + 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..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 @@ -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,27 @@ 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/PodDevice.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt index fde09bff..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 @@ -28,17 +29,21 @@ 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, ) { - // 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 @@ -48,7 +53,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 +75,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/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/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/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/BlePodMonitor.kt b/app/src/main/java/eu/darken/capod/monitor/core/ble/BlePodMonitor.kt similarity index 79% 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 6eee861f..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,14 +1,14 @@ -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 import eu.darken.capod.common.bluetooth.BleScanner import eu.darken.capod.common.bluetooth.BluetoothManager2 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 @@ -25,10 +25,11 @@ 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 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 @@ -46,7 +47,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, ) { @@ -63,10 +63,16 @@ 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 { - createBleScanner() + val staleEvictionTicker: Flow> = flow { + while (true) { + delay(STALE_EVICTION_INTERVAL.toMillis()) + emit(emptyList()) + } + } + merge(createBleScanner(), staleEvictionTicker) } } .map { results -> results?.mapNotNull { podFactory.createPod(it) } } @@ -76,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 } @@ -140,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()) } @@ -168,8 +177,8 @@ class BlePodMonitor @Inject constructor( val now = Instant.now() deviceCache.toList().forEach { (key, value) -> - if (Duration.between(value.seenLastAt, now) > Duration.ofSeconds(20)) { - log(TAG, VERBOSE) { "Removing stale device from cache: $value" } + if (Duration.between(value.seenLastAt, now) > STALE_DEVICE_TIMEOUT) { + log(TAG, Logging.Priority.VERBOSE) { "Removing stale device from cache: $value" } deviceCache.remove(key) } } @@ -183,35 +192,12 @@ 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") + 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/monitor/core/cache/CachedDeviceState.kt b/app/src/main/java/eu/darken/capod/monitor/core/cache/CachedDeviceState.kt new file mode 100644 index 00000000..c07e99f3 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/monitor/core/cache/CachedDeviceState.kt @@ -0,0 +1,31 @@ +package eu.darken.capod.monitor.core.cache + +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, + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCache.kt b/app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCache.kt new file mode 100644 index 00000000..66f6e9ec --- /dev/null +++ b/app/src/main/java/eu/darken/capod/monitor/core/cache/DeviceStateCache.kt @@ -0,0 +1,120 @@ +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 +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, + Logging.Priority.ERROR + ) { "Failed to load cached state from ${file.name}: ${e.asLog()}, deleting" } + file.delete() + } + } + log(TAG, Logging.Priority.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, 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, Logging.Priority.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, Logging.Priority.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, Logging.Priority.VERBOSE) { "delete(id=$id)" } + id.toCacheFile().delete() + _cachedStates.value -= id + } + } + + suspend fun deleteAll() = withContext(dispatcherProvider.IO) { + lock.withLock { + log(TAG, Logging.Priority.VERBOSE) { "deleteAll()" } + cacheDir.listFiles()?.forEach { it.delete() } + _cachedStates.value = emptyMap() + } + } + + 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/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/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..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,16 +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.reaction.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 @@ -77,6 +77,7 @@ class MonitorService : Service() { @Inject lateinit var profilesRepo: DeviceProfilesRepo @Inject lateinit var aapAutoConnect: AapAutoConnect @Inject lateinit var aapKeyPersister: AapKeyPersister + @Inject lateinit var aapConnectionManager: AapConnectionManager private val monitorScope = MonitorCoroutineScope() 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..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.PodDeviceCache +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( @@ -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/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/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" } } 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/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..53ff609e --- /dev/null +++ b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceCacheTest.kt @@ -0,0 +1,202 @@ +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 +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..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 @@ -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,36 +87,32 @@ 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 } @Test - fun `identity properties delegate to BLE`() { + fun `identifier delegates to BLE`() { 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 }, aap = null, ) device.identifier shouldBe id - device.meta shouldBe meta } @Test - fun `identity properties null when BLE null`() { - val device = PodDevice(ble = null, aap = null) + fun `identifier null when BLE null`() { + val device = PodDevice(profileId = null, ble = null, aap = null) device.identifier.shouldBeNull() - device.meta.shouldBeNull() } @Test @@ -124,7 +120,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 +136,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 +151,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 +166,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 +188,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 +199,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 +218,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 +238,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 +257,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 +275,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 +297,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 +318,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 +339,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 +357,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 +370,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 +390,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 +410,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 +421,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 +452,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 +472,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 +488,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 +504,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 +516,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 +623,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 +640,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 +651,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 +665,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/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() + } + } +} 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