mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-16 19:26:12 -04:00
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.
This commit is contained in:
@@ -87,6 +87,7 @@ import eu.darken.capod.common.settings.SettingsSection
|
|||||||
import eu.darken.capod.common.settings.SettingsSliderItem
|
import eu.darken.capod.common.settings.SettingsSliderItem
|
||||||
import eu.darken.capod.common.settings.SettingsSwitchItem
|
import eu.darken.capod.common.settings.SettingsSwitchItem
|
||||||
import eu.darken.capod.main.ui.devicesettings.cards.AapUnavailableCard
|
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.DeviceInfoCard
|
||||||
import eu.darken.capod.main.ui.devicesettings.cards.buildModelLabel
|
import eu.darken.capod.main.ui.devicesettings.cards.buildModelLabel
|
||||||
import eu.darken.capod.main.ui.devicesettings.cards.NotConnectedCard
|
import eu.darken.capod.main.ui.devicesettings.cards.NotConnectedCard
|
||||||
@@ -276,6 +277,41 @@ fun DeviceSettingsScreen(
|
|||||||
) {
|
) {
|
||||||
device.firstSeenFormatted(state.now)
|
device.firstSeenFormatted(state.now)
|
||||||
} else null
|
} 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.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(
|
DeviceInfoCard(
|
||||||
deviceInfo = device.deviceInfo,
|
deviceInfo = device.deviceInfo,
|
||||||
modelLabel = buildModelLabel(device),
|
modelLabel = buildModelLabel(device),
|
||||||
@@ -283,6 +319,7 @@ fun DeviceSettingsScreen(
|
|||||||
connectionStateLabel = stateDetection?.state?.getLabel(context),
|
connectionStateLabel = stateDetection?.state?.getLabel(context),
|
||||||
lastSeen = device.lastSeenFormatted(state.now),
|
lastSeen = device.lastSeenFormatted(state.now),
|
||||||
firstSeen = firstSeen,
|
firstSeen = firstSeen,
|
||||||
|
detailItems = detailItems,
|
||||||
canRename = device.isAapReady,
|
canRename = device.isAapReady,
|
||||||
onRename = onDeviceNameChange,
|
onRename = onDeviceNameChange,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+84
@@ -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<DeviceDetailItem>,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.twotone.Edit
|
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.CardDefaults
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -46,10 +48,16 @@ internal fun DeviceInfoCard(
|
|||||||
connectionStateLabel: String?,
|
connectionStateLabel: String?,
|
||||||
lastSeen: String?,
|
lastSeen: String?,
|
||||||
firstSeen: String?,
|
firstSeen: String?,
|
||||||
|
detailItems: List<DeviceDetailItem> = emptyList(),
|
||||||
canRename: Boolean = false,
|
canRename: Boolean = false,
|
||||||
onRename: (String) -> Unit = {},
|
onRename: (String) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var showRenameDialog by remember { mutableStateOf(false) }
|
var showRenameDialog by remember { mutableStateOf(false) }
|
||||||
|
var showBottomSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(detailItems) {
|
||||||
|
if (detailItems.isEmpty()) showBottomSheet = false
|
||||||
|
}
|
||||||
|
|
||||||
if (showRenameDialog && deviceInfo != null) {
|
if (showRenameDialog && deviceInfo != null) {
|
||||||
RenameDialog(
|
RenameDialog(
|
||||||
@@ -62,6 +70,13 @@ internal fun DeviceInfoCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showBottomSheet && detailItems.isNotEmpty()) {
|
||||||
|
DeviceInfoBottomSheet(
|
||||||
|
items = detailItems,
|
||||||
|
onDismiss = { showBottomSheet = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
ElevatedCard(
|
ElevatedCard(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -69,70 +84,49 @@ internal fun DeviceInfoCard(
|
|||||||
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp),
|
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
if (modelLabel != null) {
|
if (modelLabel != null || detailItems.isNotEmpty()) {
|
||||||
InfoRow(
|
Row(
|
||||||
label = stringResource(R.string.device_settings_info_model_label),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
value = modelLabel,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
)
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
}
|
) {
|
||||||
if (deviceInfo != null) {
|
if (modelLabel != null) {
|
||||||
if (deviceInfo.name.isNotBlank()) {
|
|
||||||
val nameMismatch = systemBluetoothName != null && systemBluetoothName != deviceInfo.name
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
InfoRow(
|
InfoRow(
|
||||||
label = stringResource(R.string.device_settings_info_bt_name_label),
|
label = stringResource(R.string.device_settings_info_model_label),
|
||||||
value = deviceInfo.name,
|
value = modelLabel,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
valueFontFamily = if (nameMismatch) FontFamily.Cursive else null,
|
|
||||||
)
|
)
|
||||||
if (canRename) {
|
}
|
||||||
IconButton(onClick = { showRenameDialog = true }) {
|
if (detailItems.isNotEmpty()) {
|
||||||
Icon(
|
IconButton(onClick = { showBottomSheet = true }) {
|
||||||
imageVector = Icons.TwoTone.Edit,
|
Icon(
|
||||||
contentDescription = stringResource(R.string.device_settings_rename_label),
|
imageVector = Icons.TwoTone.Info,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
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(
|
InfoRow(
|
||||||
label = stringResource(R.string.device_settings_info_serial_label),
|
label = stringResource(R.string.device_settings_info_bt_name_label),
|
||||||
value = deviceInfo.serialNumber,
|
value = deviceInfo.name,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
valueFontFamily = if (nameMismatch) FontFamily.Cursive else null,
|
||||||
)
|
)
|
||||||
}
|
if (canRename) {
|
||||||
if (deviceInfo.firmwareVersion.isNotBlank()) {
|
IconButton(onClick = { showRenameDialog = true }) {
|
||||||
InfoRow(
|
Icon(
|
||||||
label = stringResource(R.string.device_settings_info_firmware_label),
|
imageVector = Icons.TwoTone.Edit,
|
||||||
value = deviceInfo.firmwareVersion,
|
contentDescription = stringResource(R.string.device_settings_rename_label),
|
||||||
)
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
}
|
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,7 +163,7 @@ internal fun DeviceInfoCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun InfoRow(
|
internal fun InfoRow(
|
||||||
label: String,
|
label: String,
|
||||||
value: String,
|
value: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -215,6 +209,13 @@ private fun DeviceInfoCardFullPreview() = PreviewWrapper {
|
|||||||
connectionStateLabel = "Connected",
|
connectionStateLabel = "Connected",
|
||||||
lastSeen = "Just now",
|
lastSeen = "Just now",
|
||||||
firstSeen = "5 minutes ago",
|
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,
|
canRename = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -235,27 +236,14 @@ private fun DeviceInfoCardMismatchPreview() = PreviewWrapper {
|
|||||||
connectionStateLabel = "Connected",
|
connectionStateLabel = "Connected",
|
||||||
lastSeen = "Just now",
|
lastSeen = "Just now",
|
||||||
firstSeen = "5 minutes ago",
|
firstSeen = "5 minutes ago",
|
||||||
canRename = true,
|
detailItems = listOf(
|
||||||
)
|
DeviceDetailItem.Single("Serial Number", "W5J7KV0N04"),
|
||||||
}
|
DeviceDetailItem.Paired(
|
||||||
|
start = DeviceDetailItem.Single("Firmware", "7A305"),
|
||||||
@Composable
|
end = DeviceDetailItem.Single("Build", "8454624"),
|
||||||
@Preview2
|
),
|
||||||
private fun DeviceInfoCardLastSeenOnlyPreview() = PreviewWrapper {
|
|
||||||
DeviceInfoCard(
|
|
||||||
deviceInfo = AapDeviceInfo(
|
|
||||||
name = "AirPods Pro",
|
|
||||||
modelNumber = "A2699",
|
|
||||||
manufacturer = "Apple Inc.",
|
|
||||||
serialNumber = "W5J7KV0N04",
|
|
||||||
firmwareVersion = "7A305",
|
|
||||||
),
|
),
|
||||||
modelLabel = "AirPods Pro 2 (A2699)",
|
canRename = true,
|
||||||
systemBluetoothName = "AirPods Pro",
|
|
||||||
connectionStateLabel = "Disconnected",
|
|
||||||
lastSeen = "2 hours ago",
|
|
||||||
firstSeen = null,
|
|
||||||
canRename = false,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ internal class AapConnection(
|
|||||||
private var lastAncCommandSentAt: Long = 0L
|
private var lastAncCommandSentAt: Long = 0L
|
||||||
private var lastCommandedAncMode: AapSetting.AncMode.Value? = null
|
private var lastCommandedAncMode: AapSetting.AncMode.Value? = null
|
||||||
private var ancResendJob: Job? = 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 lastSentCommand: AapCommand? = null
|
||||||
private var lastSentAt: Long = 0L
|
private var lastSentAt: Long = 0L
|
||||||
|
|
||||||
@@ -140,6 +142,7 @@ internal class AapConnection(
|
|||||||
ancDebounceJob = null
|
ancDebounceJob = null
|
||||||
ancResendJob?.cancel()
|
ancResendJob?.cancel()
|
||||||
ancResendJob = null
|
ancResendJob = null
|
||||||
|
ancResendAttempted = false
|
||||||
pendingAncMode = null
|
pendingAncMode = null
|
||||||
lastCommandedAncMode = null
|
lastCommandedAncMode = null
|
||||||
connectionScope = null
|
connectionScope = null
|
||||||
@@ -158,6 +161,7 @@ internal class AapConnection(
|
|||||||
lastCommandedAncMode = command.mode
|
lastCommandedAncMode = command.mode
|
||||||
ancResendJob?.cancel()
|
ancResendJob?.cancel()
|
||||||
ancResendJob = null
|
ancResendJob = null
|
||||||
|
ancResendAttempted = false
|
||||||
val earDetection = currentState.setting<AapSetting.EarDetection>()
|
val earDetection = currentState.setting<AapSetting.EarDetection>()
|
||||||
if (earDetection != null && !earDetection.isEitherPodInEar) {
|
if (earDetection != null && !earDetection.isEitherPodInEar) {
|
||||||
log(TAG) { "No pod in ear, queuing ANC mode: ${command.mode}" }
|
log(TAG) { "No pod in ear, queuing ANC mode: ${command.mode}" }
|
||||||
@@ -318,6 +322,7 @@ internal class AapConnection(
|
|||||||
} finally {
|
} finally {
|
||||||
ancDebounceJob?.cancel()
|
ancDebounceJob?.cancel()
|
||||||
ancResendJob?.cancel()
|
ancResendJob?.cancel()
|
||||||
|
ancResendAttempted = false
|
||||||
pendingAncMode = null
|
pendingAncMode = null
|
||||||
lastCommandedAncMode = null
|
lastCommandedAncMode = null
|
||||||
cleanupSocket()
|
cleanupSocket()
|
||||||
@@ -409,20 +414,50 @@ internal class AapConnection(
|
|||||||
ancDebounceJob?.cancel()
|
ancDebounceJob?.cancel()
|
||||||
_state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now())
|
_state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now())
|
||||||
log(TAG) { "Setting: ${key.simpleName} = $value [was: $previous]" }
|
log(TAG) { "Setting: ${key.simpleName} = $value [was: $previous]" }
|
||||||
|
applyInferences(value)
|
||||||
|
|
||||||
// After our command, firmware may cycle through modes before settling.
|
// After our command, firmware may cycle through modes before settling.
|
||||||
// Schedule a verification: if settled mode != commanded mode, re-send once.
|
// 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 ->
|
lastCommandedAncMode?.let { commanded ->
|
||||||
ancResendJob?.cancel()
|
ancResendJob?.cancel()
|
||||||
ancResendJob = connectionScope?.launch {
|
if (ancResendAttempted) {
|
||||||
delay(1000L)
|
// Already retried once — accept the device's answer as final.
|
||||||
val current = _state.value.setting<AapSetting.AncMode>()?.current
|
val current = (value as AapSetting.AncMode).current
|
||||||
if (current != null && current != commanded) {
|
if (current != commanded) {
|
||||||
log(TAG) { "ANC mode diverged: commanded=$commanded settled=$current, re-sending" }
|
log(TAG) { "ANC mode rejected: commanded=$commanded settled=$current, giving up after retry" }
|
||||||
lastCommandedAncMode = null
|
// OFF specifically requires AllowOffOption — rejection means it's disabled
|
||||||
sendRaw(AapCommand.SetAncMode(commanded))
|
if (commanded == AapSetting.AncMode.Value.OFF) {
|
||||||
} else {
|
val prev = _state.value.setting<AapSetting.AllowOffOption>()
|
||||||
lastCommandedAncMode = null
|
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<AapSetting.AncMode>()?.current
|
||||||
|
val ear = _state.value.setting<AapSetting.EarDetection>()
|
||||||
|
// 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)
|
delay(1500L)
|
||||||
_state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now())
|
_state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now())
|
||||||
log(TAG) { "Setting (debounced): ${key.simpleName} = $value [was: $previous]" }
|
log(TAG) { "Setting (debounced): ${key.simpleName} = $value [was: $previous]" }
|
||||||
|
applyInferences(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -449,6 +485,7 @@ internal class AapConnection(
|
|||||||
}
|
}
|
||||||
_state.value = newState
|
_state.value = newState
|
||||||
log(TAG) { "Setting: ${key.simpleName} = $value${if (clearPrimaryPod) " (swap, PrimaryPod cleared)" else ""} [was: $previous]" }
|
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
|
// Flush queued ANC command when a pod goes in ear
|
||||||
if (value is AapSetting.EarDetection && value.isEitherPodInEar) {
|
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<Pair<KClass<out AapSetting>, 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<AapSetting.AllowOffOption>()
|
||||||
|
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() {
|
private fun cleanupSocket() {
|
||||||
try {
|
try {
|
||||||
socket?.close()
|
socket?.close()
|
||||||
|
|||||||
@@ -443,11 +443,14 @@
|
|||||||
<string name="device_settings_info_name_label">Name</string>
|
<string name="device_settings_info_name_label">Name</string>
|
||||||
<string name="device_settings_info_bt_name_label">Bluetooth Device Label</string>
|
<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_model_label">Model</string>
|
||||||
|
<string name="device_settings_info_manufacturer_label">Manufacturer</string>
|
||||||
<string name="device_settings_info_serial_label">Serial Number</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_label">Firmware</string>
|
||||||
<string name="device_settings_info_build_label">Build</string>
|
<string name="device_settings_info_build_label">Build</string>
|
||||||
<string name="device_settings_info_left_serial_label">Left Serial</string>
|
<string name="device_settings_info_left_serial_label">Left Pod Serial</string>
|
||||||
<string name="device_settings_info_right_serial_label">Right Serial</string>
|
<string name="device_settings_info_right_serial_label">Right Pod Serial</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>
|
<string name="device_settings_info_status_label">Status</string>
|
||||||
<string name="device_settings_info_last_seen_label">Last Seen</string>
|
<string name="device_settings_info_last_seen_label">Last Seen</string>
|
||||||
<string name="device_settings_info_first_seen_label">First Seen</string>
|
<string name="device_settings_info_first_seen_label">First Seen</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user