feat: Add unified device state cache for persistent battery display

Replace PodDeviceCache (raw BLE scan bytes) with DeviceStateCache that stores decoded combined device state (battery, charging, model) per profile.

Battery values persist across app restarts with per-slot timestamps. Cached-only cards appear for offline devices with muted visuals and a staleness indicator. Fallback chain: AAP -> BLE -> cached.
This commit is contained in:
darken
2026-04-02 13:56:09 +02:00
parent 19d61ca5cb
commit e91243e577
20 changed files with 902 additions and 231 deletions
@@ -4,6 +4,7 @@ import android.content.Context
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.monitor.core.CachedDeviceState
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
@@ -165,35 +166,73 @@ object MockPodDataProvider {
// --- PodDevice wrappers ---
fun dualPodMonitored(): PodDevice = PodDevice(
profileId = "preview-dual",
ble = airPodsProFullCharge(),
aap = null,
)
fun dualPodMonitoredMixed(): PodDevice = PodDevice(
profileId = "preview-dual-mixed",
ble = airPodsProMixed(),
aap = null,
)
fun dualPodMonitoredWithKeys(): PodDevice = PodDevice(
profileId = "preview-dual-keys",
ble = airPodsProWithKeys(),
aap = null,
)
fun dualPodMonitoredWithAap(): PodDevice = PodDevice(
profileId = "preview-dual-aap",
ble = airPodsProWithKeys(),
aap = AapPodState(connectionState = AapPodState.ConnectionState.READY),
)
fun singlePodMonitored(): PodDevice = PodDevice(
profileId = "preview-single",
ble = airPodsMax(),
aap = null,
)
fun unknownMonitored(): PodDevice = PodDevice(
profileId = null,
ble = unknownDevice(),
aap = null,
)
/** Cached-only dual pod — device fully offline, showing last known state. */
fun dualPodCachedOnly(): PodDevice = PodDevice(
profileId = "preview-cached",
ble = null,
aap = null,
cached = CachedDeviceState(
profileId = "preview-cached",
model = PodModel.AIRPODS_PRO2,
address = "AA:BB:CC:DD:EE:FF",
left = CachedDeviceState.CachedBatterySlot(0.65f, MOCK_NOW.minusSeconds(3600)),
right = CachedDeviceState.CachedBatterySlot(0.50f, MOCK_NOW.minusSeconds(3600)),
case = CachedDeviceState.CachedBatterySlot(0.80f, MOCK_NOW.minusSeconds(3600)),
isLeftCharging = false,
isRightCharging = false,
isCaseCharging = false,
lastSeenAt = MOCK_NOW.minusSeconds(3600),
),
)
/** Cached-only single pod — device fully offline, showing last known state. */
fun singlePodCachedOnly(): PodDevice = PodDevice(
profileId = "preview-cached-single",
ble = null,
aap = null,
cached = CachedDeviceState(
profileId = "preview-cached-single",
model = PodModel.AIRPODS_MAX,
headset = CachedDeviceState.CachedBatterySlot(0.40f, MOCK_NOW.minusSeconds(7200)),
lastSeenAt = MOCK_NOW.minusSeconds(7200),
),
)
// --- UpgradeInfo ---
fun fossInfo(isPro: Boolean = false): UpgradeRepo.Info = MockUpgradeInfo(
@@ -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(
@@ -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(
@@ -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())
}
@@ -46,7 +46,6 @@ class BlePodMonitor @Inject constructor(
private val generalSettings: GeneralSettings,
bluetoothManager: BluetoothManager2,
private val debugSettings: DebugSettings,
private val podDeviceCache: PodDeviceCache,
permissionTool: PermissionTool,
private val profilesRepo: DeviceProfilesRepo,
) {
@@ -183,34 +182,9 @@ class BlePodMonitor @Inject constructor(
pods[it.identifier] = it
}
newPods
.mapNotNull {
val profileId = it.device.meta.profile?.id ?: return@mapNotNull null
profileId to it.device.scanResult
}
.toMap()
.run { podDeviceCache.saveAll(this) }
return pods
}
suspend fun getDeviceForProfile(profileId: String): BlePodSnapshot? {
log(TAG) { "getDeviceForProfile(profileId=$profileId)" }
val liveDevice = devices.firstOrNull()?.firstOrNull { device ->
device.meta.profile?.id == profileId
}
if (liveDevice != null) {
log(TAG) { "Found live device for profile $profileId: $liveDevice" }
return liveDevice
}
val cachedDevice = podDeviceCache.load(profileId)?.let {
podFactory.createPod(it)?.device
}
log(TAG) { "Cached device for profile $profileId: $cachedDevice" }
return cachedDevice
}
companion object {
private val TAG = logTag("Monitor", "PodMonitor")
}
@@ -0,0 +1,31 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.common.serialization.InstantEpochMillisSerializer
import eu.darken.capod.pods.core.apple.PodModel
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.Instant
@Serializable
data class CachedDeviceState(
@SerialName("profileId") val profileId: String,
@SerialName("model") val model: PodModel,
@SerialName("address") val address: String? = null,
@SerialName("left") val left: CachedBatterySlot? = null,
@SerialName("right") val right: CachedBatterySlot? = null,
@SerialName("case") val case: CachedBatterySlot? = null,
@SerialName("headset") val headset: CachedBatterySlot? = null,
@SerialName("isLeftCharging") val isLeftCharging: Boolean? = null,
@SerialName("isRightCharging") val isRightCharging: Boolean? = null,
@SerialName("isCaseCharging") val isCaseCharging: Boolean? = null,
@SerialName("isHeadsetCharging") val isHeadsetCharging: Boolean? = null,
@Serializable(with = InstantEpochMillisSerializer::class)
@SerialName("lastSeenAt") val lastSeenAt: Instant,
) {
@Serializable
data class CachedBatterySlot(
@SerialName("percent") val percent: Float,
@Serializable(with = InstantEpochMillisSerializer::class)
@SerialName("updatedAt") val updatedAt: Instant,
)
}
@@ -3,6 +3,7 @@ package eu.darken.capod.monitor.core
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.firstOrNull
@@ -11,34 +12,66 @@ import javax.inject.Singleton
/**
* Single merge point: combines BLE scan data ([BlePodMonitor]) with AAP connection data
* ([AapConnectionManager]) into unified [PodDevice] objects.
* ([AapConnectionManager]) and cached device state ([DeviceStateCache]) into unified [PodDevice] objects.
*
* Includes cached-only devices for profiles that have cached state but no live BLE data.
* ViewModels should observe [devices] instead of accessing BlePodMonitor directly.
*/
@Singleton
class DeviceMonitor @Inject constructor(
private val blePodMonitor: BlePodMonitor,
private val aapManager: AapConnectionManager,
private val deviceStateCache: DeviceStateCache,
private val profilesRepo: DeviceProfilesRepo,
) {
val devices: Flow<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 profileId = pod.meta?.profile?.id
PodDevice(
profileId = profileId,
ble = pod,
aap = bondedAddress?.let { aapStates[it] },
cached = profileId?.let { cachedStates[it] },
)
}
// Cached-only devices — profiles with cache but no live BLE
val liveProfileIds = liveDevices.mapNotNull { it.profileId }.toSet()
val cachedOnlyDevices = profiles
.filter { it.id !in liveProfileIds }
.mapNotNull { profile ->
cachedStates[profile.id]?.let {
PodDevice(profileId = profile.id, ble = null, aap = null, cached = it)
}
}
liveDevices + cachedOnlyDevices
}
suspend fun getDeviceForProfile(profileId: String): PodDevice? {
log(TAG) { "getDeviceForProfile(profileId=$profileId)" }
val bleDevice = blePodMonitor.getDeviceForProfile(profileId) ?: return null
val bondedAddress = bleDevice.meta?.profile?.address
val aapState = bondedAddress?.let { aapManager.allStates.firstOrNull()?.get(it) }
return PodDevice(ble = bleDevice, aap = aapState)
val liveDevice = devices.firstOrNull()?.firstOrNull { it.profileId == profileId }
if (liveDevice != null) {
log(TAG) { "Found live device for profile $profileId" }
return liveDevice
}
val cached = deviceStateCache.load(profileId)
if (cached != null) {
log(TAG) { "Found cached state for profile $profileId" }
return PodDevice(profileId = profileId, ble = null, aap = null, cached = cached)
}
log(TAG) { "No device found for profile $profileId" }
return null
}
companion object {
@@ -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,26 @@ fun PodDevice.firstSeenFormatted(now: Instant): String {
RelativeDateTimeFormatter.RelativeUnit.MINUTES
)
}
fun PodDevice.cachedBatteryFormatted(now: Instant): String {
val cachedAt = cachedBatteryAt ?: return ""
val formatter = RelativeDateTimeFormatter.getInstance()
val duration = Duration.between(cachedAt, now)
return when {
duration > Duration.ofHours(1) -> formatter.format(
duration.toHours().toDouble(),
RelativeDateTimeFormatter.Direction.LAST,
RelativeDateTimeFormatter.RelativeUnit.HOURS
)
duration > Duration.ofMinutes(1) -> formatter.format(
duration.toMinutes().toDouble(),
RelativeDateTimeFormatter.Direction.LAST,
RelativeDateTimeFormatter.RelativeUnit.MINUTES
)
else -> formatter.format(
duration.seconds.toDouble(),
RelativeDateTimeFormatter.Direction.LAST,
RelativeDateTimeFormatter.RelativeUnit.SECONDS
)
}
}
@@ -0,0 +1,118 @@
package eu.darken.capod.monitor.core
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.serialization.SerializationCapod
import eu.darken.capod.profiles.core.ProfileId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class DeviceStateCache @Inject constructor(
@ApplicationContext private val context: Context,
@AppScope private val appScope: CoroutineScope,
private val dispatcherProvider: DispatcherProvider,
@SerializationCapod private val json: Json,
) {
private val cacheDir by lazy {
File(context.filesDir, "device_state_cache").apply { mkdirs() }
}
private val lock = Mutex()
private val _cachedStates = MutableStateFlow<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, ERROR) { "Failed to load cached state from ${file.name}: ${e.asLog()}, deleting" }
file.delete()
}
}
log(TAG, VERBOSE) { "loadAll(): loaded ${loaded.size} entries" }
_cachedStates.value = loaded
}
}
private fun ProfileId.toCacheFile(): File = File(cacheDir, "profile_${this}.json")
suspend fun save(id: ProfileId, state: CachedDeviceState) = withContext(dispatcherProvider.IO) {
lock.withLock {
log(TAG, VERBOSE) { "save(id=$id)" }
val file = id.toCacheFile()
try {
file.writeText(json.encodeToString(CachedDeviceState.serializer(), state))
_cachedStates.value += (id to state)
} catch (e: Exception) {
log(TAG, ERROR) { "Failed to save state for $id: ${e.asLog()}" }
file.delete()
}
}
}
suspend fun load(id: ProfileId): CachedDeviceState? = withContext(dispatcherProvider.IO) {
lock.withLock {
val cached = _cachedStates.value[id]
if (cached != null) return@withContext cached
val file = id.toCacheFile()
if (!file.exists()) return@withContext null
try {
json.decodeFromString<CachedDeviceState>(file.readText())
} catch (e: Exception) {
log(TAG, ERROR) { "Failed to load state for $id: ${e.asLog()}, deleting" }
file.delete()
null
}
}
}
suspend fun delete(id: ProfileId) = withContext(dispatcherProvider.IO) {
lock.withLock {
log(TAG, VERBOSE) { "delete(id=$id)" }
id.toCacheFile().delete()
_cachedStates.value -= id
}
}
suspend fun deleteAll() = withContext(dispatcherProvider.IO) {
lock.withLock {
log(TAG, VERBOSE) { "deleteAll()" }
cacheDir.listFiles()?.forEach { it.delete() }
_cachedStates.value = emptyMap()
}
}
companion object {
private val TAG = logTag("Monitor", "DeviceStateCache")
}
}
@@ -28,18 +28,22 @@ import java.time.Instant
*/
@Stable
data class PodDevice(
val profileId: String?,
internal val ble: BlePodSnapshot?,
internal val aap: AapPodState?,
internal val cached: CachedDeviceState? = null,
) {
// Identity
val model: PodModel get() = ble?.model ?: PodModel.UNKNOWN
val model: PodModel get() = ble?.model ?: cached?.model ?: PodModel.UNKNOWN
/** Bonded BR/EDR address (from profile). Used for AAP commands. */
val address: BluetoothAddress? get() = ble?.meta?.profile?.address
val address: BluetoothAddress? get() = ble?.meta?.profile?.address ?: cached?.address
/** BLE scan address (RPA, rotates). */
val bleAddress: BluetoothAddress? get() = ble?.address
val identifier: BlePodSnapshot.Id? get() = ble?.identifier
val meta: BlePodSnapshot.Meta? get() = ble?.meta
/** True when at least one live data source (BLE or AAP) is present. */
val isLive: Boolean get() = ble != null || aap != null
// Capabilities from Model.Features
val hasCase: Boolean get() = model.features.hasCase
val hasDualPods: Boolean get() = model.features.hasDualPods
@@ -48,7 +52,7 @@ data class PodDevice(
val hasDualMicrophone: Boolean get() = ble is HasDualMicrophone
// Signal / timing
val seenLastAt: Instant? get() = ble?.seenLastAt
val seenLastAt: Instant? get() = ble?.seenLastAt ?: cached?.lastSeenAt
val seenFirstAt: Instant? get() = ble?.seenFirstAt
val signalQuality: Float
get() {
@@ -70,31 +74,54 @@ data class PodDevice(
}
}
// Battery — AAP preferred, BLE fallback
// Battery — AAP preferred, BLE fallback, then cached
val batteryLeft: Float?
get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent ?: cached?.left?.percent
val batteryRight: Float?
get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent ?: cached?.right?.percent
val batteryCase: Float?
get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent
get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent ?: cached?.case?.percent
val batteryHeadset: Float?
get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent ?: cached?.headset?.percent
// Charging — AAP preferred, BLE fallback
/** True when at least one displayed battery value was filled from cache (not live). */
val isBatteryCached: Boolean
get() {
if (cached == null) return false
val usedLeft = aap?.batteryLeft == null && (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent == null && cached.left != null
val usedRight = aap?.batteryRight == null && (ble as? DualBlePodSnapshot)?.batteryRightPodPercent == null && cached.right != null
val usedCase = aap?.batteryCase == null && (ble as? HasCase)?.batteryCasePercent == null && cached.case != null
val usedHeadset = aap?.batteryHeadset == null && (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent == null && cached.headset != null
return usedLeft || usedRight || usedCase || usedHeadset
}
/** Oldest per-slot timestamp among battery values that fell through to cache. Null if all live. */
val cachedBatteryAt: Instant?
get() {
if (cached == null) return null
return listOfNotNull(
cached.left?.updatedAt.takeIf { aap?.batteryLeft == null && (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent == null },
cached.right?.updatedAt.takeIf { aap?.batteryRight == null && (ble as? DualBlePodSnapshot)?.batteryRightPodPercent == null },
cached.case?.updatedAt.takeIf { aap?.batteryCase == null && (ble as? HasCase)?.batteryCasePercent == null },
cached.headset?.updatedAt.takeIf { aap?.batteryHeadset == null && (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent == null },
).minOrNull()
}
// Charging — AAP preferred, BLE fallback, then cached
val isLeftPodCharging: Boolean?
get() = aap?.isLeftCharging ?: (ble as? HasChargeDetectionDual)?.isLeftPodCharging
get() = aap?.isLeftCharging ?: (ble as? HasChargeDetectionDual)?.isLeftPodCharging ?: cached?.isLeftCharging
val isRightPodCharging: Boolean?
get() = aap?.isRightCharging ?: (ble as? HasChargeDetectionDual)?.isRightPodCharging
get() = aap?.isRightCharging ?: (ble as? HasChargeDetectionDual)?.isRightPodCharging ?: cached?.isRightCharging
val isCaseCharging: Boolean?
get() = aap?.isCaseCharging ?: (ble as? HasCase)?.isCaseCharging
get() = aap?.isCaseCharging ?: (ble as? HasCase)?.isCaseCharging ?: cached?.isCaseCharging
val isHeadsetBeingCharged: Boolean?
get() = aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged
get() = aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged ?: cached?.isHeadsetCharging
// Resolved primary pod: AAP cmd 0x08 preferred, BLE bit 5 fallback.
private val resolvedPrimaryPod: DualBlePodSnapshot.Pod?
@@ -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")
}
}
@@ -36,6 +36,7 @@ import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.reaction.core.aap.AapAutoConnect
import eu.darken.capod.reaction.core.DeviceStatePersister
import eu.darken.capod.reaction.core.aap.AapKeyPersister
import eu.darken.capod.reaction.core.autoconnect.AutoConnect
import eu.darken.capod.reaction.core.playpause.PlayPause
@@ -77,6 +78,7 @@ class MonitorService : Service() {
@Inject lateinit var profilesRepo: DeviceProfilesRepo
@Inject lateinit var aapAutoConnect: AapAutoConnect
@Inject lateinit var aapKeyPersister: AapKeyPersister
@Inject lateinit var deviceStatePersister: DeviceStatePersister
@Inject lateinit var aapConnectionManager: AapConnectionManager
private val monitorScope = MonitorCoroutineScope()
@@ -307,6 +309,11 @@ class MonitorService : Service() {
.catch { log(TAG, WARN) { "aapKeyPersister failed:\n${it.asLog()}" } }
.launchIn(monitorScope)
deviceStatePersister.monitor()
.setupCommonEventHandlers(TAG) { "deviceStatePersister" }
.catch { log(TAG, WARN) { "deviceStatePersister failed:\n${it.asLog()}" } }
.launchIn(monitorScope)
log(TAG, VERBOSE) { "Monitor job is active" }
monitorJob.join()
log(TAG, VERBOSE) { "Monitor job quit" }
@@ -8,7 +8,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.monitor.core.PodDeviceCache
import eu.darken.capod.monitor.core.DeviceStateCache
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -25,7 +25,7 @@ class DeviceProfilesRepo @Inject constructor(
@ApplicationContext private val context: Context,
private val generalSettings: GeneralSettings,
private val settings: DeviceProfilesSettings,
private val podDeviceCache: PodDeviceCache,
private val deviceStateCache: DeviceStateCache,
) {
private val mutex = Mutex()
@@ -85,7 +85,7 @@ class DeviceProfilesRepo @Inject constructor(
val updatedProfiles = currentContainer.profiles.filter { it.id != profileId }
settings.profiles.valueBlocking = DeviceProfilesContainer(updatedProfiles)
log(VERBOSE) { "Removed device profile with ID: $profileId" }
podDeviceCache.delete(profileId)
deviceStateCache.delete(profileId)
}
suspend fun reorderProfiles(profiles: List<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>) {
@@ -0,0 +1,92 @@
package eu.darken.capod.reaction.core
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.monitor.core.CachedDeviceState
import eu.darken.capod.monitor.core.CachedDeviceState.CachedBatterySlot
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.DeviceStateCache
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.devices.HasCase
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetection
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetectionDual
import kotlinx.coroutines.flow.Flow
import java.time.Duration
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
/**
* Persists combined device state (battery, charging, model) to [DeviceStateCache].
* Reads from raw BLE/AAP sources to avoid re-persisting cached values.
*/
@Singleton
class DeviceStatePersister @Inject constructor(
private val deviceMonitor: DeviceMonitor,
private val deviceStateCache: DeviceStateCache,
) {
fun monitor(): Flow<Unit> = deviceMonitor.devices
.onEach { devices ->
for (device in devices) {
if (!device.isLive) continue
val profileId = device.profileId ?: continue
val now = Instant.now()
val existing = deviceStateCache.cachedStates.value[profileId]
val liveLeft = device.aap?.batteryLeft ?: (device.ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
val liveRight = device.aap?.batteryRight ?: (device.ble as? DualBlePodSnapshot)?.batteryRightPodPercent
val liveCase = device.aap?.batteryCase ?: (device.ble as? HasCase)?.batteryCasePercent
val liveHeadset = device.aap?.batteryHeadset ?: (device.ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
// At least one live battery must be non-null to be worth persisting
if (liveLeft == null && liveRight == null && liveCase == null && liveHeadset == null) continue
val newState = CachedDeviceState(
profileId = profileId,
model = device.model,
address = device.address,
left = liveLeft?.let { CachedBatterySlot(it, now) } ?: existing?.left,
right = liveRight?.let { CachedBatterySlot(it, now) } ?: existing?.right,
case = liveCase?.let { CachedBatterySlot(it, now) } ?: existing?.case,
headset = liveHeadset?.let { CachedBatterySlot(it, now) } ?: existing?.headset,
isLeftCharging = device.aap?.isLeftCharging ?: (device.ble as? HasChargeDetectionDual)?.isLeftPodCharging ?: existing?.isLeftCharging,
isRightCharging = device.aap?.isRightCharging ?: (device.ble as? HasChargeDetectionDual)?.isRightPodCharging ?: existing?.isRightCharging,
isCaseCharging = device.aap?.isCaseCharging ?: (device.ble as? HasCase)?.isCaseCharging ?: existing?.isCaseCharging,
isHeadsetCharging = device.aap?.isHeadsetCharging ?: (device.ble as? HasChargeDetection)?.isHeadsetBeingCharged ?: existing?.isHeadsetCharging,
lastSeenAt = device.seenLastAt ?: now,
)
if (isSameState(existing, newState)) continue
log(TAG, VERBOSE) { "Persisting state for $profileId (L=${liveLeft} R=${liveRight} C=${liveCase} H=${liveHeadset})" }
deviceStateCache.save(profileId, newState)
}
}
.map { }
.setupCommonEventHandlers(TAG) { "deviceStatePersister" }
private fun isSameState(old: CachedDeviceState?, new: CachedDeviceState): Boolean {
if (old == null) return false
// Update lastSeenAt periodically so the staleness label stays fresh when device goes offline
if (Duration.between(old.lastSeenAt, new.lastSeenAt).abs() > Duration.ofMinutes(1)) return false
return old.left?.percent == new.left?.percent
&& old.right?.percent == new.right?.percent
&& old.case?.percent == new.case?.percent
&& old.headset?.percent == new.headset?.percent
&& old.isLeftCharging == new.isLeftCharging
&& old.isRightCharging == new.isRightCharging
&& old.isCaseCharging == new.isCaseCharging
&& old.isHeadsetCharging == new.isHeadsetCharging
}
companion object {
private val TAG = logTag("Reaction", "DeviceStatePersister")
}
}
+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>