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