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
+ }
}