From f0531796dd3094fbe72afa402c1144384fc1ffd1 Mon Sep 17 00:00:00 2001 From: darken Date: Fri, 24 Apr 2026 12:52:55 +0200 Subject: [PATCH] feat(device-settings): Surface hardware, pending firmware, and bonded dates --- .../ui/devicesettings/DeviceSettingsScreen.kt | 104 ++------ .../cards/DeviceInfoBottomSheet.kt | 148 ++++++---- .../cards/DeviceInfoDetailItems.kt | 86 ++++++ app/src/main/res/values/strings.xml | 4 + .../cards/DeviceInfoDetailItemsTest.kt | 252 ++++++++++++++++++ 5 files changed, 469 insertions(+), 125 deletions(-) create mode 100644 app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItems.kt create mode 100644 app/src/test/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItemsTest.kt diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt index 5228d682..5cd875ce 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt @@ -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 { - 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( diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoBottomSheet.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoBottomSheet.kt index 7a99aab8..cfa913c8 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoBottomSheet.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoBottomSheet.kt @@ -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) { + 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) + }, + ) } diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItems.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItems.kt new file mode 100644 index 00000000..366127e6 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItems.kt @@ -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 { + 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)) + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index abc59b22..38877630 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -453,11 +453,15 @@ Bluetooth Device Label Model Manufacturer + Hardware Serial Number Firmware + Pending Firmware Build Left Pod Serial Right Pod Serial + Left Bonded + Right Bonded Device Details Show device details Status diff --git a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItemsTest.kt b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItemsTest.kt new file mode 100644 index 00000000..7de29409 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItemsTest.kt @@ -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 + } +}