From ceb4108306cd94abb704d62842c9695785311edb Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 2 Apr 2026 11:53:57 +0200 Subject: [PATCH] feat(profiles): Filter paired devices already used by other profiles Show devices claimed by other profiles as disabled in the dropdown with the claiming profile's name. Enforce address uniqueness at the repo layer. --- .../capod/screenshots/ScreenshotContent.kt | 2 +- .../core/AddressAlreadyClaimedException.kt | 6 +++ .../capod/profiles/core/DeviceProfilesRepo.kt | 11 +++++ .../creation/DeviceProfileCreationScreen.kt | 37 ++++++++++++----- .../DeviceProfileCreationViewModel.kt | 41 +++++++++++++++---- app/src/main/res/values/strings.xml | 1 + 6 files changed, 78 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/eu/darken/capod/profiles/core/AddressAlreadyClaimedException.kt diff --git a/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt b/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt index 8c7192be..92e7242e 100644 --- a/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt +++ b/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt @@ -95,7 +95,7 @@ internal fun AddProfileContent() = PreviewWrapper { identityKey = null, encryptionKey = null, selectedDevice = null, - bondedDevices = emptyList(), + bondedDeviceItems = emptyList(), minimumSignalQuality = 0.15f, canSave = false, ), diff --git a/app/src/main/java/eu/darken/capod/profiles/core/AddressAlreadyClaimedException.kt b/app/src/main/java/eu/darken/capod/profiles/core/AddressAlreadyClaimedException.kt new file mode 100644 index 00000000..856065b4 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/profiles/core/AddressAlreadyClaimedException.kt @@ -0,0 +1,6 @@ +package eu.darken.capod.profiles.core + +class AddressAlreadyClaimedException( + val address: String, + val claimedByProfileLabel: String, +) : IllegalStateException("Address $address is already used by profile '$claimedByProfileLabel'") diff --git a/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesRepo.kt b/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesRepo.kt index db5601c4..eabd1759 100644 --- a/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesRepo.kt +++ b/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesRepo.kt @@ -61,6 +61,7 @@ class DeviceProfilesRepo @Inject constructor( suspend fun addProfile(profile: DeviceProfile, addFirst: Boolean = false) = mutex.withLock { val currentContainer = settings.profiles.valueBlocking + checkAddressUniqueness(profile, currentContainer.profiles) val updatedProfiles = currentContainer.profiles.toMutableList().apply { if (addFirst) add(0, profile) else add(profile) }.toList() @@ -70,6 +71,8 @@ class DeviceProfilesRepo @Inject constructor( suspend fun updateProfile(profile: DeviceProfile) = mutex.withLock { val currentContainer = settings.profiles.valueBlocking + val otherProfiles = currentContainer.profiles.filter { it.id != profile.id } + checkAddressUniqueness(profile, otherProfiles) val updatedProfiles = currentContainer.profiles.map { if (it.id == profile.id) profile else it } @@ -94,6 +97,14 @@ class DeviceProfilesRepo @Inject constructor( settings.profiles.valueBlocking = DeviceProfilesContainer(emptyList()) } + private fun checkAddressUniqueness(profile: DeviceProfile, existingProfiles: List) { + val address = profile.address ?: return + val conflict = existingProfiles.find { it.address?.equals(address, ignoreCase = true) == true } + if (conflict != null) { + throw AddressAlreadyClaimedException(address, conflict.label) + } + } + companion object { val TAG = logTag("Profiles", "Repo") } diff --git a/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationScreen.kt b/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationScreen.kt index 88475194..32d81b82 100644 --- a/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationScreen.kt +++ b/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationScreen.kt @@ -209,7 +209,7 @@ fun DeviceProfileCreationScreen( selectedModel = state.selectedModel, availableModels = state.availableModels, selectedDevice = state.selectedDevice, - bondedDevices = state.bondedDevices, + bondedDeviceItems = state.bondedDeviceItems, onNameChange = onNameChange, onModelChange = onModelChange, onDeviceChange = onDeviceChange, @@ -297,7 +297,7 @@ private fun DeviceInfoCard( selectedModel: PodModel?, availableModels: List, selectedDevice: BluetoothDevice2?, - bondedDevices: List, + bondedDeviceItems: List, onNameChange: (String) -> Unit, onModelChange: (PodModel) -> Unit, onDeviceChange: (BluetoothDevice2?) -> Unit, @@ -418,20 +418,37 @@ private fun DeviceInfoCard( deviceExpanded = false }, ) - bondedDevices.forEach { device -> + bondedDeviceItems.forEach { item -> + val isClaimed = item.claimedByProfile != null DropdownMenuItem( text = { Column { - Text(text = device.name ?: unknownLabel) Text( - text = device.address, + text = item.device.name ?: unknownLabel, + color = if (isClaimed) { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + Text( + text = if (isClaimed) { + stringResource(R.string.profiles_paired_device_used_by, item.claimedByProfile!!) + } else { + item.device.address + }, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = if (isClaimed) { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, ) } }, + enabled = !isClaimed, onClick = { - onDeviceChange(device) + onDeviceChange(item.device) deviceExpanded = false }, ) @@ -660,7 +677,7 @@ private fun DeviceProfileCreationScreenNewPreview() = PreviewWrapper { identityKey = null, encryptionKey = null, selectedDevice = null, - bondedDevices = emptyList(), + bondedDeviceItems = emptyList(), minimumSignalQuality = 0.15f, canSave = false, ), @@ -690,7 +707,7 @@ private fun DeviceProfileCreationScreenEditPreview() = PreviewWrapper { identityKey = null, encryptionKey = null, selectedDevice = null, - bondedDevices = emptyList(), + bondedDeviceItems = emptyList(), minimumSignalQuality = 0.25f, canSave = true, ), @@ -730,7 +747,7 @@ private fun DeviceProfileCreationScreenWithKeysPreview() = PreviewWrapper { 0x23.toByte(), 0x45.toByte(), 0x67.toByte(), 0x89.toByte(), ), selectedDevice = null, - bondedDevices = emptyList(), + bondedDeviceItems = emptyList(), minimumSignalQuality = 0.35f, canSave = true, ), diff --git a/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationViewModel.kt b/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationViewModel.kt index 04f81fa6..de338ef2 100644 --- a/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationViewModel.kt +++ b/app/src/main/java/eu/darken/capod/profiles/ui/creation/DeviceProfileCreationViewModel.kt @@ -17,6 +17,7 @@ import eu.darken.capod.common.uix.ViewModel4 import eu.darken.capod.pods.core.apple.PodModel import eu.darken.capod.pods.core.apple.ble.protocol.IdentityResolvingKey import eu.darken.capod.pods.core.apple.ble.protocol.ProximityEncryptionKey +import eu.darken.capod.profiles.core.AddressAlreadyClaimedException import eu.darken.capod.profiles.core.AppleDeviceProfile import eu.darken.capod.profiles.core.DeviceProfile import eu.darken.capod.profiles.core.DeviceProfilesRepo @@ -25,7 +26,6 @@ import eu.darken.capod.profiles.core.currentProfiles import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.map import java.util.UUID import javax.inject.Inject @@ -54,6 +54,7 @@ class DeviceProfileCreationViewModel @Inject constructor( private val _currentState = MutableStateFlow(ProfileEditorState()) private val _initialState = MutableStateFlow(ProfileEditorState()) private val _nameError = MutableStateFlow(null) + private val _profileIdFlow = MutableStateFlow(null) val showUnsavedChangesEvent = SingleEventFlow() val showDeleteConfirmationEvent = SingleEventFlow() @@ -63,6 +64,7 @@ class DeviceProfileCreationViewModel @Inject constructor( initialized = true this.profileId = profileId + this._profileIdFlow.value = profileId this.isEditMode = profileId != null if (isEditMode && profileId != null) { @@ -75,9 +77,30 @@ class DeviceProfileCreationViewModel @Inject constructor( } } - private val bondedDevicesFlow = bluetoothManager.bondedDevices() - .catch { emit(emptySet()) } - .map { it.toList() } + data class BondedDeviceItem( + val device: BluetoothDevice2, + val claimedByProfile: String?, + ) + + private val bondedDeviceItemsFlow = combine( + bluetoothManager.bondedDevices().catch { emit(emptySet()) }, + deviceProfilesRepo.profiles, + _profileIdFlow, + ) { bonded, profiles, currentProfileId -> + val claimedAddresses = profiles + .filter { it.id != currentProfileId } + .mapNotNull { profile -> profile.address?.let { it.uppercase() to profile.label } } + .toMap() + + bonded + .map { device -> + BondedDeviceItem( + device = device, + claimedByProfile = claimedAddresses[device.address.uppercase()], + ) + } + .sortedWith(compareBy({ it.claimedByProfile != null }, { it.device.name ?: "" }, { it.device.address })) + } private val hasUnsavedChangesFlow = combine( _currentState, _initialState @@ -97,12 +120,12 @@ class DeviceProfileCreationViewModel @Inject constructor( val state = combine( _currentState, _nameError, - bondedDevicesFlow, + bondedDeviceItemsFlow, hasUnsavedChangesFlow, isFormValid, - ) { editorState, nameErr, bonded, hasChanges, formValid -> + ) { editorState, nameErr, bondedItems, hasChanges, formValid -> val selectedDevice = editorState.selectedDeviceAddress?.let { address -> - bonded.find { it.address == address } + bondedItems.find { it.device.address.equals(address, ignoreCase = true) }?.device } State( isEditMode = isEditMode, @@ -113,7 +136,7 @@ class DeviceProfileCreationViewModel @Inject constructor( identityKey = editorState.identityKeyHex?.fromHex(), encryptionKey = editorState.encryptionKeyHex?.fromHex(), selectedDevice = selectedDevice, - bondedDevices = bonded, + bondedDeviceItems = bondedItems, minimumSignalQuality = editorState.minimumSignalQuality, canSave = formValid && hasChanges, ) @@ -128,7 +151,7 @@ class DeviceProfileCreationViewModel @Inject constructor( val identityKey: IdentityResolvingKey?, val encryptionKey: ProximityEncryptionKey?, val selectedDevice: BluetoothDevice2?, - val bondedDevices: List, + val bondedDeviceItems: List, val minimumSignalQuality: Float, val canSave: Boolean, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 349ec2d8..e25b957b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -288,6 +288,7 @@ Paired device None No device selected + Used by %s Save profile Drag to reorder Delete Profile