Merge pull request #469 from d4rken-org/feat/device-state-cache

Device: Remember battery levels and show offline device cards
This commit is contained in:
Matthias Urhahn
2026-04-02 17:36:59 +02:00
committed by GitHub
30 changed files with 928 additions and 297 deletions
+1
View File
@@ -13,6 +13,7 @@ import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.common.upgrade.UpgradeRepo import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.ui.widget.WidgetManager import eu.darken.capod.main.ui.widget.WidgetManager
import eu.darken.capod.monitor.core.DeviceMonitor import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.devicesWithProfiles import eu.darken.capod.monitor.core.devicesWithProfiles
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -5,18 +5,19 @@ import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.upgrade.UpgradeRepo import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.monitor.core.PodDevice 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.aap.AapPodState
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot 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.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.HasCase
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetection 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.HasChargeDetectionDual
import eu.darken.capod.pods.core.apple.ble.devices.HasDualMicrophone 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.HasEarDetection
import eu.darken.capod.pods.core.apple.ble.devices.HasEarDetectionDual 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.apple.ble.protocol.ProximityPayload
import eu.darken.capod.pods.core.unknown.UnknownSnapshotBle import eu.darken.capod.pods.core.unknown.UnknownSnapshotBle
import eu.darken.capod.profiles.core.AppleDeviceProfile import eu.darken.capod.profiles.core.AppleDeviceProfile
@@ -165,35 +166,73 @@ object MockPodDataProvider {
// --- PodDevice wrappers --- // --- PodDevice wrappers ---
fun dualPodMonitored(): PodDevice = PodDevice( fun dualPodMonitored(): PodDevice = PodDevice(
profileId = "preview-dual",
ble = airPodsProFullCharge(), ble = airPodsProFullCharge(),
aap = null, aap = null,
) )
fun dualPodMonitoredMixed(): PodDevice = PodDevice( fun dualPodMonitoredMixed(): PodDevice = PodDevice(
profileId = "preview-dual-mixed",
ble = airPodsProMixed(), ble = airPodsProMixed(),
aap = null, aap = null,
) )
fun dualPodMonitoredWithKeys(): PodDevice = PodDevice( fun dualPodMonitoredWithKeys(): PodDevice = PodDevice(
profileId = "preview-dual-keys",
ble = airPodsProWithKeys(), ble = airPodsProWithKeys(),
aap = null, aap = null,
) )
fun dualPodMonitoredWithAap(): PodDevice = PodDevice( fun dualPodMonitoredWithAap(): PodDevice = PodDevice(
profileId = "preview-dual-aap",
ble = airPodsProWithKeys(), ble = airPodsProWithKeys(),
aap = AapPodState(connectionState = AapPodState.ConnectionState.READY), aap = AapPodState(connectionState = AapPodState.ConnectionState.READY),
) )
fun singlePodMonitored(): PodDevice = PodDevice( fun singlePodMonitored(): PodDevice = PodDevice(
profileId = "preview-single",
ble = airPodsMax(), ble = airPodsMax(),
aap = null, aap = null,
) )
fun unknownMonitored(): PodDevice = PodDevice( fun unknownMonitored(): PodDevice = PodDevice(
profileId = null,
ble = unknownDevice(), ble = unknownDevice(),
aap = null, 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 --- // --- UpgradeInfo ---
fun fossInfo(isPro: Boolean = false): UpgradeRepo.Info = MockUpgradeInfo( fun fossInfo(isPro: Boolean = false): UpgradeRepo.Info = MockUpgradeInfo(
@@ -134,8 +134,8 @@ class OverviewViewModel @Inject constructor(
val showUnmatchedDevices: Boolean, val showUnmatchedDevices: Boolean,
) { ) {
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking } val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
val profiledDevices: List<PodDevice> get() = devices.filter { it.meta?.profile != null } val profiledDevices: List<PodDevice> get() = devices.filter { it.profileId != null }
val unmatchedDevices: List<PodDevice> get() = devices.filter { it.meta?.profile == null } val unmatchedDevices: List<PodDevice> get() = devices.filter { it.profileId == null }
} }
fun onPermissionResult(@Suppress("UNUSED_PARAMETER") granted: Boolean) { fun onPermissionResult(@Suppress("UNUSED_PARAMETER") granted: Boolean) {
@@ -31,6 +31,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource 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.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.monitor.core.PodDevice 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.firstSeenFormatted
import eu.darken.capod.monitor.core.getSignalQuality import eu.darken.capod.monitor.core.getSignalQuality
import eu.darken.capod.monitor.core.lastSeenFormatted import eu.darken.capod.monitor.core.lastSeenFormatted
@@ -74,7 +76,9 @@ fun DualPodsCard(
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp), elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
) { ) {
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier
.padding(16.dp)
.then(if (!device.isLive) Modifier.alpha(0.7f) else Modifier),
) { ) {
// Header // Header
Row( Row(
@@ -91,7 +95,7 @@ fun DualPodsCard(
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = device.meta?.profile?.label ?: "?", text = device.label ?: "?",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -120,6 +124,7 @@ fun DualPodsCard(
signalText = device.getSignalQuality(context), signalText = device.getSignalQuality(context),
bleKeyState = device.bleKeyState, bleKeyState = device.bleKeyState,
isAapConnected = device.isAapConnected, 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 // Connection state
val stateDetection = device.ble as? HasStateDetection val stateDetection = device.ble as? HasStateDetection
if (stateDetection != null) { if (stateDetection != null) {
@@ -415,3 +430,9 @@ private fun DualPodsCardWithKeysPreview() = PreviewWrapper {
private fun DualPodsCardWithAapPreview() = PreviewWrapper { private fun DualPodsCardWithAapPreview() = PreviewWrapper {
DualPodsCard(device = MockPodDataProvider.dualPodMonitoredWithAap(), showDebug = false, now = Instant.now()) DualPodsCard(device = MockPodDataProvider.dualPodMonitoredWithAap(), showDebug = false, now = Instant.now())
} }
@Preview2
@Composable
private fun DualPodsCardCachedOnlyPreview() = PreviewWrapper {
DualPodsCard(device = MockPodDataProvider.dualPodCachedOnly(), showDebug = false, now = Instant.now())
}
@@ -26,6 +26,7 @@ import androidx.compose.material.icons.twotone.KeyboardVoice
import androidx.compose.material.icons.outlined.Key import androidx.compose.material.icons.outlined.Key
import androidx.compose.material.icons.twotone.Bluetooth import androidx.compose.material.icons.twotone.Bluetooth
import androidx.compose.material.icons.twotone.Key import androidx.compose.material.icons.twotone.Key
import androidx.compose.material.icons.twotone.LinkOff
import androidx.compose.material.icons.twotone.SettingsInputAntenna import androidx.compose.material.icons.twotone.SettingsInputAntenna
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -163,6 +164,7 @@ fun SignalBadge(
signalText: String, signalText: String,
bleKeyState: BleKeyState = BleKeyState.NONE, bleKeyState: BleKeyState = BleKeyState.NONE,
isAapConnected: Boolean = false, isAapConnected: Boolean = false,
isLive: Boolean = true,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Surface( Surface(
@@ -174,39 +176,48 @@ fun SignalBadge(
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (bleKeyState != BleKeyState.NONE) { if (!isLive) {
Icon( Icon(
imageVector = if (bleKeyState == BleKeyState.IRK_AND_ENCRYPTED) Icons.TwoTone.Key else Icons.Outlined.Key, imageVector = Icons.TwoTone.LinkOff,
contentDescription = stringResource( contentDescription = null,
if (bleKeyState == BleKeyState.IRK_AND_ENCRYPTED) R.string.signal_badge_key_encrypted_cd modifier = Modifier.size(12.dp),
else R.string.signal_badge_key_irk_cd 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), modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Spacer(modifier = Modifier.width(3.dp)) Spacer(modifier = Modifier.width(3.dp))
} Text(
if (isAapConnected) { text = signalText,
Icon( style = MaterialTheme.typography.labelSmall,
imageVector = Icons.TwoTone.Bluetooth, color = MaterialTheme.colorScheme.onSurfaceVariant,
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))
Text(
text = signalText,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
} }
} }
@@ -31,6 +31,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource 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.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.monitor.core.PodDevice 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.firstSeenFormatted
import eu.darken.capod.monitor.core.getSignalQuality import eu.darken.capod.monitor.core.getSignalQuality
import eu.darken.capod.monitor.core.lastSeenFormatted import eu.darken.capod.monitor.core.lastSeenFormatted
@@ -82,7 +84,9 @@ fun SinglePodsCard(
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp), elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
) { ) {
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier
.padding(16.dp)
.then(if (!device.isLive) Modifier.alpha(0.7f) else Modifier),
) { ) {
// Header // Header
Row( Row(
@@ -99,7 +103,7 @@ fun SinglePodsCard(
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = device.meta?.profile?.label ?: "?", text = device.label ?: "?",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -117,6 +121,7 @@ fun SinglePodsCard(
signalText = device.getSignalQuality(context), signalText = device.getSignalQuality(context),
bleKeyState = device.bleKeyState, bleKeyState = device.bleKeyState,
isAapConnected = device.isAapConnected, 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 // ANC mode selector
val ancMode = device.ancMode val ancMode = device.ancMode
if (device.isAapConnected && device.hasAncControl && ancMode != null) { if (device.isAapConnected && device.hasAncControl && ancMode != null) {
@@ -245,3 +260,9 @@ private fun SinglePodsCardPreview() = PreviewWrapper {
private fun SinglePodsCardDebugPreview() = PreviewWrapper { private fun SinglePodsCardDebugPreview() = PreviewWrapper {
SinglePodsCard(device = MockPodDataProvider.singlePodMonitored(), showDebug = true, now = Instant.now()) SinglePodsCard(device = MockPodDataProvider.singlePodMonitored(), showDebug = true, now = Instant.now())
} }
@Preview2
@Composable
private fun SinglePodsCardCachedOnlyPreview() = PreviewWrapper {
SinglePodsCard(device = MockPodDataProvider.singlePodCachedOnly(), showDebug = false, now = Instant.now())
}
@@ -86,8 +86,8 @@ class BatteryGlanceWidget : GlanceAppWidget() {
val isPro = upgradeInfo?.isPro ?: initialIsPro val isPro = upgradeInfo?.isPro ?: initialIsPro
val liveDevice = devices.firstOrNull { it.meta?.profile?.id == profileId } val liveDevice = devices.firstOrNull { it.profileId == profileId }
val device = liveDevice ?: cachedDevice?.takeIf { it.meta?.profile?.id == profileId } val device = liveDevice ?: cachedDevice?.takeIf { it.profileId == profileId }
val profileLabel = profileId?.let { pid -> val profileLabel = profileId?.let { pid ->
profiles.firstOrNull { it.id == pid }?.label profiles.firstOrNull { it.id == pid }?.label
@@ -1,44 +1,102 @@
package eu.darken.capod.monitor.core 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.log
import eu.darken.capod.common.debug.logging.logTag 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.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.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* Single merge point: combines BLE scan data ([BlePodMonitor]) with AAP connection data * Single merge point: combines BLE scan data ([eu.darken.capod.monitor.core.ble.BlePodMonitor]) with AAP connection data
* ([AapConnectionManager]) into unified [PodDevice] objects. * ([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. * ViewModels should observe [devices] instead of accessing BlePodMonitor directly.
*/ */
@Singleton @Singleton
class DeviceMonitor @Inject constructor( class DeviceMonitor @Inject constructor(
@AppScope private val appScope: CoroutineScope,
private val blePodMonitor: BlePodMonitor, private val blePodMonitor: BlePodMonitor,
private val aapManager: AapConnectionManager, private val aapManager: AapConnectionManager,
private val deviceStateCache: DeviceStateCache,
private val profilesRepo: DeviceProfilesRepo,
) { ) {
val devices: Flow<List<PodDevice>> = blePodMonitor.devices val devices: Flow<List<PodDevice>> = combine(
.combine(aapManager.allStates) { pods, aapStates -> blePodMonitor.devices,
pods.map { pod -> aapManager.allStates,
// AAP connections are keyed by bonded BR/EDR address (from profile), deviceStateCache.cachedStates,
// BLE scans use rotating RPAs. Bridge via the profile's bonded address. profilesRepo.profiles,
val bondedAddress = pod.meta?.profile?.address ) { pods, aapStates, cachedStates, profiles ->
PodDevice( // Live devices — BLE + AAP + cached fallback for missing fields
ble = pod, val liveDevices = pods.map { pod ->
aap = bondedAddress?.let { aapStates[it] }, 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<PodDevice>) {
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? { suspend fun getDeviceForProfile(profileId: String): PodDevice? {
log(TAG) { "getDeviceForProfile(profileId=$profileId)" } log(TAG) { "getDeviceForProfile(profileId=$profileId)" }
val bleDevice = blePodMonitor.getDeviceForProfile(profileId) ?: return null
val bondedAddress = bleDevice.meta?.profile?.address val liveDevice = devices.firstOrNull()?.firstOrNull { it.profileId == profileId }
val aapState = bondedAddress?.let { aapManager.allStates.firstOrNull()?.get(it) } if (liveDevice != null) {
return PodDevice(ble = bleDevice, aap = aapState) 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 { companion object {
@@ -9,7 +9,7 @@ import java.time.Instant
import kotlin.math.roundToInt import kotlin.math.roundToInt
fun DeviceMonitor.devicesWithProfiles(): Flow<List<PodDevice>> = devices fun DeviceMonitor.devicesWithProfiles(): Flow<List<PodDevice>> = devices
.map { devices -> devices.filter { it.meta?.profile != null } } .map { devices -> devices.filter { it.profileId != null } }
fun DeviceMonitor.primaryDevice(): Flow<PodDevice?> = devicesWithProfiles().map { it.firstOrNull() } fun DeviceMonitor.primaryDevice(): Flow<PodDevice?> = devicesWithProfiles().map { it.firstOrNull() }
@@ -47,3 +47,27 @@ fun PodDevice.firstSeenFormatted(now: Instant): String {
RelativeDateTimeFormatter.RelativeUnit.MINUTES 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
)
}
}
@@ -4,20 +4,21 @@ import android.content.Context
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BluetoothAddress 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.BlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot 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.HasCase
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetection 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.HasChargeDetectionDual
import eu.darken.capod.pods.core.apple.ble.devices.HasDualMicrophone 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.HasEarDetection
import eu.darken.capod.pods.core.apple.ble.devices.HasEarDetectionDual 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.Duration
import java.time.Instant import java.time.Instant
@@ -28,17 +29,21 @@ import java.time.Instant
*/ */
@Stable @Stable
data class PodDevice( data class PodDevice(
val profileId: String?,
val label: String? = null,
internal val ble: BlePodSnapshot?, internal val ble: BlePodSnapshot?,
internal val aap: AapPodState?, internal val aap: AapPodState?,
internal val cached: CachedDeviceState? = null,
) { ) {
// Identity val model: PodModel get() = ble?.model ?: cached?.model ?: PodModel.UNKNOWN
val model: PodModel get() = ble?.model ?: PodModel.UNKNOWN
/** Bonded BR/EDR address (from profile). Used for AAP commands. */ /** 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). */ /** BLE scan address (RPA, rotates). */
val bleAddress: BluetoothAddress? get() = ble?.address val bleAddress: BluetoothAddress? get() = ble?.address
val identifier: BlePodSnapshot.Id? get() = ble?.identifier 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 // Capabilities from Model.Features
val hasCase: Boolean get() = model.features.hasCase val hasCase: Boolean get() = model.features.hasCase
@@ -48,7 +53,7 @@ data class PodDevice(
val hasDualMicrophone: Boolean get() = ble is HasDualMicrophone val hasDualMicrophone: Boolean get() = ble is HasDualMicrophone
// Signal / timing // Signal / timing
val seenLastAt: Instant? get() = ble?.seenLastAt val seenLastAt: Instant? get() = ble?.seenLastAt ?: cached?.lastSeenAt
val seenFirstAt: Instant? get() = ble?.seenFirstAt val seenFirstAt: Instant? get() = ble?.seenFirstAt
val signalQuality: Float val signalQuality: Float
get() { get() {
@@ -70,31 +75,54 @@ data class PodDevice(
} }
} }
// Battery — AAP preferred, BLE fallback // Battery — AAP preferred, BLE fallback, then cached
val batteryLeft: Float? val batteryLeft: Float?
get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent ?: cached?.left?.percent
val batteryRight: Float? val batteryRight: Float?
get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent ?: cached?.right?.percent
val batteryCase: Float? val batteryCase: Float?
get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent ?: cached?.case?.percent
val batteryHeadset: Float? 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? val isLeftPodCharging: Boolean?
get() = aap?.isLeftCharging ?: (ble as? HasChargeDetectionDual)?.isLeftPodCharging get() = aap?.isLeftCharging ?: (ble as? HasChargeDetectionDual)?.isLeftPodCharging ?: cached?.isLeftCharging
val isRightPodCharging: Boolean? val isRightPodCharging: Boolean?
get() = aap?.isRightCharging ?: (ble as? HasChargeDetectionDual)?.isRightPodCharging get() = aap?.isRightCharging ?: (ble as? HasChargeDetectionDual)?.isRightPodCharging ?: cached?.isRightCharging
val isCaseCharging: Boolean? val isCaseCharging: Boolean?
get() = aap?.isCaseCharging ?: (ble as? HasCase)?.isCaseCharging get() = aap?.isCaseCharging ?: (ble as? HasCase)?.isCaseCharging ?: cached?.isCaseCharging
val isHeadsetBeingCharged: Boolean? 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. // Resolved primary pod: AAP cmd 0x08 preferred, BLE bit 5 fallback.
private val resolvedPrimaryPod: DualBlePodSnapshot.Pod? private val resolvedPrimaryPod: DualBlePodSnapshot.Pod?
@@ -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<BleScanResult>(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<ProfileId, BleScanResult>) {
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")
}
}
@@ -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<List<BlePodSnapshot>> = devices
.map { devices -> devices.filter { it.meta.profile != null } }
fun BlePodMonitor.primaryDevice(): Flow<BlePodSnapshot?> = devicesWithProfiles().map { it.firstOrNull() }
@@ -1,9 +0,0 @@
package eu.darken.capod.monitor.core
import dagger.Reusable
import javax.inject.Inject
@Reusable
class PodSorter @Inject constructor(
)
@@ -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.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
@@ -47,6 +47,6 @@ class AapKeyPersister @Inject constructor(
.setupCommonEventHandlers(TAG) { "keyPersister" } .setupCommonEventHandlers(TAG) { "keyPersister" }
companion object { companion object {
private val TAG = logTag("Reaction", "AapKeyPersister") private val TAG = logTag("Monitor", "AapKeyPersister")
} }
} }
@@ -1,14 +1,14 @@
package eu.darken.capod.monitor.core package eu.darken.capod.monitor.core.ble
import android.bluetooth.le.ScanFilter 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.BleScanner
import eu.darken.capod.common.bluetooth.BluetoothManager2 import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.bluetooth.ScannerMode import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.bluetooth.onlyNewAndUnique import eu.darken.capod.common.bluetooth.onlyNewAndUnique
import eu.darken.capod.common.coroutine.AppScope import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.DebugSettings 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
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.asLog import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
@@ -25,10 +25,11 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.retryWhen import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@@ -46,7 +47,6 @@ class BlePodMonitor @Inject constructor(
private val generalSettings: GeneralSettings, private val generalSettings: GeneralSettings,
bluetoothManager: BluetoothManager2, bluetoothManager: BluetoothManager2,
private val debugSettings: DebugSettings, private val debugSettings: DebugSettings,
private val podDeviceCache: PodDeviceCache,
permissionTool: PermissionTool, permissionTool: PermissionTool,
private val profilesRepo: DeviceProfilesRepo, private val profilesRepo: DeviceProfilesRepo,
) { ) {
@@ -63,10 +63,16 @@ class BlePodMonitor @Inject constructor(
} }
.flatMapLatest { isReady -> .flatMapLatest { isReady ->
if (!isReady) { if (!isReady) {
log(TAG, WARN) { "Bluetooth is not ready" } log(TAG, Logging.Priority.WARN) { "Bluetooth is not ready" }
flowOf(null) flowOf(null)
} else { } else {
createBleScanner() val staleEvictionTicker: Flow<Collection<BleScanResult>> = flow {
while (true) {
delay(STALE_EVICTION_INTERVAL.toMillis())
emit(emptyList())
}
}
merge(createBleScanner(), staleEvictionTicker)
} }
} }
.map { results -> results?.mapNotNull { podFactory.createPod(it) } } .map { results -> results?.mapNotNull { podFactory.createPod(it) } }
@@ -76,10 +82,13 @@ class BlePodMonitor @Inject constructor(
} }
.retryWhen { cause, attempt -> .retryWhen { cause, attempt ->
if (cause is SecurityException) { 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 false
} else { } 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) delay(3000)
true true
} }
@@ -140,7 +149,7 @@ class BlePodMonitor @Inject constructor(
.flatMapLatest { options -> .flatMapLatest { options ->
val filters = when { val filters = when {
options.showUnfiltered -> { options.showUnfiltered -> {
log(TAG, WARN) { "Using unfiltered scan mode" } log(TAG, Logging.Priority.WARN) { "Using unfiltered scan mode" }
setOf(ScanFilter.Builder().build()) setOf(ScanFilter.Builder().build())
} }
@@ -168,8 +177,8 @@ class BlePodMonitor @Inject constructor(
val now = Instant.now() val now = Instant.now()
deviceCache.toList().forEach { (key, value) -> deviceCache.toList().forEach { (key, value) ->
if (Duration.between(value.seenLastAt, now) > Duration.ofSeconds(20)) { if (Duration.between(value.seenLastAt, now) > STALE_DEVICE_TIMEOUT) {
log(TAG, VERBOSE) { "Removing stale device from cache: $value" } log(TAG, Logging.Priority.VERBOSE) { "Removing stale device from cache: $value" }
deviceCache.remove(key) deviceCache.remove(key)
} }
} }
@@ -183,35 +192,12 @@ class BlePodMonitor @Inject constructor(
pods[it.identifier] = it 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 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 { companion object {
private val TAG = logTag("Monitor", "PodMonitor") private val TAG = logTag("Monitor", "PodMonitor")
private val STALE_DEVICE_TIMEOUT = Duration.ofSeconds(20)
private val STALE_EVICTION_INTERVAL = Duration.ofSeconds(10)
} }
} }
@@ -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,
)
}
@@ -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<Map<ProfileId, CachedDeviceState>>(emptyMap())
val cachedStates: StateFlow<Map<ProfileId, CachedDeviceState>> = _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<ProfileId, CachedDeviceState>()
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<CachedDeviceState>(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<CachedDeviceState>(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")
}
}
@@ -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
}
@@ -27,16 +27,16 @@ import eu.darken.capod.common.hasApiLevel
import eu.darken.capod.main.core.GeneralSettings import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.PermissionTool 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.DeviceMonitor
import eu.darken.capod.monitor.core.MonitorCoroutineScope 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.core.primaryDevice
import eu.darken.capod.monitor.ui.MonitorNotifications 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.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo 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.AapAutoConnect
import eu.darken.capod.reaction.core.aap.AapKeyPersister
import eu.darken.capod.reaction.core.autoconnect.AutoConnect import eu.darken.capod.reaction.core.autoconnect.AutoConnect
import eu.darken.capod.reaction.core.playpause.PlayPause import eu.darken.capod.reaction.core.playpause.PlayPause
import eu.darken.capod.reaction.core.popup.PopUpReaction import eu.darken.capod.reaction.core.popup.PopUpReaction
@@ -77,6 +77,7 @@ class MonitorService : Service() {
@Inject lateinit var profilesRepo: DeviceProfilesRepo @Inject lateinit var profilesRepo: DeviceProfilesRepo
@Inject lateinit var aapAutoConnect: AapAutoConnect @Inject lateinit var aapAutoConnect: AapAutoConnect
@Inject lateinit var aapKeyPersister: AapKeyPersister @Inject lateinit var aapKeyPersister: AapKeyPersister
@Inject lateinit var aapConnectionManager: AapConnectionManager @Inject lateinit var aapConnectionManager: AapConnectionManager
private val monitorScope = MonitorCoroutineScope() private val monitorScope = MonitorCoroutineScope()
@@ -4,11 +4,12 @@ import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.common.coroutine.AppScope 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.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.main.core.GeneralSettings 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.CoroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -17,7 +18,6 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import eu.darken.capod.common.datastore.valueBlocking
@Singleton @Singleton
class DeviceProfilesRepo @Inject constructor( class DeviceProfilesRepo @Inject constructor(
@@ -25,7 +25,7 @@ class DeviceProfilesRepo @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val generalSettings: GeneralSettings, private val generalSettings: GeneralSettings,
private val settings: DeviceProfilesSettings, private val settings: DeviceProfilesSettings,
private val podDeviceCache: PodDeviceCache, private val deviceStateCache: DeviceStateCache,
) { ) {
private val mutex = Mutex() private val mutex = Mutex()
@@ -85,7 +85,7 @@ class DeviceProfilesRepo @Inject constructor(
val updatedProfiles = currentContainer.profiles.filter { it.id != profileId } val updatedProfiles = currentContainer.profiles.filter { it.id != profileId }
settings.profiles.valueBlocking = DeviceProfilesContainer(updatedProfiles) settings.profiles.valueBlocking = DeviceProfilesContainer(updatedProfiles)
log(VERBOSE) { "Removed device profile with ID: $profileId" } log(VERBOSE) { "Removed device profile with ID: $profileId" }
podDeviceCache.delete(profileId) deviceStateCache.delete(profileId)
} }
suspend fun reorderProfiles(profiles: List<DeviceProfile>) = mutex.withLock { suspend fun reorderProfiles(profiles: List<DeviceProfile>) = mutex.withLock {
@@ -95,6 +95,7 @@ class DeviceProfilesRepo @Inject constructor(
suspend fun clear() { suspend fun clear() {
settings.profiles.valueBlocking = DeviceProfilesContainer(emptyList()) settings.profiles.valueBlocking = DeviceProfilesContainer(emptyList())
deviceStateCache.deleteAll()
} }
private fun checkAddressUniqueness(profile: DeviceProfile, existingProfiles: List<DeviceProfile>) { private fun checkAddressUniqueness(profile: DeviceProfile, existingProfiles: List<DeviceProfile>) {
@@ -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.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers 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.PodModel
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager 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.AapPodState
@@ -24,9 +24,9 @@ import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import kotlin.time.Duration.Companion.seconds
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds
@Singleton @Singleton
class AapAutoConnect @Inject constructor( class AapAutoConnect @Inject constructor(
@@ -49,7 +49,7 @@ class AutoConnect @Inject constructor(
.map { (connectedDevices, mainDevice) -> .map { (connectedDevices, mainDevice) ->
log(TAG, VERBOSE) { "mainPodDevice is $mainDevice" } log(TAG, VERBOSE) { "mainPodDevice is $mainDevice" }
val mainDeviceAddr = mainDevice.meta?.profile?.address val mainDeviceAddr = mainDevice.address
if (mainDeviceAddr.isNullOrEmpty()) { if (mainDeviceAddr.isNullOrEmpty()) {
log(TAG, WARN) { "mainDeviceAddress is null" } log(TAG, WARN) { "mainDeviceAddress is null" }
return@map return@map
@@ -53,7 +53,7 @@ class PopUpReaction @Inject constructor(
log(TAG, VERBOSE) { "previous-id=${previous?.identifier}, current-id=${current.identifier}" } log(TAG, VERBOSE) { "previous-id=${previous?.identifier}, current-id=${current.identifier}" }
val isSameDeviceOrProfile = previous?.identifier == 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 isSameDeviceWithCaseNowOpen = isSameDeviceOrProfile && previous?.caseLidState != current.caseLidState
val isNewDeviceWithJustOpenedCase = !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? { 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 now = Instant.now()
val lastShown = caseCoolDowns[cooldownKey] val lastShown = caseCoolDowns[cooldownKey]
@@ -110,7 +110,7 @@ class PopUpReaction @Inject constructor(
deviceMonitor.primaryDevice().distinctUntilChangedBy { it?.rawDataHex }, deviceMonitor.primaryDevice().distinctUntilChangedBy { it?.rawDataHex },
) { devices, broadcast -> ) { devices, broadcast ->
log(TAG) { "$broadcast $devices " } log(TAG) { "$broadcast $devices " }
val primaryAddr = broadcast?.meta?.profile?.address val primaryAddr = broadcast?.address
val direct = devices.singleOrNull { it.address == primaryAddr }.also { val direct = devices.singleOrNull { it.address == primaryAddr }.also {
log(TAG, VERBOSE) { "Connected main device is $it" } log(TAG, VERBOSE) { "Connected main device is $it" }
} }
@@ -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.debug.logging.logTag
import eu.darken.capod.common.uix.ViewModel4 import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.main.core.GeneralSettings 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.DeviceMonitor
import eu.darken.capod.monitor.core.ble.BlePodMonitor
import eu.darken.capod.monitor.core.primaryDevice import eu.darken.capod.monitor.core.primaryDevice
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
import eu.darken.capod.pods.core.unknown.UnknownSnapshotBle import eu.darken.capod.pods.core.unknown.UnknownSnapshotBle
+1
View File
@@ -273,6 +273,7 @@
<string name="last_seen_x">Last seen: %s</string> <string name="last_seen_x">Last seen: %s</string>
<string name="first_seen_x">First seen: %s</string> <string name="first_seen_x">First seen: %s</string>
<string name="battery_cached_label">Last known \u00B7 %s</string>
<string name="permission_post_notifications_label">Show notifications</string> <string name="permission_post_notifications_label">Show notifications</string>
<string name="permission_post_notifications_description">"Allow CAPod to show notifications about your AirPods, e.g. their current status while connected."</string> <string name="permission_post_notifications_description">"Allow CAPod to show notifications about your AirPods, e.g. their current status while connected."</string>
@@ -155,7 +155,7 @@ class OverviewViewModelTest : BaseTest() {
@Test @Test
fun `devices passed through when permissions granted`() = runTest(testDispatcher) { 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) devicesFlow.value = listOf(device)
val vm = createViewModel() val vm = createViewModel()
@@ -166,18 +166,14 @@ class OverviewViewModelTest : BaseTest() {
@Test @Test
fun `profiledDevices returns only devices with non-null profile`() { 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( val profiled = PodDevice(
ble = mockk(relaxed = true) { every { meta } returns withProfile }, profileId = "test-id",
ble = mockk(relaxed = true),
aap = null, aap = null,
) )
val unmatched = PodDevice( val unmatched = PodDevice(
ble = mockk(relaxed = true) { every { meta } returns withoutProfile }, profileId = null,
ble = mockk(relaxed = true),
aap = null, aap = null,
) )
@@ -197,18 +193,14 @@ class OverviewViewModelTest : BaseTest() {
@Test @Test
fun `unmatchedDevices returns only devices with null profile`() { 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( val profiled = PodDevice(
ble = mockk(relaxed = true) { every { meta } returns withProfile }, profileId = "test-id",
ble = mockk(relaxed = true),
aap = null, aap = null,
) )
val unmatched = PodDevice( val unmatched = PodDevice(
ble = mockk(relaxed = true) { every { meta } returns withoutProfile }, profileId = null,
ble = mockk(relaxed = true),
aap = null, aap = null,
) )
@@ -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
}
}
}
@@ -41,7 +41,7 @@ class PodDeviceTest : BaseTest() {
@Test @Test
fun `BLE-only device exposes battery from BLE`() { 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.batteryLeft shouldBe 0.8f
device.isAapConnected shouldBe false device.isAapConnected shouldBe false
} }
@@ -49,7 +49,7 @@ class PodDeviceTest : BaseTest() {
@Test @Test
fun `capabilities come from model features`() { fun `capabilities come from model features`() {
val device = PodDevice( val device = PodDevice(
ble = mockDualPod(model = PodModel.AIRPODS_PRO3), profileId = null, ble = mockDualPod(model = PodModel.AIRPODS_PRO3),
aap = null, aap = null,
) )
device.hasDualPods shouldBe true device.hasDualPods shouldBe true
@@ -61,7 +61,7 @@ class PodDeviceTest : BaseTest() {
@Test @Test
fun `Beats Solo 3 has no dual pods or case`() { fun `Beats Solo 3 has no dual pods or case`() {
val device = PodDevice( 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, aap = null,
) )
device.hasDualPods shouldBe false 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.isAapConnected shouldBe true
device.ancMode.shouldNotBeNull() device.ancMode.shouldNotBeNull()
device.ancMode!!.current shouldBe AapSetting.AncMode.Value.TRANSPARENCY device.ancMode!!.current shouldBe AapSetting.AncMode.Value.TRANSPARENCY
@@ -87,36 +87,32 @@ class PodDeviceTest : BaseTest() {
@Test @Test
fun `ANC mode is null when not AAP connected`() { 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() device.ancMode.shouldBeNull()
} }
@Test @Test
fun `null BLE gives UNKNOWN model`() { 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 device.model shouldBe PodModel.UNKNOWN
} }
@Test @Test
fun `identity properties delegate to BLE`() { fun `identifier delegates to BLE`() {
val id = BlePodSnapshot.Id() val id = BlePodSnapshot.Id()
val meta = mockk<BlePodSnapshot.Meta>(relaxed = true)
val device = PodDevice( val device = PodDevice(
ble = mockk(relaxed = true) { profileId = null, ble = mockk(relaxed = true) {
every { identifier } returns id every { identifier } returns id
every { this@mockk.meta } returns meta
}, },
aap = null, aap = null,
) )
device.identifier shouldBe id device.identifier shouldBe id
device.meta shouldBe meta
} }
@Test @Test
fun `identity properties null when BLE null`() { fun `identifier null when BLE null`() {
val device = PodDevice(ble = null, aap = null) val device = PodDevice(profileId = null, ble = null, aap = null)
device.identifier.shouldBeNull() device.identifier.shouldBeNull()
device.meta.shouldBeNull()
} }
@Test @Test
@@ -124,7 +120,7 @@ class PodDeviceTest : BaseTest() {
val now = Instant.now() val now = Instant.now()
val earlier = now.minusSeconds(60) val earlier = now.minusSeconds(60)
val device = PodDevice( val device = PodDevice(
ble = mockk(relaxed = true) { profileId = null, ble = mockk(relaxed = true) {
every { seenLastAt } returns now every { seenLastAt } returns now
every { seenFirstAt } returns earlier every { seenFirstAt } returns earlier
every { signalQuality } returns 0.75f every { signalQuality } returns 0.75f
@@ -140,7 +136,7 @@ class PodDeviceTest : BaseTest() {
@Test @Test
fun `signal timing defaults when BLE null`() { 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.seenLastAt.shouldBeNull()
device.seenFirstAt.shouldBeNull() device.seenFirstAt.shouldBeNull()
device.signalQuality shouldBe 0f device.signalQuality shouldBe 0f
@@ -155,7 +151,7 @@ class PodDeviceTest : BaseTest() {
every { (this@mockk as HasChargeDetectionDual).isRightPodCharging } returns false every { (this@mockk as HasChargeDetectionDual).isRightPodCharging } returns false
every { (this@mockk as HasCase).isCaseCharging } returns true 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.isLeftPodCharging shouldBe true
device.isRightPodCharging shouldBe false device.isRightPodCharging shouldBe false
device.isCaseCharging shouldBe true device.isCaseCharging shouldBe true
@@ -170,7 +166,7 @@ class PodDeviceTest : BaseTest() {
every { (this@mockk as HasEarDetection).isBeingWorn } returns false every { (this@mockk as HasEarDetection).isBeingWorn } returns false
every { (this@mockk as HasEarDetectionDual).isEitherPodInEar } returns true 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.isLeftInEar shouldBe true
device.isRightInEar shouldBe false device.isRightInEar shouldBe false
device.isBeingWorn 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 device.isEitherPodInEar shouldBe true
} }
@@ -203,7 +199,7 @@ class PodDeviceTest : BaseTest() {
every { (this@mockk as HasEarDetectionDual).isEitherPodInEar } returns true every { (this@mockk as HasEarDetectionDual).isEitherPodInEar } returns true
} }
val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY) 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 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.isLeftInEar shouldBe true
device.isRightInEar shouldBe false 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.isLeftInEar shouldBe false
device.isRightInEar shouldBe true 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 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 device.isBeingWorn shouldBe false
} }
@@ -301,7 +297,7 @@ class PodDeviceTest : BaseTest() {
AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(AapSetting.PrimaryPod.Pod.LEFT), 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.isLeftInEar shouldBe true
device.isRightInEar shouldBe false device.isRightInEar shouldBe false
} }
@@ -322,7 +318,7 @@ class PodDeviceTest : BaseTest() {
AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(AapSetting.PrimaryPod.Pod.LEFT), // AAP says LEFT 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.isLeftInEar shouldBe true // AAP wins
device.isRightInEar shouldBe false device.isRightInEar shouldBe false
} }
@@ -343,7 +339,7 @@ class PodDeviceTest : BaseTest() {
// No PrimaryPod setting — falls back to BLE // 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.isLeftInEar shouldBe false
device.isRightInEar shouldBe true // BLE says RIGHT is primary 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), 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.isLeftPodMicrophone shouldBe true // AAP says LEFT
device.isRightPodMicrophone shouldBe false device.isRightPodMicrophone shouldBe false
} }
@@ -374,7 +370,7 @@ class PodDeviceTest : BaseTest() {
every { isRightPodMicrophone } returns true every { isRightPodMicrophone } returns true
} }
val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY) 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.isLeftPodMicrophone shouldBe false
device.isRightPodMicrophone shouldBe true // BLE fallback device.isRightPodMicrophone shouldBe true // BLE fallback
} }
@@ -394,7 +390,7 @@ class PodDeviceTest : BaseTest() {
AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(AapSetting.PrimaryPod.Pod.RIGHT), 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.isLeftPodMicrophone shouldBe false
device.isRightPodMicrophone shouldBe true device.isRightPodMicrophone shouldBe true
} }
@@ -414,7 +410,7 @@ class PodDeviceTest : BaseTest() {
AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(AapSetting.PrimaryPod.Pod.LEFT), 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.isLeftPodMicrophone shouldBe true
device.isRightPodMicrophone shouldBe false device.isRightPodMicrophone shouldBe false
} }
@@ -425,27 +421,27 @@ class PodDeviceTest : BaseTest() {
connectionState = AapPodState.ConnectionState.READY, connectionState = AapPodState.ConnectionState.READY,
pendingAncMode = AapSetting.AncMode.Value.ADAPTIVE, 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 device.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
} }
@Test @Test
fun `pendingAncMode null when no AAP`() { 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() device.pendingAncMode.shouldBeNull()
} }
@Test @Test
fun `pendingAncMode null when not set`() { fun `pendingAncMode null when not set`() {
val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY) 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() device.pendingAncMode.shouldBeNull()
} }
@Test @Test
fun `icon and label properties delegate to BLE`() { fun `icon and label properties delegate to BLE`() {
val device = PodDevice( val device = PodDevice(
ble = mockk(relaxed = true) { profileId = null, ble = mockk(relaxed = true) {
every { model } returns PodModel.AIRPODS_PRO3 every { model } returns PodModel.AIRPODS_PRO3
every { iconRes } returns 42 every { iconRes } returns 42
}, },
@@ -456,14 +452,14 @@ class PodDeviceTest : BaseTest() {
@Test @Test
fun `rawDataHex empty when BLE null`() { 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() device.rawDataHex shouldBe emptyList()
} }
@Test @Test
fun `battery falls back to BLE when AAP battery is null`() { fun `battery falls back to BLE when AAP battery is null`() {
val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY) 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.batteryLeft shouldBe 0.8f
device.isAapConnected shouldBe true 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), 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% 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), 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 device.isLeftPodCharging shouldBe true // AAP CHARGING_OPTIMIZED counts as charging
} }
@@ -508,7 +504,7 @@ class PodDeviceTest : BaseTest() {
every { this@mockk.address } returns bleRpa every { this@mockk.address } returns bleRpa
every { meta } returns ApplePods.AppleMeta(profile = profile) 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.address shouldBe bondedAddress
device.bleAddress shouldBe bleRpa device.bleAddress shouldBe bleRpa
} }
@@ -520,7 +516,7 @@ class PodDeviceTest : BaseTest() {
every { model } returns PodModel.AIRPODS_PRO3 every { model } returns PodModel.AIRPODS_PRO3
every { signalQuality } returns bleQuality every { signalQuality } returns bleQuality
} }
return PodDevice(ble = ble, aap = aap) return PodDevice(profileId = null, ble = ble, aap = aap)
} }
@Test @Test
@@ -627,13 +623,13 @@ class PodDeviceTest : BaseTest() {
@Test @Test
fun `bleKeyState - null BLE returns NONE`() { 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 device.bleKeyState shouldBe BleKeyState.NONE
} }
@Test @Test
fun `bleKeyState - non-Apple BLE returns NONE`() { 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 device.bleKeyState shouldBe BleKeyState.NONE
} }
@@ -644,7 +640,7 @@ class PodDeviceTest : BaseTest() {
every { meta } returns ApplePods.AppleMeta(isIRKMatch = false) every { meta } returns ApplePods.AppleMeta(isIRKMatch = false)
every { payload } returns ProximityPayload(public = ProximityPayload.Public(UByteArray(9)), private = null) 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 device.bleKeyState shouldBe BleKeyState.NONE
} }
@@ -655,7 +651,7 @@ class PodDeviceTest : BaseTest() {
every { meta } returns ApplePods.AppleMeta(isIRKMatch = true) every { meta } returns ApplePods.AppleMeta(isIRKMatch = true)
every { payload } returns ProximityPayload(public = ProximityPayload.Public(UByteArray(9)), private = null) 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 device.bleKeyState shouldBe BleKeyState.IRK_ONLY
} }
@@ -669,7 +665,7 @@ class PodDeviceTest : BaseTest() {
private = ProximityPayload.Private(UByteArray(8)), 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 device.bleKeyState shouldBe BleKeyState.IRK_AND_ENCRYPTED
} }
} }
@@ -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()
}
}
}
@@ -2,12 +2,12 @@ package eu.darken.capod.reaction.core.aap
import eu.darken.capod.common.bluetooth.BluetoothDevice2 import eu.darken.capod.common.bluetooth.BluetoothDevice2
import eu.darken.capod.common.bluetooth.BluetoothManager2 import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.monitor.core.BlePodMonitor import eu.darken.capod.monitor.core.ble.BlePodMonitor
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
import eu.darken.capod.pods.core.apple.PodModel 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.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.AapPodState 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.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.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfile import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo import eu.darken.capod.profiles.core.DeviceProfilesRepo