mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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")
|
||||
}
|
||||
|
||||
+27
-10
@@ -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,
|
||||
),
|
||||
|
||||
+32
-9
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user