From ed37e142adf21521de88575d55551ff591270fae Mon Sep 17 00:00:00 2001 From: darken Date: Wed, 8 Apr 2026 17:17:50 +0200 Subject: [PATCH] fix(ui): Fix signal bars and restore individual connection icons --- .../main/ui/overview/cards/DualPodsCard.kt | 5 +- .../ui/overview/cards/PodCardComponents.kt | 95 ++++++++++--- .../main/ui/overview/cards/SinglePodsCard.kt | 5 +- .../ui/overview/cards/UnknownPodDeviceCard.kt | 2 +- .../capod/monitor/core/DeviceMonitor.kt | 14 ++ .../eu/darken/capod/monitor/core/PodDevice.kt | 48 +++++++ .../pods/core/apple/ble/BlePodSnapshot.kt | 21 ++- .../capod/reaction/ui/popup/PopUpContent.kt | 2 +- app/src/main/res/values/strings.xml | 1 + .../capod/monitor/core/PodDeviceTest.kt | 125 ++++++++++++++++++ .../pods/core/apple/ble/BlePodSnapshotTest.kt | 110 +++++++++++++++ 11 files changed, 397 insertions(+), 31 deletions(-) create mode 100644 app/src/test/java/eu/darken/capod/pods/core/apple/ble/BlePodSnapshotTest.kt diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt index 5753869e..8f88f734 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt @@ -102,7 +102,7 @@ fun DualPodsCard( modifier = Modifier.weight(1f, fill = false), ) SignalIndicator( - signalQuality = device.signalQuality, + signalQuality = device.rssiQuality, isLive = device.isLive, modifier = Modifier.padding(start = 6.dp), ) @@ -131,8 +131,7 @@ fun DualPodsCard( modifier = Modifier.weight(1f, fill = false), ) DeviceConnectionBadge( - bleKeyState = device.bleKeyState, - isAapConnected = device.isAapConnected, + state = device.connectionState, modifier = Modifier.padding(start = 6.dp), ) } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PodCardComponents.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PodCardComponents.kt index f5c5ef08..cab6f9ba 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PodCardComponents.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PodCardComponents.kt @@ -26,9 +26,10 @@ import androidx.compose.material.icons.outlined.Key import androidx.compose.material.icons.twotone.BatteryChargingFull import androidx.compose.material.icons.twotone.Bluetooth import androidx.compose.material.icons.twotone.Hearing -import androidx.compose.material.icons.twotone.Key import androidx.compose.material.icons.twotone.KeyboardVoice import androidx.compose.material.icons.twotone.LinkOff +import androidx.compose.material.icons.twotone.Lock +import androidx.compose.material.icons.twotone.SettingsInputAntenna import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SegmentedButton @@ -55,6 +56,7 @@ import eu.darken.capod.R import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.PreviewWrapper import eu.darken.capod.monitor.core.BleKeyState +import eu.darken.capod.monitor.core.ConnectionState import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting private val CapsuleShape = RoundedCornerShape(6.dp) @@ -244,11 +246,10 @@ fun SignalIndicator( @Composable fun DeviceConnectionBadge( - bleKeyState: BleKeyState, - isAapConnected: Boolean, + state: ConnectionState, modifier: Modifier = Modifier, ) { - if (bleKeyState == BleKeyState.NONE && !isAapConnected) return + if (!state.hasBleData && state.bleKeyState == BleKeyState.NONE && !state.isAapConnected) return Surface( modifier = modifier, shape = RoundedCornerShape(8.dp), @@ -257,20 +258,33 @@ fun DeviceConnectionBadge( Row( modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), ) { - if (bleKeyState != BleKeyState.NONE) { + if (state.hasBleData) { Icon( - imageVector = if (bleKeyState == BleKeyState.IRK_AND_ENCRYPTED) Icons.TwoTone.Key else Icons.Outlined.Key, - contentDescription = stringResource( - if (bleKeyState == BleKeyState.IRK_AND_ENCRYPTED) R.string.signal_badge_key_encrypted_cd - else R.string.signal_badge_key_irk_cd - ), + imageVector = Icons.TwoTone.SettingsInputAntenna, + contentDescription = stringResource(R.string.signal_badge_ble_cd), modifier = Modifier.size(12.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) - if (isAapConnected) Spacer(modifier = Modifier.width(3.dp)) } - if (isAapConnected) { + if (state.bleKeyState != BleKeyState.NONE) { + Icon( + imageVector = Icons.Outlined.Key, + contentDescription = stringResource(R.string.signal_badge_key_irk_cd), + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (state.bleKeyState == BleKeyState.IRK_AND_ENCRYPTED) { + Icon( + imageVector = Icons.TwoTone.Lock, + contentDescription = stringResource(R.string.signal_badge_key_encrypted_cd), + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (state.isAapConnected) { Icon( imageVector = Icons.TwoTone.Bluetooth, contentDescription = stringResource(R.string.signal_badge_aap_cd), @@ -367,20 +381,67 @@ fun ConversationAwarenessToggle( @Preview2 @Composable -private fun DeviceConnectionBadgeIrkPreview() = PreviewWrapper { - DeviceConnectionBadge(bleKeyState = BleKeyState.IRK_ONLY, isAapConnected = false) +private fun DeviceConnectionBadgeAllIconsPreview() = PreviewWrapper { + DeviceConnectionBadge( + state = ConnectionState( + hasBleData = true, + bleKeyState = BleKeyState.IRK_AND_ENCRYPTED, + isAapConnected = true, + rssiQuality = 1f, + ), + ) } @Preview2 @Composable -private fun DeviceConnectionBadgeAllIconsPreview() = PreviewWrapper { - DeviceConnectionBadge(bleKeyState = BleKeyState.IRK_AND_ENCRYPTED, isAapConnected = true) +private fun DeviceConnectionBadgeBleOnlyPreview() = PreviewWrapper { + DeviceConnectionBadge( + state = ConnectionState( + hasBleData = true, + bleKeyState = BleKeyState.NONE, + isAapConnected = false, + rssiQuality = 0.7f, + ), + ) +} + +@Preview2 +@Composable +private fun DeviceConnectionBadgeBleIrkPreview() = PreviewWrapper { + DeviceConnectionBadge( + state = ConnectionState( + hasBleData = true, + bleKeyState = BleKeyState.IRK_ONLY, + isAapConnected = false, + rssiQuality = 0.5f, + ), + ) +} + +@Preview2 +@Composable +private fun DeviceConnectionBadgeAapOnlyPreview() = PreviewWrapper { + DeviceConnectionBadge( + state = ConnectionState( + hasBleData = false, + bleKeyState = BleKeyState.NONE, + isAapConnected = true, + rssiQuality = 0f, + ), + ) } @Preview2 @Composable private fun DeviceConnectionBadgeEmptyPreview() = PreviewWrapper { - DeviceConnectionBadge(bleKeyState = BleKeyState.NONE, isAapConnected = false) + DeviceConnectionBadge( + state = ConnectionState( + hasBleData = false, + bleKeyState = BleKeyState.NONE, + isAapConnected = false, + rssiQuality = 0f, + ), + ) } @Preview2 diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt index 798111c6..fe74f599 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt @@ -112,7 +112,7 @@ fun SinglePodsCard( modifier = Modifier.weight(1f, fill = false), ) SignalIndicator( - signalQuality = device.signalQuality, + signalQuality = device.rssiQuality, isLive = device.isLive, modifier = Modifier.padding(start = 6.dp), ) @@ -130,8 +130,7 @@ fun SinglePodsCard( modifier = Modifier.weight(1f, fill = false), ) DeviceConnectionBadge( - bleKeyState = device.bleKeyState, - isAapConnected = device.isAapConnected, + state = device.connectionState, modifier = Modifier.padding(start = 6.dp), ) } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/UnknownPodDeviceCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/UnknownPodDeviceCard.kt index 6544168c..6f6a62d3 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/UnknownPodDeviceCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/UnknownPodDeviceCard.kt @@ -51,7 +51,7 @@ fun UnknownPodDeviceCard( modifier = Modifier.weight(1f, fill = false), ) SignalIndicator( - signalQuality = device.signalQuality, + signalQuality = device.rssiQuality, isLive = device.isLive, modifier = Modifier.padding(start = 6.dp), ) diff --git a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt index 8b0cc69d..dfec632c 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt @@ -13,6 +13,7 @@ import eu.darken.capod.monitor.core.cache.toCachedState import eu.darken.capod.pods.core.apple.PodModel import eu.darken.capod.pods.core.apple.aap.AapConnectionManager import eu.darken.capod.pods.core.apple.aap.AapPodState +import eu.darken.capod.profiles.core.AppleDeviceProfile import eu.darken.capod.profiles.core.DeviceProfile import eu.darken.capod.profiles.core.DeviceProfilesRepo import kotlinx.coroutines.CoroutineScope @@ -61,6 +62,7 @@ class DeviceMonitor @Inject constructor( cached = profile?.id?.let { cachedStates[it] }, profileAddress = profile?.address, profileModel = profile?.model, + profileKeyState = profile.toBleKeyState(), ) } @@ -85,6 +87,7 @@ class DeviceMonitor @Inject constructor( cached = cached, profileAddress = profile.address, profileModel = profile.model, + profileKeyState = profile.toBleKeyState(), ) } @@ -155,6 +158,7 @@ class DeviceMonitor @Inject constructor( cached = cached, profileAddress = profile?.address, profileModel = profile?.model, + profileKeyState = profile.toBleKeyState(), ) } @@ -166,6 +170,16 @@ class DeviceMonitor @Inject constructor( private fun Map.forProfile(profile: DeviceProfile?): AapPodState? = profile?.address?.let { this[it] } + /** + * Derives the stable badge key state from the profile's stored IRK/ENC. Used so the badge + * icons don't evaporate every time the BLE scanner misses a scan batch. + */ + private fun DeviceProfile?.toBleKeyState(): BleKeyState { + val apple = this as? AppleDeviceProfile ?: return BleKeyState.NONE + if (apple.identityKey == null) return BleKeyState.NONE + return if (apple.encryptionKey != null) BleKeyState.IRK_AND_ENCRYPTED else BleKeyState.IRK_ONLY + } + companion object { private val TAG = logTag("DeviceMonitor") } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt index 396e9a20..1ff217db 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt @@ -39,6 +39,12 @@ data class PodDevice( internal val profileAddress: BluetoothAddress? = null, /** Pod model from the profile — current source of truth, preferred over cache snapshot. */ internal val profileModel: PodModel? = null, + /** + * Key state derived from the profile's stored IRK/ENC. Used as the source of truth for the + * badge icons when the device is live — per-scan IRK matching would otherwise evaporate + * every time the BLE scanner misses the next advertisement batch. + */ + internal val profileKeyState: BleKeyState = BleKeyState.NONE, ) { val model: PodModel get() = ble?.model ?: profileModel ?: cached?.model ?: PodModel.UNKNOWN /** Bonded BR/EDR address (from profile). Used for AAP commands. */ @@ -65,8 +71,35 @@ data class PodDevice( val bleQuality = ble?.signalQuality ?: 0f return (bleQuality + computeAapBoost(Instant.now())).coerceAtMost(1f) } + + /** + * Quality value for the signal bar display. Prefers live BLE RSSI; when BLE is absent + * (common for a device that's actively connected to us via classic BT — we stop receiving + * its BLE advertisements), falls back to a full-bars constant as long as AAP is READY. + * + * Note: we don't try to graduate this off AAP message age. The AirPods only push messages + * on state changes (battery %, ear detection, setting toggles, user taps). A user quietly + * listening to music can go minutes without any AAP traffic, which is still a perfectly + * healthy connection. A quiet channel is not a weak channel. + */ + val rssiQuality: Float + get() { + ble?.rssiQuality?.let { return it } + val state = aap ?: return 0f + return if (state.connectionState == AapPodState.ConnectionState.READY) 1.0f else 0f + } + val rssi: Int get() = ble?.rssi ?: 0 + /** Snapshot of connection-related state for badges and bars. */ + val connectionState: ConnectionState + get() = ConnectionState( + hasBleData = ble != null, + bleKeyState = bleKeyState, + isAapConnected = isAapConnected, + rssiQuality = rssiQuality, + ) + internal fun computeAapBoost(now: Instant): Float { val state = aap ?: return 0f if (state.connectionState != AapPodState.ConnectionState.READY) return 0.05f @@ -291,8 +324,16 @@ data class PodDevice( val isAapReady: Boolean get() = aap?.connectionState == AapPodState.ConnectionState.READY + /** + * Badge key state for the overview card. Profile-stored keys are the source of truth while + * the device is live: a profile that has an IRK (and optionally ENC) keeps showing those + * icons whether the current scan happens to include a fresh BLE advertisement or not. For + * anonymous BLE pods (no profile), we still derive the state from the live scan result. + */ val bleKeyState: BleKeyState get() { + if (!isLive) return BleKeyState.NONE + if (profileKeyState != BleKeyState.NONE) return profileKeyState val applePod = ble as? ApplePods ?: return BleKeyState.NONE if (!applePod.meta.isIRKMatch) return BleKeyState.NONE return if (applePod.payload.private != null) BleKeyState.IRK_AND_ENCRYPTED else BleKeyState.IRK_ONLY @@ -300,3 +341,10 @@ data class PodDevice( } enum class BleKeyState { NONE, IRK_ONLY, IRK_AND_ENCRYPTED } + +data class ConnectionState( + val hasBleData: Boolean, + val bleKeyState: BleKeyState, + val isAapConnected: Boolean, + val rssiQuality: Float, +) diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/BlePodSnapshot.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/BlePodSnapshot.kt index 6058dd71..5b2815cf 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/BlePodSnapshot.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/BlePodSnapshot.kt @@ -11,7 +11,6 @@ import eu.darken.capod.profiles.core.DeviceProfile import java.time.Duration import java.time.Instant import java.util.UUID -import kotlin.math.abs import kotlin.math.max interface BlePodSnapshot { @@ -36,13 +35,23 @@ interface BlePodSnapshot { val reliability: Float + /** + * Pure RSSI quality for display bars — linear map of -100..-30 dBm → 0..1. + * Does not include reliability/age. Use for SignalIndicator only, not for + * device matching or sorting (that's signalQuality's job). + */ + val rssiQuality: Float + get() = ((rssi + 100) / 70f).coerceIn(0f, 1f) + + /** + * Composite quality for profile matching and sorting. Weighted blend of + * RSSI strength, detection reliability, and observation age. + * Used by AppleFactory (minimumSignalQuality filter) and TroubleShooter + * (closest-device selection). Not used for display bars. + */ val signalQuality: Float get() { - /** - * This is not correct but it works ¯\_(ツ)_/¯ - * The range of the RSSI is device specific (ROMs). - */ - val sqRssi = ((100 - abs(rssi)) / 100f) + val sqRssi = ((rssi + 100) / 70f).coerceIn(0f, 1f) val sqReliability = max(BASE_CONFIDENCE, reliability) val sqAge = (Duration.between(seenFirstAt, Instant.now()).toMinutes().coerceAtMost(60) / 60f) * 0.25f log(VERBOSE) { "Signal Quality ($address): rssi=$sqRssi, reliability=$reliability, age=$sqAge" } diff --git a/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpContent.kt b/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpContent.kt index a7353693..5dccf462 100644 --- a/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpContent.kt +++ b/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpContent.kt @@ -75,7 +75,7 @@ fun PopUpContent( modifier = Modifier.weight(1f, fill = false), ) SignalIndicator( - signalQuality = device.signalQuality, + signalQuality = device.rssiQuality, isLive = device.isLive, modifier = Modifier.padding(start = 6.dp), ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ab2964a4..c038f069 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -270,6 +270,7 @@ Transparency Adaptive Conversation Awareness + BLE scan active Identity verified Encrypted connection Direct connection active diff --git a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt index e1368d6c..08233349 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt @@ -668,4 +668,129 @@ class PodDeviceTest : BaseTest() { val device = PodDevice(profileId = null, ble = ble, aap = null) device.bleKeyState shouldBe BleKeyState.IRK_AND_ENCRYPTED } + + @Test + fun `bleKeyState - profile IRK_AND_ENCRYPTED wins while AAP is live with no BLE`() { + val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY) + val device = PodDevice( + profileId = "p1", + ble = null, + aap = aap, + profileKeyState = BleKeyState.IRK_AND_ENCRYPTED, + ) + device.bleKeyState shouldBe BleKeyState.IRK_AND_ENCRYPTED + } + + @Test + fun `bleKeyState - profile IRK_ONLY shown while AAP is live with no BLE`() { + val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY) + val device = PodDevice( + profileId = "p1", + ble = null, + aap = aap, + profileKeyState = BleKeyState.IRK_ONLY, + ) + device.bleKeyState shouldBe BleKeyState.IRK_ONLY + } + + @Test + fun `bleKeyState - profile keys ignored when device is not live`() { + val device = PodDevice( + profileId = "p1", + ble = null, + aap = null, + profileKeyState = BleKeyState.IRK_AND_ENCRYPTED, + ) + device.bleKeyState shouldBe BleKeyState.NONE + } + + @Test + fun `bleKeyState - profile keys override unmatched live BLE`() { + val ble = mockk(relaxed = true) { + every { model } returns PodModel.AIRPODS_PRO3 + every { meta } returns ApplePods.AppleMeta(isIRKMatch = false) + every { payload } returns ProximityPayload(public = ProximityPayload.Public(UByteArray(9)), private = null) + } + val device = PodDevice( + profileId = "p1", + ble = ble, + aap = null, + profileKeyState = BleKeyState.IRK_AND_ENCRYPTED, + ) + device.bleKeyState shouldBe BleKeyState.IRK_AND_ENCRYPTED + } + + // --- rssiQuality tests --- + + @Test + fun `rssiQuality - uses BLE value when BLE present`() { + val ble = mockk(relaxed = true) { + every { model } returns PodModel.AIRPODS_PRO3 + every { rssiQuality } returns 0.42f + } + val device = PodDevice(profileId = null, ble = ble, aap = null) + device.rssiQuality shouldBe 0.42f + } + + @Test + fun `rssiQuality - zero when no BLE and no AAP`() { + val device = PodDevice(profileId = null, ble = null, aap = null) + device.rssiQuality shouldBe 0f + } + + @Test + fun `rssiQuality - zero when AAP CONNECTING`() { + val aap = AapPodState(connectionState = AapPodState.ConnectionState.CONNECTING) + val device = PodDevice(profileId = "p1", ble = null, aap = aap) + device.rssiQuality shouldBe 0f + } + + @Test + fun `rssiQuality - zero when AAP HANDSHAKING`() { + val aap = AapPodState(connectionState = AapPodState.ConnectionState.HANDSHAKING) + val device = PodDevice(profileId = "p1", ble = null, aap = aap) + device.rssiQuality shouldBe 0f + } + + @Test + fun `rssiQuality - full when AAP READY regardless of lastMessageAt null`() { + val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY, lastMessageAt = null) + val device = PodDevice(profileId = "p1", ble = null, aap = aap) + device.rssiQuality shouldBe 1.0f + } + + @Test + fun `rssiQuality - full when AAP READY with fresh message`() { + val now = Instant.parse("2026-01-01T12:00:00Z") + val aap = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + lastMessageAt = now.minusSeconds(5), + ) + val device = PodDevice(profileId = "p1", ble = null, aap = aap) + device.rssiQuality shouldBe 1.0f + } + + @Test + fun `rssiQuality - full when AAP READY with very old message (quiet channel)`() { + // AirPods don't send periodic AAP messages — a user listening to music with no UI + // interaction for 10 minutes is still a healthy connection, not a degraded one. + val now = Instant.parse("2026-01-01T12:00:00Z") + val aap = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + lastMessageAt = now.minusSeconds(600), + ) + val device = PodDevice(profileId = "p1", ble = null, aap = aap) + device.rssiQuality shouldBe 1.0f + } + + @Test + fun `rssiQuality - BLE value wins over AAP READY`() { + val ble = mockk(relaxed = true) { + every { model } returns PodModel.AIRPODS_PRO3 + every { rssiQuality } returns 0.3f + } + val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY) + val device = PodDevice(profileId = "p1", ble = ble, aap = aap) + device.rssiQuality shouldBe 0.3f + } } diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/ble/BlePodSnapshotTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/ble/BlePodSnapshotTest.kt new file mode 100644 index 00000000..18aa1c94 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/ble/BlePodSnapshotTest.kt @@ -0,0 +1,110 @@ +package eu.darken.capod.pods.core.apple.ble + +import eu.darken.capod.common.bluetooth.BleScanResult +import eu.darken.capod.pods.core.apple.PodModel +import eu.darken.capod.profiles.core.DeviceProfile +import io.kotest.matchers.floats.plusOrMinus +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import java.time.Instant +import java.util.UUID + +class BlePodSnapshotTest : BaseTest() { + + private fun fakeSnapshot( + rssi: Int, + reliability: Float = 0f, + seenFirstAt: Instant = Instant.now(), + ): BlePodSnapshot = object : BlePodSnapshot { + override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id(UUID.randomUUID()) + override val model: PodModel = PodModel.UNKNOWN + override val seenLastAt: Instant = seenFirstAt + override val seenFirstAt: Instant = seenFirstAt + override val seenCounter: Int = 1 + override val scanResult: BleScanResult = BleScanResult( + receivedAt = seenFirstAt, + address = "AA:BB:CC:DD:EE:FF", + rssi = rssi, + generatedAtNanos = 0L, + manufacturerSpecificData = emptyMap(), + ) + override val reliability: Float = reliability + override val meta: BlePodSnapshot.Meta = object : BlePodSnapshot.Meta { + override val profile: DeviceProfile? = null + } + } + + // --- rssiQuality — pure RSSI mapping for display bars --- + + @Test + fun `rssiQuality at -30 dBm maps to 1_0 (excellent, 4 bars)`() { + fakeSnapshot(rssi = -30).rssiQuality shouldBe 1.0f + } + + @Test + fun `rssiQuality at -50 dBm maps to ~0_71 (3 bars)`() { + fakeSnapshot(rssi = -50).rssiQuality shouldBe (0.714f plusOrMinus 0.01f) + } + + @Test + fun `rssiQuality at -65 dBm maps to 0_50 (2 bars)`() { + fakeSnapshot(rssi = -65).rssiQuality shouldBe (0.50f plusOrMinus 0.01f) + } + + @Test + fun `rssiQuality at -85 dBm maps to ~0_21 (1 bar)`() { + fakeSnapshot(rssi = -85).rssiQuality shouldBe (0.214f plusOrMinus 0.01f) + } + + @Test + fun `rssiQuality at -100 dBm maps to 0_0 (edge of range)`() { + fakeSnapshot(rssi = -100).rssiQuality shouldBe 0.0f + } + + @Test + fun `rssiQuality clamps lower bound for weaker than -100 dBm`() { + fakeSnapshot(rssi = -150).rssiQuality shouldBe 0.0f + } + + @Test + fun `rssiQuality clamps upper bound for stronger than -30 dBm`() { + fakeSnapshot(rssi = 0).rssiQuality shouldBe 1.0f + fakeSnapshot(rssi = -10).rssiQuality shouldBe 1.0f + } + + // --- signalQuality — composite for matching/sorting --- + // Formula: (sqRssi + max(BASE, reliability) + sqAge) / 2 + // BASE_CONFIDENCE = 0, age=0 for fresh snapshots + + @Test + fun `signalQuality at -30 dBm cold start (reliability=0, age=0) equals rssiQuality half`() { + // (1.0 + 0 + 0) / 2 = 0.5 + fakeSnapshot(rssi = -30, reliability = 0f).signalQuality shouldBe (0.5f plusOrMinus 0.01f) + } + + @Test + fun `signalQuality at -100 dBm cold start is 0`() { + // (0 + 0 + 0) / 2 = 0 + fakeSnapshot(rssi = -100, reliability = 0f).signalQuality shouldBe 0.0f + } + + @Test + fun `signalQuality at -30 dBm established (reliability=0_85) approaches 0_925`() { + // (1.0 + 0.85 + 0) / 2 = 0.925 + fakeSnapshot(rssi = -30, reliability = 0.85f).signalQuality shouldBe (0.925f plusOrMinus 0.01f) + } + + @Test + fun `signalQuality at -50 dBm established (reliability=0_85) approaches 0_78`() { + // (0.714 + 0.85 + 0) / 2 = 0.782 + fakeSnapshot(rssi = -50, reliability = 0.85f).signalQuality shouldBe (0.782f plusOrMinus 0.01f) + } + + @Test + fun `signalQuality at -100 dBm with reliability 0 stays below AppleFactory default 0_15 threshold`() { + // Verify filter behavior: a weak-signal fresh scan is still filtered out + val q = fakeSnapshot(rssi = -100, reliability = 0f).signalQuality + (q < 0.15f) shouldBe true + } +}