fix(ui): Fix signal bars and restore individual connection icons

This commit is contained in:
darken
2026-04-08 18:07:06 +02:00
committed by Matthias Urhahn
parent cb915d6263
commit ed37e142ad
11 changed files with 397 additions and 31 deletions
@@ -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),
)
}
@@ -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
@@ -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),
)
}
@@ -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),
)
@@ -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<BluetoothAddress, AapPodState>.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")
}
@@ -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,
)
@@ -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" }
@@ -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),
)
+1
View File
@@ -270,6 +270,7 @@
<string name="anc_mode_transparency">Transparency</string>
<string name="anc_mode_adaptive">Adaptive</string>
<string name="conversation_awareness_label">Conversation Awareness</string>
<string name="signal_badge_ble_cd">BLE scan active</string>
<string name="signal_badge_key_irk_cd">Identity verified</string>
<string name="signal_badge_key_encrypted_cd">Encrypted connection</string>
<string name="signal_badge_aap_cd">Direct connection active</string>
@@ -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<DualApplePods>(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<DualApplePods>(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<DualApplePods>(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
}
}
@@ -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
}
}