feat(device-settings): Surface hardware, pending firmware, and bonded dates

This commit is contained in:
darken
2026-04-24 13:08:57 +02:00
committed by Matthias Urhahn
parent a701fdfba7
commit f0531796dd
5 changed files with 469 additions and 125 deletions
@@ -25,6 +25,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
@@ -42,13 +43,14 @@ import eu.darken.capod.common.settings.SettingsInfoBox
import eu.darken.capod.common.settings.SettingsSection
import eu.darken.capod.main.ui.devicesettings.cards.AapUnavailableCard
import eu.darken.capod.main.ui.devicesettings.cards.ControlsCard
import eu.darken.capod.main.ui.devicesettings.cards.DeviceDetailItem
import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard
import eu.darken.capod.main.ui.devicesettings.cards.NoiseControlCard
import eu.darken.capod.main.ui.devicesettings.cards.NotConnectedCard
import eu.darken.capod.main.ui.devicesettings.cards.ReactionsCard
import eu.darken.capod.main.ui.devicesettings.cards.SoundCard
import eu.darken.capod.main.ui.devicesettings.cards.buildDeviceInfoDetailItems
import eu.darken.capod.main.ui.devicesettings.cards.buildModelLabel
import eu.darken.capod.main.ui.devicesettings.cards.rememberDeviceInfoDetailLabels
import eu.darken.capod.main.ui.devicesettings.components.ConnectedDevicesList
import eu.darken.capod.main.ui.devicesettings.components.EqBarsChart
import eu.darken.capod.main.ui.devicesettings.dialogs.SystemRenameUnavailableDialog
@@ -61,6 +63,10 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.devices.HasStateDetection
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@Composable
fun DeviceSettingsScreenHost(
@@ -239,86 +245,18 @@ fun DeviceSettingsScreen(
device.firstSeenFormatted(state.now)
} else null
val info = device.deviceInfo
val detailItems = buildList<DeviceDetailItem> {
if (info != null) {
if (info.manufacturer.isNotBlank()) {
add(
DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_manufacturer_label),
info.manufacturer
)
)
}
if (info.serialNumber.isNotBlank()) {
add(
DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_serial_label),
info.serialNumber
)
)
}
val hasFirmware = info.firmwareVersion.isNotBlank()
val hasBuild = !info.marketingVersion.isNullOrBlank()
if (hasFirmware && hasBuild) {
add(
DeviceDetailItem.Paired(
start = DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_firmware_label),
info.firmwareVersion
),
end = DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_build_label),
info.marketingVersion!!
),
)
)
} else if (hasFirmware) {
add(
DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_firmware_label),
info.firmwareVersion
)
)
} else if (hasBuild) {
add(
DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_build_label),
info.marketingVersion!!
)
)
}
val hasLeft = !info.leftEarbudSerial.isNullOrBlank()
val hasRight = !info.rightEarbudSerial.isNullOrBlank()
if (hasLeft && hasRight) {
add(
DeviceDetailItem.Paired(
start = DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_left_serial_label),
info.leftEarbudSerial!!
),
end = DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_right_serial_label),
info.rightEarbudSerial!!
),
)
)
} else if (hasLeft) {
add(
DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_left_serial_label),
info.leftEarbudSerial!!
)
)
} else if (hasRight) {
add(
DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_right_serial_label),
info.rightEarbudSerial!!
)
)
}
}
val locale = LocalConfiguration.current.locales[0]
val zoneId = ZoneId.systemDefault()
val dateFormatter = remember(locale, zoneId) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(locale)
.withZone(zoneId)
}
val detailItems = buildDeviceInfoDetailItems(
info = info,
labels = rememberDeviceInfoDetailLabels(),
formatDate = { instant -> dateFormatter.format(instant) },
)
DeviceInfoCard(
deviceInfo = device.deviceInfo,
modelLabel = buildModelLabel(device),
@@ -506,6 +444,12 @@ internal fun previewFullState(isPro: Boolean) = DeviceSettingsViewModel.State(
manufacturer = "Apple Inc.",
serialNumber = "W5J7KV0N04",
firmwareVersion = "7A305",
hardwareVersion = "1.0.0",
leftEarbudSerial = "H3KL7HR926JY",
rightEarbudSerial = "H3KL2AYL26K0",
marketingVersion = "8454624",
leftEarbudFirstPaired = Instant.ofEpochSecond(1697480211L),
rightEarbudFirstPaired = Instant.ofEpochSecond(1697480211L),
),
settings = mapOf(
AapSetting.AncMode::class to AapSetting.AncMode(
@@ -18,6 +18,11 @@ 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.pods.core.apple.aap.protocol.AapDeviceInfo
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.Locale
@Composable
internal fun DeviceInfoBottomSheet(
@@ -28,57 +33,110 @@ internal fun DeviceInfoBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(start = 16.dp, end = 16.dp, bottom = 32.dp),
) {
Text(
text = stringResource(R.string.device_settings_info_details_label),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 12.dp),
)
items.forEach { item ->
when (item) {
is DeviceDetailItem.Single -> InfoRow(label = item.label, value = item.value)
is DeviceDetailItem.Paired -> Row(modifier = Modifier.fillMaxWidth()) {
InfoRow(
label = item.start.label,
value = item.start.value,
modifier = Modifier.weight(1f),
)
InfoRow(
label = item.end.label,
value = item.end.value,
modifier = Modifier.weight(1f),
textAlign = TextAlign.End,
)
}
DeviceInfoDetailsContent(items)
}
}
@Composable
private fun DeviceInfoDetailsContent(items: List<DeviceDetailItem>) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(start = 16.dp, end = 16.dp, bottom = 32.dp),
) {
Text(
text = stringResource(R.string.device_settings_info_details_label),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 12.dp),
)
items.forEach { item ->
when (item) {
is DeviceDetailItem.Single -> InfoRow(label = item.label, value = item.value)
is DeviceDetailItem.Paired -> Row(modifier = Modifier.fillMaxWidth()) {
InfoRow(
label = item.start.label,
value = item.start.value,
modifier = Modifier.weight(1f),
)
InfoRow(
label = item.end.label,
value = item.end.value,
modifier = Modifier.weight(1f),
textAlign = TextAlign.End,
)
}
}
}
}
}
private val previewDateFormatter: DateTimeFormatter =
DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.US).withZone(ZoneOffset.UTC)
@Preview2
@Composable
private fun DeviceInfoBottomSheetPreview() = PreviewWrapper {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Device Details",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 12.dp),
)
InfoRow(label = "Manufacturer", value = "Apple Inc.")
InfoRow(label = "Serial Number", value = "W5J7KV0N04")
Row(modifier = Modifier.fillMaxWidth()) {
InfoRow(label = "Firmware", value = "7A305", modifier = Modifier.weight(1f))
InfoRow(label = "Build", value = "8454624", modifier = Modifier.weight(1f), textAlign = TextAlign.End)
}
Row(modifier = Modifier.fillMaxWidth()) {
InfoRow(label = "Left Pod Serial", value = "H3KL7HR926JY", modifier = Modifier.weight(1f))
InfoRow(label = "Right Pod Serial", value = "H3KL2AYL26K0", modifier = Modifier.weight(1f), textAlign = TextAlign.End)
}
}
private fun DeviceInfoBottomSheetPreviewSameDayPairing() = PreviewWrapper {
val pairedAt = Instant.parse("2023-10-16T12:00:00Z")
val info = AapDeviceInfo(
name = "AirPods Pro",
modelNumber = "A2699",
manufacturer = "Apple Inc.",
serialNumber = "W5J7KV0N04",
firmwareVersion = "81.2675000075000000.6814",
hardwareVersion = "1.0.0",
leftEarbudSerial = "H3KL7HR926JY",
rightEarbudSerial = "H3KL2AYL26K0",
marketingVersion = "8454768",
leftEarbudFirstPaired = pairedAt,
rightEarbudFirstPaired = pairedAt,
)
DeviceInfoDetailsContent(
buildDeviceInfoDetailItems(info, rememberDeviceInfoDetailLabels()) {
previewDateFormatter.format(it)
},
)
}
@Preview2
@Composable
private fun DeviceInfoBottomSheetPreviewMismatchedPairing() = PreviewWrapper {
val info = AapDeviceInfo(
name = "AirPods Pro",
modelNumber = "A2699",
manufacturer = "Apple Inc.",
serialNumber = "W5J7KV0N04",
firmwareVersion = "81.2675000075000000.6814",
firmwareVersionPending = "82.1000000075000000.7000",
hardwareVersion = "1.0.0",
leftEarbudSerial = "H3KL7HR926JY",
rightEarbudSerial = "H3KL2AYL26K0",
marketingVersion = "8454768",
leftEarbudFirstPaired = Instant.parse("2024-02-14T12:00:00Z"),
rightEarbudFirstPaired = Instant.parse("2023-10-16T12:00:00Z"),
)
DeviceInfoDetailsContent(
buildDeviceInfoDetailItems(info, rememberDeviceInfoDetailLabels()) {
previewDateFormatter.format(it)
},
)
}
@Preview2
@Composable
private fun DeviceInfoBottomSheetPreviewOneSidedPairing() = PreviewWrapper {
val info = AapDeviceInfo(
name = "AirPods Pro",
modelNumber = "A2699",
manufacturer = "Apple Inc.",
serialNumber = "W5J7KV0N04",
firmwareVersion = "7A305",
leftEarbudSerial = "H3KL7HR926JY",
leftEarbudFirstPaired = Instant.parse("2023-10-16T12:00:00Z"),
)
DeviceInfoDetailsContent(
buildDeviceInfoDetailItems(info, rememberDeviceInfoDetailLabels()) {
previewDateFormatter.format(it)
},
)
}
@@ -0,0 +1,86 @@
package eu.darken.capod.main.ui.devicesettings.cards
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import eu.darken.capod.R
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
import java.time.Instant
@Composable
internal fun rememberDeviceInfoDetailLabels() = DeviceInfoDetailLabels(
manufacturer = stringResource(R.string.device_settings_info_manufacturer_label),
hardware = stringResource(R.string.device_settings_info_hardware_label),
serial = stringResource(R.string.device_settings_info_serial_label),
firmware = stringResource(R.string.device_settings_info_firmware_label),
firmwarePending = stringResource(R.string.device_settings_info_firmware_pending_label),
build = stringResource(R.string.device_settings_info_build_label),
leftSerial = stringResource(R.string.device_settings_info_left_serial_label),
rightSerial = stringResource(R.string.device_settings_info_right_serial_label),
leftBonded = stringResource(R.string.device_settings_info_left_bonded_label),
rightBonded = stringResource(R.string.device_settings_info_right_bonded_label),
)
internal data class DeviceInfoDetailLabels(
val manufacturer: String,
val hardware: String,
val serial: String,
val firmware: String,
val firmwarePending: String,
val build: String,
val leftSerial: String,
val rightSerial: String,
val leftBonded: String,
val rightBonded: String,
)
internal fun buildDeviceInfoDetailItems(
info: AapDeviceInfo?,
labels: DeviceInfoDetailLabels,
formatDate: (Instant) -> String,
): List<DeviceDetailItem> {
if (info == null) return emptyList()
return buildList {
info.manufacturer.takeIf { it.isNotBlank() }?.let {
add(DeviceDetailItem.Single(labels.manufacturer, it))
}
info.hardwareVersion?.takeIf { it.isNotBlank() }?.let {
add(DeviceDetailItem.Single(labels.hardware, it))
}
info.serialNumber.takeIf { it.isNotBlank() }?.let {
add(DeviceDetailItem.Single(labels.serial, it))
}
info.firmwareVersion.takeIf { it.isNotBlank() }?.let {
add(DeviceDetailItem.Single(labels.firmware, it))
}
info.firmwareVersionPending?.takeIf { it.isNotBlank() }?.let {
add(DeviceDetailItem.Single(labels.firmwarePending, it))
}
info.marketingVersion?.takeIf { it.isNotBlank() }?.let {
add(DeviceDetailItem.Single(labels.build, it))
}
val leftSerial = info.leftEarbudSerial?.takeIf { it.isNotBlank() }
val rightSerial = info.rightEarbudSerial?.takeIf { it.isNotBlank() }
when {
leftSerial != null && rightSerial != null -> add(
DeviceDetailItem.Paired(
start = DeviceDetailItem.Single(labels.leftSerial, leftSerial),
end = DeviceDetailItem.Single(labels.rightSerial, rightSerial),
)
)
leftSerial != null -> add(DeviceDetailItem.Single(labels.leftSerial, leftSerial))
rightSerial != null -> add(DeviceDetailItem.Single(labels.rightSerial, rightSerial))
}
val leftBonded = info.leftEarbudFirstPaired?.let(formatDate)
val rightBonded = info.rightEarbudFirstPaired?.let(formatDate)
when {
leftBonded != null && rightBonded != null -> add(
DeviceDetailItem.Paired(
start = DeviceDetailItem.Single(labels.leftBonded, leftBonded),
end = DeviceDetailItem.Single(labels.rightBonded, rightBonded),
)
)
leftBonded != null -> add(DeviceDetailItem.Single(labels.leftBonded, leftBonded))
rightBonded != null -> add(DeviceDetailItem.Single(labels.rightBonded, rightBonded))
}
}
}
+4
View File
@@ -453,11 +453,15 @@
<string name="device_settings_info_bt_name_label">Bluetooth Device Label</string>
<string name="device_settings_info_model_label">Model</string>
<string name="device_settings_info_manufacturer_label">Manufacturer</string>
<string name="device_settings_info_hardware_label">Hardware</string>
<string name="device_settings_info_serial_label">Serial Number</string>
<string name="device_settings_info_firmware_label">Firmware</string>
<string name="device_settings_info_firmware_pending_label">Pending Firmware</string>
<string name="device_settings_info_build_label">Build</string>
<string name="device_settings_info_left_serial_label">Left Pod Serial</string>
<string name="device_settings_info_right_serial_label">Right Pod Serial</string>
<string name="device_settings_info_left_bonded_label">Left Bonded</string>
<string name="device_settings_info_right_bonded_label">Right Bonded</string>
<string name="device_settings_info_details_label">Device Details</string>
<string name="device_settings_info_details_action">Show device details</string>
<string name="device_settings_info_status_label">Status</string>
@@ -0,0 +1,252 @@
package eu.darken.capod.main.ui.devicesettings.cards
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import java.time.Instant
class DeviceInfoDetailItemsTest : BaseTest() {
private val labels = DeviceInfoDetailLabels(
manufacturer = "Manufacturer",
hardware = "Hardware",
serial = "Serial Number",
firmware = "Firmware",
firmwarePending = "Pending Firmware",
build = "Build",
leftSerial = "Left Pod Serial",
rightSerial = "Right Pod Serial",
leftBonded = "Left Bonded",
rightBonded = "Right Bonded",
)
private val formatter: (Instant) -> String = { "fmt:${it.epochSecond}" }
private fun info(
manufacturer: String = "",
serialNumber: String = "",
firmwareVersion: String = "",
firmwareVersionPending: String? = null,
hardwareVersion: String? = null,
leftEarbudSerial: String? = null,
rightEarbudSerial: String? = null,
marketingVersion: String? = null,
leftEarbudFirstPaired: Instant? = null,
rightEarbudFirstPaired: Instant? = null,
) = AapDeviceInfo(
name = "AirPods",
modelNumber = "A2084",
manufacturer = manufacturer,
serialNumber = serialNumber,
firmwareVersion = firmwareVersion,
firmwareVersionPending = firmwareVersionPending,
hardwareVersion = hardwareVersion,
leftEarbudSerial = leftEarbudSerial,
rightEarbudSerial = rightEarbudSerial,
marketingVersion = marketingVersion,
leftEarbudFirstPaired = leftEarbudFirstPaired,
rightEarbudFirstPaired = rightEarbudFirstPaired,
)
@Test
fun `null AapDeviceInfo yields empty list`() {
buildDeviceInfoDetailItems(null, labels, formatter) shouldBe emptyList()
}
@Test
fun `minimal info renders only populated rows`() {
val result = buildDeviceInfoDetailItems(
info(manufacturer = "Apple", serialNumber = "ABC123", firmwareVersion = "7A305"),
labels,
formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Manufacturer", "Apple"),
DeviceDetailItem.Single("Serial Number", "ABC123"),
DeviceDetailItem.Single("Firmware", "7A305"),
)
}
@Test
fun `hardware row sits between manufacturer and serial`() {
val result = buildDeviceInfoDetailItems(
info(
manufacturer = "Apple",
hardwareVersion = "1.0.0",
serialNumber = "ABC123",
firmwareVersion = "7A305",
),
labels,
formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Manufacturer", "Apple"),
DeviceDetailItem.Single("Hardware", "1.0.0"),
DeviceDetailItem.Single("Serial Number", "ABC123"),
DeviceDetailItem.Single("Firmware", "7A305"),
)
}
@Test
fun `pending firmware row sits between firmware and build`() {
val result = buildDeviceInfoDetailItems(
info(
manufacturer = "Apple",
serialNumber = "ABC123",
firmwareVersion = "81.26",
firmwareVersionPending = "82.10",
marketingVersion = "8454768",
),
labels,
formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Manufacturer", "Apple"),
DeviceDetailItem.Single("Serial Number", "ABC123"),
DeviceDetailItem.Single("Firmware", "81.26"),
DeviceDetailItem.Single("Pending Firmware", "82.10"),
DeviceDetailItem.Single("Build", "8454768"),
)
}
@Test
fun `null pending firmware omits the row`() {
val result = buildDeviceInfoDetailItems(
info(firmwareVersion = "81.26", marketingVersion = "8454768"),
labels,
formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Firmware", "81.26"),
DeviceDetailItem.Single("Build", "8454768"),
)
}
@Test
fun `blank pending firmware omits the row`() {
val result = buildDeviceInfoDetailItems(
info(firmwareVersion = "81.26", firmwareVersionPending = " "),
labels,
formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Firmware", "81.26"),
)
}
@Test
fun `both serials present yield a Paired row`() {
val result = buildDeviceInfoDetailItems(
info(leftEarbudSerial = "LLL", rightEarbudSerial = "RRR"),
labels,
formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Paired(
start = DeviceDetailItem.Single("Left Pod Serial", "LLL"),
end = DeviceDetailItem.Single("Right Pod Serial", "RRR"),
),
)
}
@Test
fun `only left serial yields a Single row`() {
val result = buildDeviceInfoDetailItems(
info(leftEarbudSerial = "LLL"),
labels,
formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Left Pod Serial", "LLL"),
)
}
@Test
fun `only right serial yields a Single row`() {
val result = buildDeviceInfoDetailItems(
info(rightEarbudSerial = "RRR"),
labels,
formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Right Pod Serial", "RRR"),
)
}
@Test
fun `both bonded dates present always yield a Paired row`() {
val sameSecond = Instant.ofEpochSecond(1697480211L)
val result = buildDeviceInfoDetailItems(
info(leftEarbudFirstPaired = sameSecond, rightEarbudFirstPaired = sameSecond),
labels,
formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Paired(
start = DeviceDetailItem.Single("Left Bonded", "fmt:1697480211"),
end = DeviceDetailItem.Single("Right Bonded", "fmt:1697480211"),
),
)
}
@Test
fun `both bonded dates with different formatted values yield a Paired row`() {
val left = Instant.ofEpochSecond(1708000000L)
val right = Instant.ofEpochSecond(1697480211L)
val result = buildDeviceInfoDetailItems(
info(leftEarbudFirstPaired = left, rightEarbudFirstPaired = right),
labels,
formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Paired(
start = DeviceDetailItem.Single("Left Bonded", "fmt:1708000000"),
end = DeviceDetailItem.Single("Right Bonded", "fmt:1697480211"),
),
)
}
@Test
fun `only left bonded date yields a Single row`() {
val result = buildDeviceInfoDetailItems(
info(leftEarbudFirstPaired = Instant.ofEpochSecond(1697480211L)),
labels,
formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Left Bonded", "fmt:1697480211"),
)
}
@Test
fun `only right bonded date yields a Single row`() {
val result = buildDeviceInfoDetailItems(
info(rightEarbudFirstPaired = Instant.ofEpochSecond(1697480211L)),
labels,
formatter,
)
result shouldContainExactly listOf(
DeviceDetailItem.Single("Right Bonded", "fmt:1697480211"),
)
}
@Test
fun `both bonded dates null yields no bonded row`() {
val result = buildDeviceInfoDetailItems(
info(
manufacturer = "Apple",
leftEarbudFirstPaired = null,
rightEarbudFirstPaired = null,
),
labels,
formatter,
)
result.none { it is DeviceDetailItem.Paired } shouldBe true
result.none {
it is DeviceDetailItem.Single &&
(it.label == labels.leftBonded || it.label == labels.rightBonded)
} shouldBe true
}
}