From 35c06d9982be8c4ea1312ca7ac01bbe72a853aba Mon Sep 17 00:00:00 2001 From: darken Date: Wed, 15 Apr 2026 14:50:16 +0200 Subject: [PATCH] feat(device-settings): Move device details to bottom sheet Move serial, firmware, build, manufacturer, and per-pod serials from the info card into a ModalBottomSheet triggered by an info icon. Firmware+build and left+right pod serials render as paired rows. --- .../ui/devicesettings/DeviceSettingsScreen.kt | 37 +++++ .../devicesettings/cards/DeviceDetailItem.kt | 6 + .../cards/DeviceInfoBottomSheet.kt | 84 +++++++++++ .../ui/devicesettings/cards/DeviceInfoCard.kt | 142 ++++++++---------- .../pods/core/apple/aap/AapConnection.kt | 78 ++++++++-- app/src/main/res/values/strings.xml | 7 +- 6 files changed, 266 insertions(+), 88 deletions(-) create mode 100644 app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceDetailItem.kt create mode 100644 app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoBottomSheet.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 567ef62c..fcdbae9b 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 @@ -87,6 +87,7 @@ import eu.darken.capod.common.settings.SettingsSection import eu.darken.capod.common.settings.SettingsSliderItem import eu.darken.capod.common.settings.SettingsSwitchItem import eu.darken.capod.main.ui.devicesettings.cards.AapUnavailableCard +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.buildModelLabel import eu.darken.capod.main.ui.devicesettings.cards.NotConnectedCard @@ -276,6 +277,41 @@ 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.buildNumber.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.buildNumber!!), + )) + } 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.buildNumber!!)) + } + 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!!)) + } + } + } DeviceInfoCard( deviceInfo = device.deviceInfo, modelLabel = buildModelLabel(device), @@ -283,6 +319,7 @@ fun DeviceSettingsScreen( connectionStateLabel = stateDetection?.state?.getLabel(context), lastSeen = device.lastSeenFormatted(state.now), firstSeen = firstSeen, + detailItems = detailItems, canRename = device.isAapReady, onRename = onDeviceNameChange, ) diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceDetailItem.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceDetailItem.kt new file mode 100644 index 00000000..3b1cb85f --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceDetailItem.kt @@ -0,0 +1,6 @@ +package eu.darken.capod.main.ui.devicesettings.cards + +sealed interface DeviceDetailItem { + data class Single(val label: String, val value: String) : DeviceDetailItem + data class Paired(val start: Single, val end: Single) : DeviceDetailItem +} 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 new file mode 100644 index 00000000..7a99aab8 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoBottomSheet.kt @@ -0,0 +1,84 @@ +package eu.darken.capod.main.ui.devicesettings.cards + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +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 + +@Composable +internal fun DeviceInfoBottomSheet( + items: List, + onDismiss: () -> Unit, +) { + ModalBottomSheet( + 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, + ) + } + } + } + } + } +} + +@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) + } + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoCard.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoCard.kt index f8551550..676c411d 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoCard.kt @@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.twotone.Edit +import androidx.compose.material.icons.twotone.Info +import androidx.compose.runtime.LaunchedEffect import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard import androidx.compose.material3.Icon @@ -46,10 +48,16 @@ internal fun DeviceInfoCard( connectionStateLabel: String?, lastSeen: String?, firstSeen: String?, + detailItems: List = emptyList(), canRename: Boolean = false, onRename: (String) -> Unit = {}, ) { var showRenameDialog by remember { mutableStateOf(false) } + var showBottomSheet by remember { mutableStateOf(false) } + + LaunchedEffect(detailItems) { + if (detailItems.isEmpty()) showBottomSheet = false + } if (showRenameDialog && deviceInfo != null) { RenameDialog( @@ -62,6 +70,13 @@ internal fun DeviceInfoCard( ) } + if (showBottomSheet && detailItems.isNotEmpty()) { + DeviceInfoBottomSheet( + items = detailItems, + onDismiss = { showBottomSheet = false }, + ) + } + ElevatedCard( modifier = Modifier .fillMaxWidth() @@ -69,70 +84,49 @@ internal fun DeviceInfoCard( elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp), ) { Column(modifier = Modifier.padding(16.dp)) { - if (modelLabel != null) { - InfoRow( - label = stringResource(R.string.device_settings_info_model_label), - value = modelLabel, - ) - } - if (deviceInfo != null) { - if (deviceInfo.name.isNotBlank()) { - val nameMismatch = systemBluetoothName != null && systemBluetoothName != deviceInfo.name - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { + if (modelLabel != null || detailItems.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + if (modelLabel != null) { InfoRow( - label = stringResource(R.string.device_settings_info_bt_name_label), - value = deviceInfo.name, + label = stringResource(R.string.device_settings_info_model_label), + value = modelLabel, modifier = Modifier.weight(1f), - valueFontFamily = if (nameMismatch) FontFamily.Cursive else null, ) - if (canRename) { - IconButton(onClick = { showRenameDialog = true }) { - Icon( - imageVector = Icons.TwoTone.Edit, - contentDescription = stringResource(R.string.device_settings_rename_label), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + } + if (detailItems.isNotEmpty()) { + IconButton(onClick = { showBottomSheet = true }) { + Icon( + imageVector = Icons.TwoTone.Info, + contentDescription = stringResource(R.string.device_settings_info_details_action), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } - if (deviceInfo.serialNumber.isNotBlank()) { + } + if (deviceInfo != null && deviceInfo.name.isNotBlank()) { + val nameMismatch = systemBluetoothName != null && systemBluetoothName != deviceInfo.name + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { InfoRow( - label = stringResource(R.string.device_settings_info_serial_label), - value = deviceInfo.serialNumber, + label = stringResource(R.string.device_settings_info_bt_name_label), + value = deviceInfo.name, + modifier = Modifier.weight(1f), + valueFontFamily = if (nameMismatch) FontFamily.Cursive else null, ) - } - if (deviceInfo.firmwareVersion.isNotBlank()) { - InfoRow( - label = stringResource(R.string.device_settings_info_firmware_label), - value = deviceInfo.firmwareVersion, - ) - } - if (!deviceInfo.buildNumber.isNullOrBlank()) { - InfoRow( - label = stringResource(R.string.device_settings_info_build_label), - value = deviceInfo.buildNumber, - ) - } - if (!deviceInfo.leftEarbudSerial.isNullOrBlank() || !deviceInfo.rightEarbudSerial.isNullOrBlank()) { - Row(modifier = Modifier.fillMaxWidth()) { - if (!deviceInfo.leftEarbudSerial.isNullOrBlank()) { - InfoRow( - label = stringResource(R.string.device_settings_info_left_serial_label), - value = deviceInfo.leftEarbudSerial, - modifier = Modifier.weight(1f), - ) - } - if (!deviceInfo.rightEarbudSerial.isNullOrBlank()) { - InfoRow( - label = stringResource(R.string.device_settings_info_right_serial_label), - value = deviceInfo.rightEarbudSerial, - modifier = Modifier.weight(1f), - textAlign = if (!deviceInfo.leftEarbudSerial.isNullOrBlank()) TextAlign.End else TextAlign.Start, + if (canRename) { + IconButton(onClick = { showRenameDialog = true }) { + Icon( + imageVector = Icons.TwoTone.Edit, + contentDescription = stringResource(R.string.device_settings_rename_label), + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -169,7 +163,7 @@ internal fun DeviceInfoCard( } @Composable -private fun InfoRow( +internal fun InfoRow( label: String, value: String, modifier: Modifier = Modifier, @@ -215,6 +209,13 @@ private fun DeviceInfoCardFullPreview() = PreviewWrapper { connectionStateLabel = "Connected", lastSeen = "Just now", firstSeen = "5 minutes ago", + detailItems = listOf( + DeviceDetailItem.Single("Serial Number", "W5J7KV0N04"), + DeviceDetailItem.Paired( + start = DeviceDetailItem.Single("Firmware", "7A305"), + end = DeviceDetailItem.Single("Build", "8454624"), + ), + ), canRename = true, ) } @@ -235,27 +236,14 @@ private fun DeviceInfoCardMismatchPreview() = PreviewWrapper { connectionStateLabel = "Connected", lastSeen = "Just now", firstSeen = "5 minutes ago", - canRename = true, - ) -} - -@Composable -@Preview2 -private fun DeviceInfoCardLastSeenOnlyPreview() = PreviewWrapper { - DeviceInfoCard( - deviceInfo = AapDeviceInfo( - name = "AirPods Pro", - modelNumber = "A2699", - manufacturer = "Apple Inc.", - serialNumber = "W5J7KV0N04", - firmwareVersion = "7A305", + detailItems = listOf( + DeviceDetailItem.Single("Serial Number", "W5J7KV0N04"), + DeviceDetailItem.Paired( + start = DeviceDetailItem.Single("Firmware", "7A305"), + end = DeviceDetailItem.Single("Build", "8454624"), + ), ), - modelLabel = "AirPods Pro 2 (A2699)", - systemBluetoothName = "AirPods Pro", - connectionStateLabel = "Disconnected", - lastSeen = "2 hours ago", - firstSeen = null, - canRename = false, + canRename = true, ) } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt index 2cd4e07a..984ee6b7 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt @@ -74,6 +74,8 @@ internal class AapConnection( private var lastAncCommandSentAt: Long = 0L private var lastCommandedAncMode: AapSetting.AncMode.Value? = null private var ancResendJob: Job? = null + /** True after one automatic resend — prevents further retries for the same user action. */ + private var ancResendAttempted: Boolean = false private var lastSentCommand: AapCommand? = null private var lastSentAt: Long = 0L @@ -140,6 +142,7 @@ internal class AapConnection( ancDebounceJob = null ancResendJob?.cancel() ancResendJob = null + ancResendAttempted = false pendingAncMode = null lastCommandedAncMode = null connectionScope = null @@ -158,6 +161,7 @@ internal class AapConnection( lastCommandedAncMode = command.mode ancResendJob?.cancel() ancResendJob = null + ancResendAttempted = false val earDetection = currentState.setting() if (earDetection != null && !earDetection.isEitherPodInEar) { log(TAG) { "No pod in ear, queuing ANC mode: ${command.mode}" } @@ -318,6 +322,7 @@ internal class AapConnection( } finally { ancDebounceJob?.cancel() ancResendJob?.cancel() + ancResendAttempted = false pendingAncMode = null lastCommandedAncMode = null cleanupSocket() @@ -409,20 +414,50 @@ internal class AapConnection( ancDebounceJob?.cancel() _state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now()) log(TAG) { "Setting: ${key.simpleName} = $value [was: $previous]" } + applyInferences(value) // After our command, firmware may cycle through modes before settling. // Schedule a verification: if settled mode != commanded mode, re-send once. + // Capped at one retry per user action — if the device rejects the mode + // (e.g. OFF without AllowOffOption), stop instead of looping. lastCommandedAncMode?.let { commanded -> ancResendJob?.cancel() - ancResendJob = connectionScope?.launch { - delay(1000L) - val current = _state.value.setting()?.current - if (current != null && current != commanded) { - log(TAG) { "ANC mode diverged: commanded=$commanded settled=$current, re-sending" } - lastCommandedAncMode = null - sendRaw(AapCommand.SetAncMode(commanded)) - } else { - lastCommandedAncMode = null + if (ancResendAttempted) { + // Already retried once — accept the device's answer as final. + val current = (value as AapSetting.AncMode).current + if (current != commanded) { + log(TAG) { "ANC mode rejected: commanded=$commanded settled=$current, giving up after retry" } + // OFF specifically requires AllowOffOption — rejection means it's disabled + if (commanded == AapSetting.AncMode.Value.OFF) { + val prev = _state.value.setting() + if (prev == null || prev.enabled) { + _state.value = _state.value.withSetting( + AapSetting.AllowOffOption::class, + AapSetting.AllowOffOption(enabled = false), + ) + log(TAG) { "Inferred: AllowOffOption = false (OFF mode rejected by device)" } + } + } + } + lastCommandedAncMode = null + } else { + ancResendJob = connectionScope?.launch { + delay(1000L) + val current = _state.value.setting()?.current + val ear = _state.value.setting() + // Abort if pods moved to case/disconnected — firmware is doing its own thing + if (ear != null && !ear.isEitherPodInEar) { + log(TAG) { "ANC resend aborted: no pod in ear (ear=$ear)" } + lastCommandedAncMode = null + return@launch + } + if (current != null && current != commanded) { + log(TAG) { "ANC mode diverged: commanded=$commanded settled=$current, re-sending" } + ancResendAttempted = true + sendRaw(AapCommand.SetAncMode(commanded)) + } else { + lastCommandedAncMode = null + } } } } @@ -432,6 +467,7 @@ internal class AapConnection( delay(1500L) _state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now()) log(TAG) { "Setting (debounced): ${key.simpleName} = $value [was: $previous]" } + applyInferences(value) } } return @@ -449,6 +485,7 @@ internal class AapConnection( } _state.value = newState log(TAG) { "Setting: ${key.simpleName} = $value${if (clearPrimaryPod) " (swap, PrimaryPod cleared)" else ""} [was: $previous]" } + applyInferences(value) // Flush queued ANC command when a pod goes in ear if (value is AapSetting.EarDetection && value.isEitherPodInEar) { @@ -531,6 +568,29 @@ internal class AapConnection( } } + /** + * Infer settings that the device never pushes but whose state can be deduced from other signals. + * Called after every setting update. Only SETS inferred values — never clears them based on absence. + */ + private fun applyInferences(trigger: AapSetting) { + val inferred = mutableListOf, AapSetting>>() + + // AncMode=OFF is only possible when AllowOffOption is enabled — the device rejects + // SetAncMode(OFF) otherwise. If we see OFF in the burst or after a mode change, + // AllowOffOption must be true. + if (trigger is AapSetting.AncMode && trigger.current == AapSetting.AncMode.Value.OFF) { + val current = _state.value.setting() + if (current == null || !current.enabled) { + inferred += AapSetting.AllowOffOption::class to AapSetting.AllowOffOption(enabled = true) + } + } + + for ((key, value) in inferred) { + _state.value = _state.value.withSetting(key, value) + log(TAG) { "Inferred: ${key.simpleName} = $value (from ${trigger::class.simpleName})" } + } + } + private fun cleanupSocket() { try { socket?.close() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6f7db5cf..f0992171 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -443,11 +443,14 @@ Name Bluetooth Device Label Model + Manufacturer Serial Number Firmware Build - Left Serial - Right Serial + Left Pod Serial + Right Pod Serial + Device Details + Show device details Status Last Seen First Seen