mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 02:36:12 -04:00
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:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,201 @@
|
||||
package eu.darken.capod.monitor.core
|
||||
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
||||
import io.kotest.matchers.nulls.shouldBeNull
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
import java.time.Instant
|
||||
|
||||
class PodDeviceCacheTest : BaseTest() {
|
||||
|
||||
private val fiveMinAgo = Instant.parse("2026-03-31T11:55:00Z")
|
||||
private val oneHourAgo = Instant.parse("2026-03-31T11:00:00Z")
|
||||
|
||||
private val cachedState = CachedDeviceState(
|
||||
profileId = "test-profile",
|
||||
model = PodModel.AIRPODS_PRO3,
|
||||
address = "AA:BB:CC:DD:EE:FF",
|
||||
left = CachedDeviceState.CachedBatterySlot(0.8f, fiveMinAgo),
|
||||
right = CachedDeviceState.CachedBatterySlot(0.7f, fiveMinAgo),
|
||||
case = CachedDeviceState.CachedBatterySlot(0.5f, oneHourAgo),
|
||||
headset = null,
|
||||
isLeftCharging = false,
|
||||
isRightCharging = false,
|
||||
isCaseCharging = true,
|
||||
lastSeenAt = fiveMinAgo,
|
||||
)
|
||||
|
||||
/**
|
||||
* DualApplePods extends DualBlePodSnapshot, HasCase, HasChargeDetectionDual, etc.
|
||||
* Using it as the mock type ensures all interface casts in PodDevice work correctly.
|
||||
*/
|
||||
private fun mockDualPod(
|
||||
leftBattery: Float? = null,
|
||||
rightBattery: Float? = null,
|
||||
caseBattery: Float? = null,
|
||||
): DualApplePods = mockk(relaxed = true) {
|
||||
every { batteryLeftPodPercent } returns leftBattery
|
||||
every { batteryRightPodPercent } returns rightBattery
|
||||
every { batteryCasePercent } returns caseBattery
|
||||
every { model } returns PodModel.AIRPODS_PRO3
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class CacheFallback {
|
||||
|
||||
@Test
|
||||
fun `battery falls back to cache when live sources are null`() {
|
||||
val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = cachedState)
|
||||
device.batteryLeft shouldBe 0.8f
|
||||
device.batteryRight shouldBe 0.7f
|
||||
device.batteryCase shouldBe 0.5f
|
||||
device.batteryHeadset.shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `live BLE takes precedence over cache`() {
|
||||
val device = PodDevice(
|
||||
profileId = "test-profile", ble = mockDualPod(leftBattery = 0.9f, rightBattery = 0.6f, caseBattery = 0.3f),
|
||||
aap = null,
|
||||
cached = cachedState,
|
||||
)
|
||||
device.batteryLeft shouldBe 0.9f
|
||||
device.batteryRight shouldBe 0.6f
|
||||
device.batteryCase shouldBe 0.3f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `live AAP takes precedence over both BLE and cache`() {
|
||||
val aapState = AapPodState(
|
||||
batteries = mapOf(
|
||||
AapPodState.BatteryType.LEFT to AapPodState.Battery(AapPodState.BatteryType.LEFT, 0.95f, AapPodState.ChargingState.NOT_CHARGING),
|
||||
)
|
||||
)
|
||||
val device = PodDevice(
|
||||
profileId = "test-profile", ble = mockDualPod(leftBattery = 0.5f),
|
||||
aap = aapState,
|
||||
cached = cachedState,
|
||||
)
|
||||
device.batteryLeft shouldBe 0.95f // AAP wins
|
||||
device.batteryRight shouldBe 0.7f // BLE null -> cache
|
||||
device.batteryCase shouldBe 0.5f // BLE null -> cache
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `charging falls back to cache`() {
|
||||
val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = cachedState)
|
||||
device.isLeftPodCharging shouldBe false
|
||||
device.isCaseCharging shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `model falls back to cache`() {
|
||||
val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = cachedState)
|
||||
device.model shouldBe PodModel.AIRPODS_PRO3
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `seenLastAt falls back to cache`() {
|
||||
val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = cachedState)
|
||||
device.seenLastAt shouldBe fiveMinAgo
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profileId falls back to cache`() {
|
||||
val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = cachedState)
|
||||
device.profileId shouldBe "test-profile"
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class IsLive {
|
||||
|
||||
@Test
|
||||
fun `isLive true when BLE present`() {
|
||||
val device = PodDevice(profileId = "test-profile", ble = mockDualPod(), aap = null, cached = cachedState)
|
||||
device.isLive shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isLive true when AAP present`() {
|
||||
val device = PodDevice(profileId = "test-profile", ble = null, aap = AapPodState(), cached = cachedState)
|
||||
device.isLive shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isLive false when only cache`() {
|
||||
val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = cachedState)
|
||||
device.isLive shouldBe false
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class StalenessDetection {
|
||||
|
||||
@Test
|
||||
fun `isBatteryCached true when all live null and cache has values`() {
|
||||
val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = cachedState)
|
||||
device.isBatteryCached shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isBatteryCached false when all live sources have data`() {
|
||||
val device = PodDevice(
|
||||
profileId = "test-profile", ble = mockDualPod(leftBattery = 0.9f, rightBattery = 0.8f, caseBattery = 0.3f),
|
||||
aap = null,
|
||||
cached = cachedState,
|
||||
)
|
||||
device.isBatteryCached shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isBatteryCached true when BLE present but pod batteries are null`() {
|
||||
val device = PodDevice(
|
||||
profileId = "test-profile", ble = mockDualPod(caseBattery = 0.4f),
|
||||
aap = null,
|
||||
cached = cachedState,
|
||||
)
|
||||
device.isBatteryCached shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isBatteryCached false when no cache`() {
|
||||
val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = null)
|
||||
device.isBatteryCached shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cachedBatteryAt returns oldest cached slot timestamp`() {
|
||||
val device = PodDevice(profileId = "test-profile", ble = null, aap = null, cached = cachedState)
|
||||
device.cachedBatteryAt shouldBe oneHourAgo
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cachedBatteryAt null when live data covers all slots`() {
|
||||
val device = PodDevice(
|
||||
profileId = "test-profile", ble = mockDualPod(leftBattery = 0.9f, rightBattery = 0.8f, caseBattery = 0.3f),
|
||||
aap = null,
|
||||
cached = cachedState,
|
||||
)
|
||||
device.cachedBatteryAt.shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cachedBatteryAt returns only timestamp of slots that fell through`() {
|
||||
val device = PodDevice(
|
||||
profileId = "test-profile", ble = mockDualPod(caseBattery = 0.4f),
|
||||
aap = null,
|
||||
cached = cachedState,
|
||||
)
|
||||
device.cachedBatteryAt.shouldNotBeNull()
|
||||
device.cachedBatteryAt shouldBe fiveMinAgo
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ class PodDeviceTest : BaseTest() {
|
||||
|
||||
@Test
|
||||
fun `BLE-only device exposes battery from BLE`() {
|
||||
val device = PodDevice(ble = mockDualPod(leftBattery = 0.8f), aap = null)
|
||||
val device = PodDevice(profileId = null, ble = mockDualPod(leftBattery = 0.8f), aap = null)
|
||||
device.batteryLeft shouldBe 0.8f
|
||||
device.isAapConnected shouldBe false
|
||||
}
|
||||
@@ -49,7 +49,7 @@ class PodDeviceTest : BaseTest() {
|
||||
@Test
|
||||
fun `capabilities come from model features`() {
|
||||
val device = PodDevice(
|
||||
ble = mockDualPod(model = PodModel.AIRPODS_PRO3),
|
||||
profileId = null, ble = mockDualPod(model = PodModel.AIRPODS_PRO3),
|
||||
aap = null,
|
||||
)
|
||||
device.hasDualPods shouldBe true
|
||||
@@ -61,7 +61,7 @@ class PodDeviceTest : BaseTest() {
|
||||
@Test
|
||||
fun `Beats Solo 3 has no dual pods or case`() {
|
||||
val device = PodDevice(
|
||||
ble = mockk(relaxed = true) { every { model } returns PodModel.BEATS_SOLO_3 },
|
||||
profileId = null, ble = mockk(relaxed = true) { every { model } returns PodModel.BEATS_SOLO_3 },
|
||||
aap = null,
|
||||
)
|
||||
device.hasDualPods shouldBe false
|
||||
@@ -79,7 +79,7 @@ class PodDeviceTest : BaseTest() {
|
||||
),
|
||||
),
|
||||
)
|
||||
val device = PodDevice(ble = mockDualPod(), aap = aap)
|
||||
val device = PodDevice(profileId = null, ble = mockDualPod(), aap = aap)
|
||||
device.isAapConnected shouldBe true
|
||||
device.ancMode.shouldNotBeNull()
|
||||
device.ancMode!!.current shouldBe AapSetting.AncMode.Value.TRANSPARENCY
|
||||
@@ -87,13 +87,13 @@ class PodDeviceTest : BaseTest() {
|
||||
|
||||
@Test
|
||||
fun `ANC mode is null when not AAP connected`() {
|
||||
val device = PodDevice(ble = mockDualPod(), aap = null)
|
||||
val device = PodDevice(profileId = null, ble = mockDualPod(), aap = null)
|
||||
device.ancMode.shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null BLE gives UNKNOWN model`() {
|
||||
val device = PodDevice(ble = null, aap = null)
|
||||
val device = PodDevice(profileId = null, ble = null, aap = null)
|
||||
device.model shouldBe PodModel.UNKNOWN
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ class PodDeviceTest : BaseTest() {
|
||||
val id = BlePodSnapshot.Id()
|
||||
val meta = mockk<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
|
||||
},
|
||||
@@ -114,7 +114,7 @@ class PodDeviceTest : BaseTest() {
|
||||
|
||||
@Test
|
||||
fun `identity properties null when BLE null`() {
|
||||
val device = PodDevice(ble = null, aap = null)
|
||||
val device = PodDevice(profileId = null, ble = null, aap = null)
|
||||
device.identifier.shouldBeNull()
|
||||
device.meta.shouldBeNull()
|
||||
}
|
||||
@@ -124,7 +124,7 @@ class PodDeviceTest : BaseTest() {
|
||||
val now = Instant.now()
|
||||
val earlier = now.minusSeconds(60)
|
||||
val device = PodDevice(
|
||||
ble = mockk(relaxed = true) {
|
||||
profileId = null, ble = mockk(relaxed = true) {
|
||||
every { seenLastAt } returns now
|
||||
every { seenFirstAt } returns earlier
|
||||
every { signalQuality } returns 0.75f
|
||||
@@ -140,7 +140,7 @@ class PodDeviceTest : BaseTest() {
|
||||
|
||||
@Test
|
||||
fun `signal timing defaults when BLE null`() {
|
||||
val device = PodDevice(ble = null, aap = null)
|
||||
val device = PodDevice(profileId = null, ble = null, aap = null)
|
||||
device.seenLastAt.shouldBeNull()
|
||||
device.seenFirstAt.shouldBeNull()
|
||||
device.signalQuality shouldBe 0f
|
||||
@@ -155,7 +155,7 @@ class PodDeviceTest : BaseTest() {
|
||||
every { (this@mockk as HasChargeDetectionDual).isRightPodCharging } returns false
|
||||
every { (this@mockk as HasCase).isCaseCharging } returns true
|
||||
}
|
||||
val device = PodDevice(ble = mock, aap = null)
|
||||
val device = PodDevice(profileId = null, ble = mock, aap = null)
|
||||
device.isLeftPodCharging shouldBe true
|
||||
device.isRightPodCharging shouldBe false
|
||||
device.isCaseCharging shouldBe true
|
||||
@@ -170,7 +170,7 @@ class PodDeviceTest : BaseTest() {
|
||||
every { (this@mockk as HasEarDetection).isBeingWorn } returns false
|
||||
every { (this@mockk as HasEarDetectionDual).isEitherPodInEar } returns true
|
||||
}
|
||||
val device = PodDevice(ble = mock, aap = null)
|
||||
val device = PodDevice(profileId = null, ble = mock, aap = null)
|
||||
device.isLeftInEar shouldBe true
|
||||
device.isRightInEar shouldBe false
|
||||
device.isBeingWorn shouldBe false
|
||||
@@ -192,7 +192,7 @@ class PodDeviceTest : BaseTest() {
|
||||
),
|
||||
),
|
||||
)
|
||||
val device = PodDevice(ble = mock, aap = aap)
|
||||
val device = PodDevice(profileId = null, ble = mock, aap = aap)
|
||||
device.isEitherPodInEar shouldBe true
|
||||
}
|
||||
|
||||
@@ -203,7 +203,7 @@ class PodDeviceTest : BaseTest() {
|
||||
every { (this@mockk as HasEarDetectionDual).isEitherPodInEar } returns true
|
||||
}
|
||||
val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY)
|
||||
val device = PodDevice(ble = mock, aap = aap)
|
||||
val device = PodDevice(profileId = null, ble = mock, aap = aap)
|
||||
device.isEitherPodInEar shouldBe true
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ class PodDeviceTest : BaseTest() {
|
||||
),
|
||||
),
|
||||
)
|
||||
val device = PodDevice(ble = mock, aap = aap)
|
||||
val device = PodDevice(profileId = null, ble = mock, aap = aap)
|
||||
device.isLeftInEar shouldBe true
|
||||
device.isRightInEar shouldBe false
|
||||
}
|
||||
@@ -242,7 +242,7 @@ class PodDeviceTest : BaseTest() {
|
||||
),
|
||||
),
|
||||
)
|
||||
val device = PodDevice(ble = mock, aap = aap)
|
||||
val device = PodDevice(profileId = null, ble = mock, aap = aap)
|
||||
device.isLeftInEar shouldBe false
|
||||
device.isRightInEar shouldBe true
|
||||
}
|
||||
@@ -261,7 +261,7 @@ class PodDeviceTest : BaseTest() {
|
||||
),
|
||||
),
|
||||
)
|
||||
val device = PodDevice(ble = mock, aap = aap)
|
||||
val device = PodDevice(profileId = null, ble = mock, aap = aap)
|
||||
device.isBeingWorn shouldBe true
|
||||
}
|
||||
|
||||
@@ -279,7 +279,7 @@ class PodDeviceTest : BaseTest() {
|
||||
),
|
||||
),
|
||||
)
|
||||
val device = PodDevice(ble = mock, aap = aap)
|
||||
val device = PodDevice(profileId = null, ble = mock, aap = aap)
|
||||
device.isBeingWorn shouldBe false
|
||||
}
|
||||
|
||||
@@ -301,7 +301,7 @@ class PodDeviceTest : BaseTest() {
|
||||
AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(AapSetting.PrimaryPod.Pod.LEFT),
|
||||
),
|
||||
)
|
||||
val device = PodDevice(ble = mock, aap = aap)
|
||||
val device = PodDevice(profileId = null, ble = mock, aap = aap)
|
||||
device.isLeftInEar shouldBe true
|
||||
device.isRightInEar shouldBe false
|
||||
}
|
||||
@@ -322,7 +322,7 @@ class PodDeviceTest : BaseTest() {
|
||||
AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(AapSetting.PrimaryPod.Pod.LEFT), // AAP says LEFT
|
||||
),
|
||||
)
|
||||
val device = PodDevice(ble = mock, aap = aap)
|
||||
val device = PodDevice(profileId = null, ble = mock, aap = aap)
|
||||
device.isLeftInEar shouldBe true // AAP wins
|
||||
device.isRightInEar shouldBe false
|
||||
}
|
||||
@@ -343,7 +343,7 @@ class PodDeviceTest : BaseTest() {
|
||||
// No PrimaryPod setting — falls back to BLE
|
||||
),
|
||||
)
|
||||
val device = PodDevice(ble = mock, aap = aap)
|
||||
val device = PodDevice(profileId = null, ble = mock, aap = aap)
|
||||
device.isLeftInEar shouldBe false
|
||||
device.isRightInEar shouldBe true // BLE says RIGHT is primary
|
||||
}
|
||||
@@ -361,7 +361,7 @@ class PodDeviceTest : BaseTest() {
|
||||
AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(AapSetting.PrimaryPod.Pod.LEFT),
|
||||
),
|
||||
)
|
||||
val device = PodDevice(ble = mock, aap = aap)
|
||||
val device = PodDevice(profileId = null, ble = mock, aap = aap)
|
||||
device.isLeftPodMicrophone shouldBe true // AAP says LEFT
|
||||
device.isRightPodMicrophone shouldBe false
|
||||
}
|
||||
@@ -374,7 +374,7 @@ class PodDeviceTest : BaseTest() {
|
||||
every { isRightPodMicrophone } returns true
|
||||
}
|
||||
val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY)
|
||||
val device = PodDevice(ble = mock, aap = aap)
|
||||
val device = PodDevice(profileId = null, ble = mock, aap = aap)
|
||||
device.isLeftPodMicrophone shouldBe false
|
||||
device.isRightPodMicrophone shouldBe true // BLE fallback
|
||||
}
|
||||
@@ -394,7 +394,7 @@ class PodDeviceTest : BaseTest() {
|
||||
AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(AapSetting.PrimaryPod.Pod.RIGHT),
|
||||
),
|
||||
)
|
||||
val device = PodDevice(ble = mock, aap = aap)
|
||||
val device = PodDevice(profileId = null, ble = mock, aap = aap)
|
||||
device.isLeftPodMicrophone shouldBe false
|
||||
device.isRightPodMicrophone shouldBe true
|
||||
}
|
||||
@@ -414,7 +414,7 @@ class PodDeviceTest : BaseTest() {
|
||||
AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(AapSetting.PrimaryPod.Pod.LEFT),
|
||||
),
|
||||
)
|
||||
val device = PodDevice(ble = mock, aap = aap)
|
||||
val device = PodDevice(profileId = null, ble = mock, aap = aap)
|
||||
device.isLeftPodMicrophone shouldBe true
|
||||
device.isRightPodMicrophone shouldBe false
|
||||
}
|
||||
@@ -425,27 +425,27 @@ class PodDeviceTest : BaseTest() {
|
||||
connectionState = AapPodState.ConnectionState.READY,
|
||||
pendingAncMode = AapSetting.AncMode.Value.ADAPTIVE,
|
||||
)
|
||||
val device = PodDevice(ble = mockDualPod(), aap = aap)
|
||||
val device = PodDevice(profileId = null, ble = mockDualPod(), aap = aap)
|
||||
device.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pendingAncMode null when no AAP`() {
|
||||
val device = PodDevice(ble = mockDualPod(), aap = null)
|
||||
val device = PodDevice(profileId = null, ble = mockDualPod(), aap = null)
|
||||
device.pendingAncMode.shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pendingAncMode null when not set`() {
|
||||
val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY)
|
||||
val device = PodDevice(ble = mockDualPod(), aap = aap)
|
||||
val device = PodDevice(profileId = null, ble = mockDualPod(), aap = aap)
|
||||
device.pendingAncMode.shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `icon and label properties delegate to BLE`() {
|
||||
val device = PodDevice(
|
||||
ble = mockk(relaxed = true) {
|
||||
profileId = null, ble = mockk(relaxed = true) {
|
||||
every { model } returns PodModel.AIRPODS_PRO3
|
||||
every { iconRes } returns 42
|
||||
},
|
||||
@@ -456,14 +456,14 @@ class PodDeviceTest : BaseTest() {
|
||||
|
||||
@Test
|
||||
fun `rawDataHex empty when BLE null`() {
|
||||
val device = PodDevice(ble = null, aap = null)
|
||||
val device = PodDevice(profileId = null, ble = null, aap = null)
|
||||
device.rawDataHex shouldBe emptyList()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `battery falls back to BLE when AAP battery is null`() {
|
||||
val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY)
|
||||
val device = PodDevice(ble = mockDualPod(leftBattery = 0.8f), aap = aap)
|
||||
val device = PodDevice(profileId = null, ble = mockDualPod(leftBattery = 0.8f), aap = aap)
|
||||
device.batteryLeft shouldBe 0.8f
|
||||
device.isAapConnected shouldBe true
|
||||
}
|
||||
@@ -476,7 +476,7 @@ class PodDeviceTest : BaseTest() {
|
||||
AapPodState.BatteryType.LEFT to AapPodState.Battery(AapPodState.BatteryType.LEFT, 0.79f, AapPodState.ChargingState.NOT_CHARGING),
|
||||
),
|
||||
)
|
||||
val device = PodDevice(ble = mockDualPod(leftBattery = 0.8f), aap = aap)
|
||||
val device = PodDevice(profileId = null, ble = mockDualPod(leftBattery = 0.8f), aap = aap)
|
||||
device.batteryLeft shouldBe 0.79f // AAP 1% granularity wins over BLE 10%
|
||||
}
|
||||
|
||||
@@ -492,7 +492,7 @@ class PodDeviceTest : BaseTest() {
|
||||
AapPodState.BatteryType.LEFT to AapPodState.Battery(AapPodState.BatteryType.LEFT, 0.8f, AapPodState.ChargingState.CHARGING_OPTIMIZED),
|
||||
),
|
||||
)
|
||||
val device = PodDevice(ble = mock, aap = aap)
|
||||
val device = PodDevice(profileId = null, ble = mock, aap = aap)
|
||||
device.isLeftPodCharging shouldBe true // AAP CHARGING_OPTIMIZED counts as charging
|
||||
}
|
||||
|
||||
@@ -508,7 +508,7 @@ class PodDeviceTest : BaseTest() {
|
||||
every { this@mockk.address } returns bleRpa
|
||||
every { meta } returns ApplePods.AppleMeta(profile = profile)
|
||||
}
|
||||
val device = PodDevice(ble = ble, aap = null)
|
||||
val device = PodDevice(profileId = null, ble = ble, aap = null)
|
||||
device.address shouldBe bondedAddress
|
||||
device.bleAddress shouldBe bleRpa
|
||||
}
|
||||
@@ -520,7 +520,7 @@ class PodDeviceTest : BaseTest() {
|
||||
every { model } returns PodModel.AIRPODS_PRO3
|
||||
every { signalQuality } returns bleQuality
|
||||
}
|
||||
return PodDevice(ble = ble, aap = aap)
|
||||
return PodDevice(profileId = null, ble = ble, aap = aap)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -627,13 +627,13 @@ class PodDeviceTest : BaseTest() {
|
||||
|
||||
@Test
|
||||
fun `bleKeyState - null BLE returns NONE`() {
|
||||
val device = PodDevice(ble = null, aap = null)
|
||||
val device = PodDevice(profileId = null, ble = null, aap = null)
|
||||
device.bleKeyState shouldBe BleKeyState.NONE
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bleKeyState - non-Apple BLE returns NONE`() {
|
||||
val device = PodDevice(ble = mockk(relaxed = true) { every { model } returns PodModel.UNKNOWN }, aap = null)
|
||||
val device = PodDevice(profileId = null, ble = mockk(relaxed = true) { every { model } returns PodModel.UNKNOWN }, aap = null)
|
||||
device.bleKeyState shouldBe BleKeyState.NONE
|
||||
}
|
||||
|
||||
@@ -644,7 +644,7 @@ class PodDeviceTest : BaseTest() {
|
||||
every { meta } returns ApplePods.AppleMeta(isIRKMatch = false)
|
||||
every { payload } returns ProximityPayload(public = ProximityPayload.Public(UByteArray(9)), private = null)
|
||||
}
|
||||
val device = PodDevice(ble = ble, aap = null)
|
||||
val device = PodDevice(profileId = null, ble = ble, aap = null)
|
||||
device.bleKeyState shouldBe BleKeyState.NONE
|
||||
}
|
||||
|
||||
@@ -655,7 +655,7 @@ class PodDeviceTest : BaseTest() {
|
||||
every { meta } returns ApplePods.AppleMeta(isIRKMatch = true)
|
||||
every { payload } returns ProximityPayload(public = ProximityPayload.Public(UByteArray(9)), private = null)
|
||||
}
|
||||
val device = PodDevice(ble = ble, aap = null)
|
||||
val device = PodDevice(profileId = null, ble = ble, aap = null)
|
||||
device.bleKeyState shouldBe BleKeyState.IRK_ONLY
|
||||
}
|
||||
|
||||
@@ -669,7 +669,7 @@ class PodDeviceTest : BaseTest() {
|
||||
private = ProximityPayload.Private(UByteArray(8)),
|
||||
)
|
||||
}
|
||||
val device = PodDevice(ble = ble, aap = null)
|
||||
val device = PodDevice(profileId = null, ble = ble, aap = null)
|
||||
device.bleKeyState shouldBe BleKeyState.IRK_AND_ENCRYPTED
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package eu.darken.capod.reaction.core
|
||||
|
||||
import eu.darken.capod.monitor.core.CachedDeviceState
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.DeviceStateCache
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
import java.time.Instant
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DeviceStatePersisterTest : BaseTest() {
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
private lateinit var deviceMonitor: DeviceMonitor
|
||||
private lateinit var deviceStateCache: DeviceStateCache
|
||||
private lateinit var devicesFlow: MutableStateFlow<List<PodDevice>>
|
||||
private lateinit var cachedStatesFlow: MutableStateFlow<Map<String, CachedDeviceState>>
|
||||
|
||||
private val testProfile = AppleDeviceProfile(
|
||||
id = "test-profile",
|
||||
label = "Test AirPods",
|
||||
model = PodModel.AIRPODS_PRO3,
|
||||
address = "AA:BB:CC:DD:EE:FF",
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
devicesFlow = MutableStateFlow(emptyList())
|
||||
cachedStatesFlow = MutableStateFlow(emptyMap())
|
||||
|
||||
deviceMonitor = mockk {
|
||||
every { devices } returns devicesFlow
|
||||
}
|
||||
|
||||
deviceStateCache = mockk(relaxed = true) {
|
||||
every { cachedStates } returns cachedStatesFlow
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPersister() = DeviceStatePersister(
|
||||
deviceMonitor = deviceMonitor,
|
||||
deviceStateCache = deviceStateCache,
|
||||
)
|
||||
|
||||
private fun createLiveDevice(
|
||||
leftBattery: Float? = 0.8f,
|
||||
rightBattery: Float? = 0.7f,
|
||||
caseBattery: Float? = 0.5f,
|
||||
): PodDevice {
|
||||
val bleMeta = ApplePods.AppleMeta(profile = testProfile)
|
||||
val blePod: DualApplePods = mockk(relaxed = true) {
|
||||
every { meta } returns bleMeta
|
||||
every { batteryLeftPodPercent } returns leftBattery
|
||||
every { batteryRightPodPercent } returns rightBattery
|
||||
every { batteryCasePercent } returns caseBattery
|
||||
every { isLeftPodCharging } returns false
|
||||
every { isRightPodCharging } returns false
|
||||
every { isCaseCharging } returns false
|
||||
every { model } returns PodModel.AIRPODS_PRO3
|
||||
every { address } returns "5A:3B:1C:2D:4E:6F"
|
||||
every { seenLastAt } returns Instant.now()
|
||||
}
|
||||
return PodDevice(profileId = "test-profile", ble = blePod, aap = null, cached = null)
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class Persistence {
|
||||
|
||||
@Test
|
||||
fun `persists state for live device with battery`() = runTest(testDispatcher) {
|
||||
val persister = createPersister()
|
||||
|
||||
val job = launch { persister.monitor().toList() }
|
||||
devicesFlow.value = listOf(createLiveDevice())
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) { deviceStateCache.save("test-profile", any()) }
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips device with all null live batteries`() = runTest(testDispatcher) {
|
||||
val persister = createPersister()
|
||||
|
||||
val job = launch { persister.monitor().toList() }
|
||||
devicesFlow.value = listOf(createLiveDevice(leftBattery = null, rightBattery = null, caseBattery = null))
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 0) { deviceStateCache.save(any(), any()) }
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips cached-only devices`() = runTest(testDispatcher) {
|
||||
val persister = createPersister()
|
||||
val cachedOnlyDevice = PodDevice(
|
||||
profileId = "test-profile", ble = null,
|
||||
aap = null,
|
||||
cached = CachedDeviceState(
|
||||
profileId = "test-profile",
|
||||
model = PodModel.AIRPODS_PRO3,
|
||||
left = CachedDeviceState.CachedBatterySlot(0.5f, Instant.now()),
|
||||
lastSeenAt = Instant.now(),
|
||||
),
|
||||
)
|
||||
|
||||
val job = launch { persister.monitor().toList() }
|
||||
devicesFlow.value = listOf(cachedOnlyDevice)
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 0) { deviceStateCache.save(any(), any()) }
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips write when values unchanged`() = runTest(testDispatcher) {
|
||||
val persister = createPersister()
|
||||
|
||||
val existingCached = CachedDeviceState(
|
||||
profileId = "test-profile",
|
||||
model = PodModel.AIRPODS_PRO3,
|
||||
left = CachedDeviceState.CachedBatterySlot(0.8f, Instant.now()),
|
||||
right = CachedDeviceState.CachedBatterySlot(0.7f, Instant.now()),
|
||||
case = CachedDeviceState.CachedBatterySlot(0.5f, Instant.now()),
|
||||
isLeftCharging = false,
|
||||
isRightCharging = false,
|
||||
isCaseCharging = false,
|
||||
isHeadsetCharging = false,
|
||||
lastSeenAt = Instant.now(),
|
||||
)
|
||||
cachedStatesFlow.value = mapOf("test-profile" to existingCached)
|
||||
|
||||
val job = launch { persister.monitor().toList() }
|
||||
devicesFlow.value = listOf(createLiveDevice())
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 0) { deviceStateCache.save(any(), any()) }
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user