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