feat: Add connection type indicators to SignalBadge

Move BLE key and AAP connection icons into the SignalBadge pill.

Add BleKeyState enum on PodDevice to expose IRK/ENC state cleanly.

Key icon: outlined for IRK-only, solid for IRK+ENC.

Bluetooth icon shown when AAP transport is active.
This commit is contained in:
darken
2026-03-31 19:17:09 +02:00
committed by Matthias Urhahn
parent f65c6e4a29
commit 70e5b99e1d
7 changed files with 230 additions and 46 deletions
@@ -5,6 +5,7 @@ import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.devices.HasCase
@@ -146,6 +147,21 @@ object MockPodDataProvider {
model = model,
)
// --- Dual pod with Apple keys ---
fun airPodsProWithKeys(): DualBlePodSnapshot = MockDualAppleBlePodSnapshot(
_model = PodModel.AIRPODS_PRO2,
_label = "My AirPods Pro",
batteryLeftPodPercent = 0.80f,
batteryRightPodPercent = 0.45f,
_batteryCasePercent = 0.60f,
_isIRKMatch = true,
_hasPrivatePayload = true,
leftPodIcon = R.drawable.device_airpods_pro2_left,
rightPodIcon = R.drawable.device_airpods_pro2_right,
_caseIcon = R.drawable.device_airpods_pro2_case,
)
// --- PodDevice wrappers ---
fun dualPodMonitored(): PodDevice = PodDevice(
@@ -158,6 +174,16 @@ object MockPodDataProvider {
aap = null,
)
fun dualPodMonitoredWithKeys(): PodDevice = PodDevice(
ble = airPodsProWithKeys(),
aap = null,
)
fun dualPodMonitoredWithAap(): PodDevice = PodDevice(
ble = airPodsProWithKeys(),
aap = AapPodState(connectionState = AapPodState.ConnectionState.READY),
)
fun singlePodMonitored(): PodDevice = PodDevice(
ble = airPodsMax(),
aap = null,
@@ -339,3 +365,62 @@ private class MockSingleAppleBlePodSnapshot(
// HasEarDetection
override val isBeingWorn: Boolean = _isBeingWorn
}
@Suppress("PropertyName")
private class MockDualAppleBlePodSnapshot(
private val _model: PodModel,
private val _label: String,
override val batteryLeftPodPercent: Float?,
override val batteryRightPodPercent: Float?,
private val _batteryCasePercent: Float?,
private val _isCaseCharging: Boolean = false,
private val _isLeftPodCharging: Boolean = false,
private val _isRightPodCharging: Boolean = false,
private val _isLeftPodInEar: Boolean = false,
private val _isRightPodInEar: Boolean = false,
private val _isLeftPodMicrophone: Boolean = false,
private val _isRightPodMicrophone: Boolean = false,
private val _isIRKMatch: Boolean = false,
private val _hasPrivatePayload: Boolean = false,
override val leftPodIcon: Int = R.drawable.device_airpods_gen1_left,
override val rightPodIcon: Int = R.drawable.device_airpods_gen1_right,
private val _caseIcon: Int = R.drawable.device_airpods_gen1_case,
rssi: Int = -50,
) : DualBlePodSnapshot, ApplePods, HasCase, HasChargeDetectionDual, HasEarDetectionDual, HasDualMicrophone {
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id()
override val model: PodModel = _model
override val seenLastAt: Instant = MOCK_NOW
override val seenFirstAt: Instant = MOCK_NOW
override val seenCounter: Int = 5
override val scanResult: BleScanResult = MockPodDataProvider.dummyScanResult(rssi)
override val reliability: Float = 1.0f
override val signalQuality: Float = 0.75f
override val iconRes: Int = _model.iconRes
override val meta: ApplePods.AppleMeta = ApplePods.AppleMeta(
isIRKMatch = _isIRKMatch,
profile = AppleDeviceProfile(label = _label, model = _model),
)
override val payload: ProximityPayload = ProximityPayload(
public = ProximityPayload.Public(UByteArray(9)),
private = if (_hasPrivatePayload) ProximityPayload.Private(UByteArray(8)) else null,
)
override fun getLabel(context: Context): String = _model.label
// HasCase
override val batteryCasePercent: Float? = _batteryCasePercent
override val isCaseCharging: Boolean = _isCaseCharging
override val caseIcon: Int = _caseIcon
// HasChargeDetectionDual
override val isLeftPodCharging: Boolean = _isLeftPodCharging
override val isRightPodCharging: Boolean = _isRightPodCharging
// HasEarDetectionDual
override val isLeftPodInEar: Boolean = _isLeftPodInEar
override val isRightPodInEar: Boolean = _isRightPodInEar
// HasDualMicrophone
override val isLeftPodMicrophone: Boolean = _isLeftPodMicrophone
override val isRightPodMicrophone: Boolean = _isRightPodMicrophone
}
@@ -18,15 +18,12 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Key
import androidx.compose.material.icons.twotone.BatteryChargingFull
import androidx.compose.material.icons.twotone.GridView
import androidx.compose.material.icons.twotone.Key
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -50,7 +47,6 @@ import eu.darken.capod.monitor.core.getSignalQuality
import eu.darken.capod.monitor.core.lastSeenFormatted
import eu.darken.capod.pods.core.apple.ble.devices.HasPodStyle
import eu.darken.capod.pods.core.apple.ble.devices.HasStateDetection
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.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
@@ -94,24 +90,12 @@ fun DualPodsCard(
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = device.meta?.profile?.label ?: "?",
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val applePod = device.ble as? ApplePods
if (applePod != null && applePod.meta.isIRKMatch) {
Spacer(modifier = Modifier.width(6.dp))
Icon(
imageVector = if (applePod.payload.private != null) Icons.TwoTone.Key else Icons.Outlined.Key,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Text(
text = device.meta?.profile?.label ?: "?",
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val deviceLabel = buildString {
append(device.getLabel(context))
val podStyle = device.ble as? HasPodStyle
@@ -132,7 +116,11 @@ fun DualPodsCard(
)
}
SignalBadge(signalText = device.getSignalQuality(context))
SignalBadge(
signalText = device.getSignalQuality(context),
bleKeyState = device.bleKeyState,
isAapConnected = device.isAapConnected,
)
}
Spacer(modifier = Modifier.height(4.dp))
@@ -414,3 +402,15 @@ private fun DualPodsCardMixedBatteryPreview() = PreviewWrapper {
private fun DualPodsCardDebugPreview() = PreviewWrapper {
DualPodsCard(device = MockPodDataProvider.dualPodMonitoredMixed(), showDebug = true, now = Instant.now())
}
@Preview2
@Composable
private fun DualPodsCardWithKeysPreview() = PreviewWrapper {
DualPodsCard(device = MockPodDataProvider.dualPodMonitoredWithKeys(), showDebug = false, now = Instant.now())
}
@Preview2
@Composable
private fun DualPodsCardWithAapPreview() = PreviewWrapper {
DualPodsCard(device = MockPodDataProvider.dualPodMonitoredWithAap(), showDebug = false, now = Instant.now())
}
@@ -23,6 +23,9 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.BatteryChargingFull
import androidx.compose.material.icons.twotone.Hearing
import androidx.compose.material.icons.twotone.KeyboardVoice
import androidx.compose.material.icons.outlined.Key
import androidx.compose.material.icons.twotone.Bluetooth
import androidx.compose.material.icons.twotone.Key
import androidx.compose.material.icons.twotone.SettingsInputAntenna
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -42,6 +45,9 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
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.pods.core.apple.aap.protocol.AapSetting
private val CapsuleShape = RoundedCornerShape(6.dp)
@@ -155,6 +161,8 @@ fun StatusChipRow(
@Composable
fun SignalBadge(
signalText: String,
bleKeyState: BleKeyState = BleKeyState.NONE,
isAapConnected: Boolean = false,
modifier: Modifier = Modifier,
) {
Surface(
@@ -166,6 +174,27 @@ fun SignalBadge(
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
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,
@@ -251,3 +280,21 @@ fun ConversationAwarenessToggle(
)
}
}
@Preview2
@Composable
private fun SignalBadgeDefaultPreview() = PreviewWrapper {
SignalBadge(signalText = "85%")
}
@Preview2
@Composable
private fun SignalBadgeIrkOnlyPreview() = PreviewWrapper {
SignalBadge(signalText = "85%", bleKeyState = BleKeyState.IRK_ONLY)
}
@Preview2
@Composable
private fun SignalBadgeAllIconsPreview() = PreviewWrapper {
SignalBadge(signalText = "85%", bleKeyState = BleKeyState.IRK_AND_ENCRYPTED, isAapConnected = true)
}
@@ -19,14 +19,11 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Key
import androidx.compose.material.icons.twotone.BatteryChargingFull
import androidx.compose.material.icons.twotone.Hearing
import androidx.compose.material.icons.twotone.Key
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -48,7 +45,6 @@ import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.firstSeenFormatted
import eu.darken.capod.monitor.core.getSignalQuality
import eu.darken.capod.monitor.core.lastSeenFormatted
import eu.darken.capod.pods.core.apple.ble.devices.ApplePods
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import java.time.Duration
@@ -102,24 +98,12 @@ fun SinglePodsCard(
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = device.meta?.profile?.label ?: "?",
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val applePod = device.ble as? ApplePods
if (applePod != null && applePod.meta.isIRKMatch) {
Spacer(modifier = Modifier.width(6.dp))
Icon(
imageVector = if (applePod.payload.private != null) Icons.TwoTone.Key else Icons.Outlined.Key,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Text(
text = device.meta?.profile?.label ?: "?",
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = device.getLabel(context),
style = MaterialTheme.typography.bodySmall,
@@ -129,7 +113,11 @@ fun SinglePodsCard(
)
}
SignalBadge(signalText = device.getSignalQuality(context))
SignalBadge(
signalText = device.getSignalQuality(context),
bleKeyState = device.bleKeyState,
isAapConnected = device.isAapConnected,
)
}
Spacer(modifier = Modifier.height(4.dp))
@@ -13,6 +13,7 @@ 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
@@ -155,4 +156,13 @@ data class PodDevice(
get() = aap?.setting()
val isAapConnected: Boolean get() = aap != null
val bleKeyState: BleKeyState
get() {
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
}
}
enum class BleKeyState { NONE, IRK_ONLY, IRK_AND_ENCRYPTED }
+3
View File
@@ -263,6 +263,9 @@
<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_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>
<string name="pods_yours">Yours</string>
<string name="headset_being_worn_label">Being worn</string>
<string name="headset_not_being_worn_label">Not being worn</string>
@@ -11,6 +11,7 @@ import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
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.devices.ApplePods
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
@@ -355,4 +356,54 @@ class PodDeviceTest : BaseTest() {
val device = deviceWithBleQuality(0.50f, aap = null)
device.computeAapBoost(now) shouldBe 0f
}
// --- bleKeyState tests ---
@Test
fun `bleKeyState - null BLE returns NONE`() {
val device = PodDevice(ble = null, aap = null)
device.bleKeyState shouldBe BleKeyState.NONE
}
@Test
fun `bleKeyState - non-Apple BLE returns NONE`() {
val device = PodDevice(ble = mockk(relaxed = true) { every { model } returns PodModel.UNKNOWN }, aap = null)
device.bleKeyState shouldBe BleKeyState.NONE
}
@Test
fun `bleKeyState - Apple BLE without IRK match returns NONE`() {
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(ble = ble, aap = null)
device.bleKeyState shouldBe BleKeyState.NONE
}
@Test
fun `bleKeyState - IRK match without private payload returns IRK_ONLY`() {
val ble = mockk<DualApplePods>(relaxed = true) {
every { model } returns PodModel.AIRPODS_PRO3
every { meta } returns ApplePods.AppleMeta(isIRKMatch = true)
every { payload } returns ProximityPayload(public = ProximityPayload.Public(UByteArray(9)), private = null)
}
val device = PodDevice(ble = ble, aap = null)
device.bleKeyState shouldBe BleKeyState.IRK_ONLY
}
@Test
fun `bleKeyState - IRK match with private payload returns IRK_AND_ENCRYPTED`() {
val ble = mockk<DualApplePods>(relaxed = true) {
every { model } returns PodModel.AIRPODS_PRO3
every { meta } returns ApplePods.AppleMeta(isIRKMatch = true)
every { payload } returns ProximityPayload(
public = ProximityPayload.Public(UByteArray(9)),
private = ProximityPayload.Private(UByteArray(8)),
)
}
val device = PodDevice(ble = ble, aap = null)
device.bleKeyState shouldBe BleKeyState.IRK_AND_ENCRYPTED
}
}