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