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