From 70e5b99e1d8abb9c519adf1e5b9a5cdc277f45f8 Mon Sep 17 00:00:00 2001 From: darken Date: Mon, 30 Mar 2026 21:53:24 +0200 Subject: [PATCH] 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. --- .../compose/preview/MockPodDataProvider.kt | 85 +++++++++++++++++++ .../main/ui/overview/cards/DualPodsCard.kt | 46 +++++----- .../ui/overview/cards/PodCardComponents.kt | 47 ++++++++++ .../main/ui/overview/cards/SinglePodsCard.kt | 34 +++----- .../eu/darken/capod/monitor/core/PodDevice.kt | 10 +++ app/src/main/res/values/strings.xml | 3 + .../capod/monitor/core/PodDeviceTest.kt | 51 +++++++++++ 7 files changed, 230 insertions(+), 46 deletions(-) diff --git a/app/src/main/java/eu/darken/capod/common/compose/preview/MockPodDataProvider.kt b/app/src/main/java/eu/darken/capod/common/compose/preview/MockPodDataProvider.kt index 6dc50703..1bc1a2c4 100644 --- a/app/src/main/java/eu/darken/capod/common/compose/preview/MockPodDataProvider.kt +++ b/app/src/main/java/eu/darken/capod/common/compose/preview/MockPodDataProvider.kt @@ -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 +} 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 65b576d1..3f1a2941 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 @@ -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()) +} 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 56f25a96..6c7b8b34 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 @@ -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) +} 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 1f9a6a73..4dbd2cbc 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 @@ -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)) 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 4659b4c1..23f72f1d 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 @@ -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 } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a5c93f9b..349ec2d8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -263,6 +263,9 @@ Transparency Adaptive Conversation Awareness + Identity verified + Encrypted connection + Direct connection active Yours Being worn Not being worn 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 ee0ba152..d0f09c9c 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 @@ -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(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(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(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 + } }