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.
This commit is contained in:
darken
2026-04-02 12:23:06 +02:00
committed by Matthias Urhahn
parent fe4ac306cf
commit ceb4108306
6 changed files with 78 additions and 20 deletions
@@ -95,7 +95,7 @@ internal fun AddProfileContent() = PreviewWrapper {
identityKey = null,
encryptionKey = null,
selectedDevice = null,
bondedDevices = emptyList(),
bondedDeviceItems = emptyList(),
minimumSignalQuality = 0.15f,
canSave = false,
),
@@ -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'")
@@ -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<DeviceProfile>) {
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")
}
@@ -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<PodModel>,
selectedDevice: BluetoothDevice2?,
bondedDevices: List<BluetoothDevice2>,
bondedDeviceItems: List<DeviceProfileCreationViewModel.BondedDeviceItem>,
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,
),
@@ -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<String?>(null)
private val _profileIdFlow = MutableStateFlow<ProfileId?>(null)
val showUnsavedChangesEvent = SingleEventFlow<Unit>()
val showDeleteConfirmationEvent = SingleEventFlow<Unit>()
@@ -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<BluetoothDevice2>,
val bondedDeviceItems: List<BondedDeviceItem>,
val minimumSignalQuality: Float,
val canSave: Boolean,
)
+1
View File
@@ -288,6 +288,7 @@
<string name="profiles_paired_device_label">Paired device</string>
<string name="profiles_paired_device_none">None</string>
<string name="profiles_paired_device_none_description">No device selected</string>
<string name="profiles_paired_device_used_by">Used by %s</string>
<string name="profiles_save_action">Save profile</string>
<string name="profiles_drag_handle_description">Drag to reorder</string>
<string name="profiles_delete_title">Delete Profile</string>