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