mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user