From 98cf4ff3e7bda5e774f0a336ed5eebdf74dd4545 Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 4 Sep 2025 18:39:37 +0200 Subject: [PATCH] Refactor device profiles: Add creation UI and drag-to-reorder This commit introduces a new UI for creating and editing device profiles. Users can now define profile names, select device models, set minimum signal quality, and optionally add identity and encryption keys. The device manager screen now supports drag-and-drop reordering of profiles. The order of profiles in the list now determines their priority, with items at the top having higher priority. The internal data structure for device profiles has been updated, and the `PodMonitor` now sorts devices based on this new profile priority. --- .../capod/devices/core/AppleDeviceProfile.kt | 8 +- .../capod/devices/core/DeviceProfile.kt | 9 +- .../capod/devices/core/DeviceProfilesRepo.kt | 76 +---- .../devices/core/DeviceProfilesSettings.kt | 32 ++ .../capod/devices/ui/DeviceManagerAdapter.kt | 18 + .../capod/devices/ui/DeviceManagerFragment.kt | 53 ++- .../devices/ui/DeviceManagerFragmentVM.kt | 41 ++- .../ui/DeviceProfileCreationFragment.kt | 239 +++++++++++++ .../ui/DeviceProfileCreationFragmentVM.kt | 207 ++++++++++++ .../capod/devices/ui/DeviceProfileVH.kt | 5 +- .../capod/devices/ui/NoProfilesCardVH.kt | 34 ++ .../darken/capod/monitor/core/PodMonitor.kt | 15 +- .../res/drawable/ic_baseline_delete_24.xml | 10 + .../drawable/ic_baseline_drag_handle_24.xml | 10 + .../main/res/drawable/ic_baseline_save_24.xml | 10 + .../main/res/layout/device_manager_item.xml | 7 +- .../layout/device_manager_noprofiles_item.xml | 54 +++ .../device_profile_creation_fragment.xml | 316 ++++++++++++++++++ .../main/res/layout/model_dropdown_item.xml | 28 ++ .../res/layout/overview_noprofiles_item.xml | 3 +- .../layout/paired_device_dropdown_item.xml | 26 ++ .../res/menu/device_profile_creation_menu.xml | 18 + app/src/main/res/navigation/nav_graph.xml | 23 +- app/src/main/res/values/strings.xml | 38 +++ 24 files changed, 1189 insertions(+), 91 deletions(-) create mode 100644 app/src/main/java/eu/darken/capod/devices/core/DeviceProfilesSettings.kt create mode 100644 app/src/main/java/eu/darken/capod/devices/ui/DeviceProfileCreationFragment.kt create mode 100644 app/src/main/java/eu/darken/capod/devices/ui/DeviceProfileCreationFragmentVM.kt create mode 100644 app/src/main/java/eu/darken/capod/devices/ui/NoProfilesCardVH.kt create mode 100644 app/src/main/res/drawable/ic_baseline_delete_24.xml create mode 100644 app/src/main/res/drawable/ic_baseline_drag_handle_24.xml create mode 100644 app/src/main/res/drawable/ic_baseline_save_24.xml create mode 100644 app/src/main/res/layout/device_manager_noprofiles_item.xml create mode 100644 app/src/main/res/layout/device_profile_creation_fragment.xml create mode 100644 app/src/main/res/layout/model_dropdown_item.xml create mode 100644 app/src/main/res/layout/paired_device_dropdown_item.xml create mode 100644 app/src/main/res/menu/device_profile_creation_menu.xml diff --git a/app/src/main/java/eu/darken/capod/devices/core/AppleDeviceProfile.kt b/app/src/main/java/eu/darken/capod/devices/core/AppleDeviceProfile.kt index 6083e748..1c2fdf81 100644 --- a/app/src/main/java/eu/darken/capod/devices/core/AppleDeviceProfile.kt +++ b/app/src/main/java/eu/darken/capod/devices/core/AppleDeviceProfile.kt @@ -12,11 +12,11 @@ import java.util.UUID @JsonClass(generateAdapter = true) data class AppleDeviceProfile( @Json(name = "id") override val id: ProfileId = UUID.randomUUID().toString(), - @Json(name = "name") override val name: String, - @Json(name = "minimumSignalQuality") override val minimumSignalQuality: Float = 0.20f, - @Json(name = "isEnabled") override val isEnabled: Boolean = true, - @Json(name = "model") override val model: PodDevice.Model = PodDevice.Model.UNKNOWN, + @Json(name = "label") override val label: String, @Json(name = "priority") override val priority: Int = 0, + @Json(name = "model") override val model: PodDevice.Model = PodDevice.Model.UNKNOWN, + @Json(name = "minimumSignalQuality") override val minimumSignalQuality: Float? = 0.20f, @Json(name = "identityKey") val identityKey: IdentityResolvingKey? = null, @Json(name = "encryptionKey") val encryptionKey: ProximityEncryptionKey? = null, + @Json(name = "address") override val address: String? = null, ) : DeviceProfile \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/devices/core/DeviceProfile.kt b/app/src/main/java/eu/darken/capod/devices/core/DeviceProfile.kt index b66b578d..1fe7759f 100644 --- a/app/src/main/java/eu/darken/capod/devices/core/DeviceProfile.kt +++ b/app/src/main/java/eu/darken/capod/devices/core/DeviceProfile.kt @@ -3,15 +3,14 @@ package eu.darken.capod.devices.core import android.os.Parcelable import eu.darken.capod.common.serialization.NameBasedPolyJsonAdapterFactory import eu.darken.capod.pods.core.PodDevice -import kotlin.jvm.java sealed interface DeviceProfile : Parcelable { val id: ProfileId - val name: String - val minimumSignalQuality: Float - val isEnabled: Boolean - val model: PodDevice.Model + val label: String val priority: Int + val model: PodDevice.Model + val minimumSignalQuality: Float? + val address: String? companion object { val MOSHI_FACTORY: NameBasedPolyJsonAdapterFactory = diff --git a/app/src/main/java/eu/darken/capod/devices/core/DeviceProfilesRepo.kt b/app/src/main/java/eu/darken/capod/devices/core/DeviceProfilesRepo.kt index e30cd4c6..9592beec 100644 --- a/app/src/main/java/eu/darken/capod/devices/core/DeviceProfilesRepo.kt +++ b/app/src/main/java/eu/darken/capod/devices/core/DeviceProfilesRepo.kt @@ -1,83 +1,43 @@ package eu.darken.capod.devices.core -import android.content.Context -import android.content.SharedPreferences -import com.squareup.moshi.Moshi -import com.squareup.moshi.Types -import dagger.hilt.android.qualifiers.ApplicationContext import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE import eu.darken.capod.common.debug.logging.log import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow import javax.inject.Inject import javax.inject.Singleton @Singleton class DeviceProfilesRepo @Inject constructor( - @ApplicationContext private val context: Context, - private val moshi: Moshi, + private val settings: DeviceProfilesSettings, ) { - private val preferences: SharedPreferences = context.getSharedPreferences("device_profiles", Context.MODE_PRIVATE) - - private val _profiles = MutableStateFlow>(emptyList()) - val profiles: Flow> = _profiles.asStateFlow() - - private val listType = Types.newParameterizedType(List::class.java, DeviceProfile::class.java) - private val adapter = moshi.adapter>(listType) - - init { - loadProfiles() - } - - private fun loadProfiles() { - val json = preferences.getString(KEY_PROFILES, null) - val loadedProfiles = if (json != null) { - try { - adapter.fromJson(json) ?: emptyList() - } catch (e: Exception) { - log(VERBOSE) { "Failed to load device profiles: $e" } - emptyList() - } - } else { - emptyList() - } - _profiles.value = loadedProfiles - log(VERBOSE) { "Loaded ${loadedProfiles.size} device profiles" } - } - - private fun saveProfiles() { - val json = adapter.toJson(_profiles.value) - preferences.edit().putString(KEY_PROFILES, json).apply() - log(VERBOSE) { "Saved ${_profiles.value.size} device profiles" } - } + val profiles: Flow> = settings.profiles.flow fun addProfile(profile: DeviceProfile) { - val updatedProfiles = _profiles.value.toMutableList() - updatedProfiles.add(profile) - _profiles.value = updatedProfiles - saveProfiles() + val currentProfiles = settings.profiles.value + val updatedProfiles = currentProfiles + profile + settings.profiles.value = updatedProfiles + log(VERBOSE) { "Added device profile: ${profile.label}" } } fun updateProfile(profile: DeviceProfile) { - val updatedProfiles = _profiles.value.toMutableList() - val index = updatedProfiles.indexOfFirst { it.id == profile.id } - if (index != -1) { - updatedProfiles[index] = profile - _profiles.value = updatedProfiles - saveProfiles() + val currentProfiles = settings.profiles.value + val updatedProfiles = currentProfiles.map { + if (it.id == profile.id) profile else it } + settings.profiles.value = updatedProfiles + log(VERBOSE) { "Updated device profile: ${profile.label}" } } fun removeProfile(profileId: String) { - val updatedProfiles = _profiles.value.toMutableList() - updatedProfiles.removeAll { it.id == profileId } - _profiles.value = updatedProfiles - saveProfiles() + val currentProfiles = settings.profiles.value + val updatedProfiles = currentProfiles.filter { it.id != profileId } + settings.profiles.value = updatedProfiles + log(VERBOSE) { "Removed device profile with ID: $profileId" } } - companion object { - private const val KEY_PROFILES = "profiles" + fun reorderProfiles(profiles: List) { + settings.profiles.value = profiles.toList() + log(VERBOSE) { "Reordered ${profiles.size} device profiles" } } } \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/devices/core/DeviceProfilesSettings.kt b/app/src/main/java/eu/darken/capod/devices/core/DeviceProfilesSettings.kt new file mode 100644 index 00000000..bffbea7b --- /dev/null +++ b/app/src/main/java/eu/darken/capod/devices/core/DeviceProfilesSettings.kt @@ -0,0 +1,32 @@ +package eu.darken.capod.devices.core + +import android.content.Context +import android.content.SharedPreferences +import androidx.preference.PreferenceDataStore +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import dagger.hilt.android.qualifiers.ApplicationContext +import eu.darken.capod.common.preferences.PreferenceStoreMapper +import eu.darken.capod.common.preferences.Settings +import eu.darken.capod.common.preferences.createFlowPreference +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DeviceProfilesSettings @Inject constructor( + @ApplicationContext private val context: Context, + moshi: Moshi, +) : Settings() { + + override val preferences: SharedPreferences = context.getSharedPreferences("device_profiles", Context.MODE_PRIVATE) + + private val listType = Types.newParameterizedType(List::class.java, DeviceProfile::class.java) + + val profiles = preferences.createFlowPreference>( + "profiles", + emptyList(), + moshi + ) + + override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper() +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/devices/ui/DeviceManagerAdapter.kt b/app/src/main/java/eu/darken/capod/devices/ui/DeviceManagerAdapter.kt index e088a175..e0d352a5 100644 --- a/app/src/main/java/eu/darken/capod/devices/ui/DeviceManagerAdapter.kt +++ b/app/src/main/java/eu/darken/capod/devices/ui/DeviceManagerAdapter.kt @@ -22,10 +22,28 @@ class DeviceManagerAdapter @Inject constructor() : init { modules.add(DataBinderMod(data)) modules.add(TypedVHCreatorMod({ data[it] is DeviceProfileVH.Item }) { DeviceProfileVH(it) }) + modules.add(TypedVHCreatorMod({ data[it] is NoProfilesCardVH.Item }) { NoProfilesCardVH(it) }) } override fun getItemCount(): Int = data.size + fun moveItem(fromPosition: Int, toPosition: Int): Boolean { + if (fromPosition < 0 || toPosition < 0 || fromPosition >= data.size || toPosition >= data.size) { + return false + } + + val currentData = data.toMutableList() + val item = currentData.removeAt(fromPosition) + currentData.add(toPosition, item) + + // Update the adapter data through the differ for proper visual feedback + asyncDiffer.submitUpdate(currentData) + notifyItemMoved(fromPosition, toPosition) + return true + } + + fun getItems(): List = data.toList() + abstract class BaseVH( @LayoutRes layoutId: Int, parent: ViewGroup diff --git a/app/src/main/java/eu/darken/capod/devices/ui/DeviceManagerFragment.kt b/app/src/main/java/eu/darken/capod/devices/ui/DeviceManagerFragment.kt index 10433529..c9f74971 100644 --- a/app/src/main/java/eu/darken/capod/devices/ui/DeviceManagerFragment.kt +++ b/app/src/main/java/eu/darken/capod/devices/ui/DeviceManagerFragment.kt @@ -7,6 +7,8 @@ import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updateLayoutParams import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint import eu.darken.capod.R import eu.darken.capod.common.EdgeToEdgeHelper @@ -25,6 +27,8 @@ class DeviceManagerFragment : Fragment3(R.layout.device_manager_fragment) { @Inject lateinit var adapter: DeviceManagerAdapter + + private var isDragging = false override fun onViewCreated(view: View, savedInstanceState: Bundle?) { EdgeToEdgeHelper(requireActivity()).apply { @@ -56,10 +60,57 @@ class DeviceManagerFragment : Fragment3(R.layout.device_manager_fragment) { fab.setOnClickListener { vm.onAddDevice() } + + // Setup drag-to-reorder for profiles + val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback( + ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0 + ) { + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + val fromPosition = viewHolder.adapterPosition + val toPosition = target.adapterPosition + + // Only allow reordering of profile items, not empty state cards + if (adapter.data[fromPosition] is DeviceProfileVH.Item && + adapter.data[toPosition] is DeviceProfileVH.Item) { + return adapter.moveItem(fromPosition, toPosition) + } + return false + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + // No swipe to dismiss + } + + override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { + super.onSelectedChanged(viewHolder, actionState) + when (actionState) { + ItemTouchHelper.ACTION_STATE_DRAG -> { + isDragging = true + } + ItemTouchHelper.ACTION_STATE_IDLE -> { + if (isDragging) { + // Drag finished, save new order + vm.onProfilesReordered(adapter.getItems()) + isDragging = false + } + } + } + } + + override fun isLongPressDragEnabled(): Boolean = true + override fun isItemViewSwipeEnabled(): Boolean = false + }) + itemTouchHelper.attachToRecyclerView(list) } vm.listItems.observe2(ui) { items -> - adapter.update(items) + if (!isDragging) { + adapter.update(items) + } } super.onViewCreated(view, savedInstanceState) diff --git a/app/src/main/java/eu/darken/capod/devices/ui/DeviceManagerFragmentVM.kt b/app/src/main/java/eu/darken/capod/devices/ui/DeviceManagerFragmentVM.kt index 1ea1fe65..893effd1 100644 --- a/app/src/main/java/eu/darken/capod/devices/ui/DeviceManagerFragmentVM.kt +++ b/app/src/main/java/eu/darken/capod/devices/ui/DeviceManagerFragmentVM.kt @@ -23,12 +23,19 @@ class DeviceManagerFragmentVM @Inject constructor( val listItems: LiveData> = deviceProfilesRepo.profiles .map { profiles -> - profiles.map { profile -> - DeviceProfileVH.Item( - profile = profile, - onItemClick = { onItemClick(it) }, - onMenuClick = { onMenuClick(it) } + if (profiles.isEmpty()) { + listOf( + NoProfilesCardVH.Item( + onAddProfile = { onAddDevice() } + ) ) + } else { + profiles.map { profile -> + DeviceProfileVH.Item( + profile = profile, + onItemClick = { onEditProfile(it) } + ) + } } } .onEach { log(TAG) { "Profiles updated: ${it.size} items" } } @@ -36,7 +43,9 @@ class DeviceManagerFragmentVM @Inject constructor( fun onAddDevice() { log(TAG) { "onAddDevice()" } - // TODO: Navigate to device profile creation screen + DeviceManagerFragmentDirections + .actionDeviceManagerFragmentToDeviceProfileCreationFragment() + .navigate() } fun onBackPressed() { @@ -44,14 +53,22 @@ class DeviceManagerFragmentVM @Inject constructor( navEvents.postValue(null) } - private fun onItemClick(profile: DeviceProfile) { - log(TAG) { "onItemClick(): $profile" } - // TODO: Navigate to device profile edit screen + private fun onEditProfile(profile: DeviceProfile) { + log(TAG) { "onEditProfile(): $profile" } + DeviceManagerFragmentDirections + .actionDeviceManagerFragmentToDeviceProfileCreationFragment(profileId = profile.id) + .navigate() } - private fun onMenuClick(profile: DeviceProfile) { - log(TAG) { "onMenuClick(): $profile" } - // TODO: Show menu with edit/delete options + fun onProfilesReordered(items: List) { + log(TAG) { "onProfilesReordered(): ${items.size} items" } + val profiles = items.filterIsInstance().map { it.profile } + if (profiles.isNotEmpty()) { + launch { + deviceProfilesRepo.reorderProfiles(profiles) + log(TAG) { "Profiles reordered: ${profiles.map { it.label }}" } + } + } } companion object { diff --git a/app/src/main/java/eu/darken/capod/devices/ui/DeviceProfileCreationFragment.kt b/app/src/main/java/eu/darken/capod/devices/ui/DeviceProfileCreationFragment.kt new file mode 100644 index 00000000..85fbcd13 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/devices/ui/DeviceProfileCreationFragment.kt @@ -0,0 +1,239 @@ +package eu.darken.capod.devices.ui + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.ImageView +import android.widget.TextView +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.viewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.slider.Slider +import dagger.hilt.android.AndroidEntryPoint +import eu.darken.capod.R +import eu.darken.capod.common.EdgeToEdgeHelper +import eu.darken.capod.common.WebpageTool +import eu.darken.capod.common.bluetooth.BluetoothDevice2 +import eu.darken.capod.common.fromHex +import eu.darken.capod.common.toHex +import eu.darken.capod.common.uix.Fragment3 +import eu.darken.capod.common.viewbinding.viewBinding +import eu.darken.capod.databinding.DeviceProfileCreationFragmentBinding +import eu.darken.capod.pods.core.PodDevice +import javax.inject.Inject + +@AndroidEntryPoint +class DeviceProfileCreationFragment : Fragment3(R.layout.device_profile_creation_fragment) { + + override val vm: DeviceProfileCreationFragmentVM by viewModels() + override val ui: DeviceProfileCreationFragmentBinding by viewBinding() + + @Inject lateinit var webpageTool: WebpageTool + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + EdgeToEdgeHelper(requireActivity()).apply { + insetsPadding(ui.root, left = true, right = true) + insetsPadding(ui.toolbar, top = true) + } + + ui.apply { + toolbar.setNavigationOnClickListener { vm.onBackPressed() } + toolbar.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.action_save -> { + vm.saveProfile() + true + } + R.id.action_delete -> { + showDeleteConfirmation() + true + } + else -> false + } + } + + // Show delete button only in edit mode + toolbar.menu.findItem(R.id.action_delete)?.isVisible = vm.isEditMode + + nameInput.doOnTextChanged { text, _, _, _ -> + vm.updateName(text?.toString() ?: "") + } + + signalQualitySlider.addOnChangeListener { _: Slider, value: Float, _: Boolean -> + vm.updateMinimumSignalQuality(value / 100f) + } + + identityKeyInput.doOnTextChanged { text, _, _, _ -> + val keyText = text?.toString() ?: "" + vm.updateIdentityKey(keyText.fromHex().takeIf { it.isNotEmpty() }) + } + + encryptionKeyInput.doOnTextChanged { text, _, _, _ -> + val keyText = text?.toString() ?: "" + vm.updateEncryptionKey(keyText.fromHex().takeIf { it.isNotEmpty() }) + } + + identityKeyGuideButton.setOnClickListener { + webpageTool.open("https://github.com/d4rken-org/capod/wiki/airpod-Keys") + } + + encryptionKeyGuideButton.setOnClickListener { + webpageTool.open("https://github.com/d4rken-org/capod/wiki/airpod-Keys") + } + + // Set hints using same format as AirPodKeyInputDialog + val exampleKey = "FE-D0-1C-54-11-81-BC-BC-87-D2-C4-3F-31-64-5F-EE" + identityKeyInputLayout.hint = getString(R.string.general_example_label, exampleKey) + encryptionKeyInputLayout.hint = getString(R.string.general_example_label, exampleKey) + + + // Setup model dropdown + val modelAdapter = ModelArrayAdapter(requireContext(), vm.availableModels) + modelInput.setAdapter(modelAdapter) + modelInput.setOnItemClickListener { _, _, position, _ -> + val selectedModel = vm.availableModels[position] + vm.updateModel(selectedModel) + } + } + + vm.bondedDevices.observe2(ui) { devices -> + val deviceAdapter = DeviceArrayAdapter(requireContext(), devices) + deviceInput.setAdapter(deviceAdapter) + deviceInput.setOnItemClickListener { _, _, position, _ -> + val selectedDevice = devices[position] + vm.updateSelectedDevice(selectedDevice) + } + } + + + vm.name.observe2(ui) { name -> + if (nameInput.text?.toString() != name) { + nameInput.setText(name) + } + } + + vm.selectedModel.observe2(ui) { model -> + model?.let { + if (modelInput.text?.toString() != it.label) { + modelInput.setText(it.label, false) + } + } + } + + + vm.nameError.observe2(ui) { error -> + nameInputLayout.error = error + } + + + vm.canSave.observe2(ui) { canSave -> + toolbar.menu.findItem(R.id.action_save)?.let { saveItem -> + saveItem.isEnabled = canSave + // Visual feedback for disabled state + val alpha = if (canSave) 255 else 128 + saveItem.icon?.alpha = alpha + } + } + + vm.identityKey.observe2(ui) { key -> + val keyText = key?.toHex() ?: "" + if (identityKeyInput.text?.toString() != keyText) { + identityKeyInput.setText(keyText) + } + } + + vm.encryptionKey.observe2(ui) { key -> + val keyText = key?.toHex() ?: "" + if (encryptionKeyInput.text?.toString() != keyText) { + encryptionKeyInput.setText(keyText) + } + } + + vm.selectedDevice.observe2(ui) { device -> + device?.let { + val deviceText = "${it.name ?: "Unknown Device"} (${it.address})" + if (deviceInput.text?.toString() != deviceText) { + deviceInput.setText(deviceText, false) + } + } + } + + vm.minimumSignalQuality.observe2(ui) { quality -> + val percentage = (quality * 100).toInt() + signalQualityStatus.text = "$percentage%" + if (signalQualitySlider.value != quality * 100f) { + signalQualitySlider.value = quality * 100f + } + } + + super.onViewCreated(view, savedInstanceState) + } + + private fun showDeleteConfirmation() { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.device_profiles_delete_title) + .setMessage(R.string.device_profiles_delete_message) + .setPositiveButton(R.string.device_profiles_delete_action) { _, _ -> + vm.deleteProfile() + } + .setNegativeButton(R.string.general_cancel_action, null) + .show() + } + + private class ModelArrayAdapter( + context: Context, + models: List + ) : ArrayAdapter(context, R.layout.model_dropdown_item, models) { + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + return createView(position, convertView, parent) + } + + override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { + return createView(position, convertView, parent) + } + + private fun createView(position: Int, convertView: View?, parent: ViewGroup): View { + val model = getItem(position)!! + val view = convertView ?: LayoutInflater.from(context).inflate(R.layout.model_dropdown_item, parent, false) + + val iconView = view.findViewById(R.id.model_icon) + val nameView = view.findViewById(R.id.model_name) + + iconView.setImageResource(model.iconRes) + nameView.text = model.label + + return view + } + } + + private class DeviceArrayAdapter( + context: Context, + devices: List + ) : ArrayAdapter(context, R.layout.paired_device_dropdown_item, devices) { + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + return createView(position, convertView, parent) + } + + override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { + return createView(position, convertView, parent) + } + + private fun createView(position: Int, convertView: View?, parent: ViewGroup): View { + val device = getItem(position)!! + val view = convertView ?: LayoutInflater.from(context).inflate(R.layout.paired_device_dropdown_item, parent, false) + + val nameView = view.findViewById(R.id.device_name) + val addressView = view.findViewById(R.id.device_address) + + nameView.text = device.name ?: "Unknown Device" + addressView.text = device.address + + return view + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/devices/ui/DeviceProfileCreationFragmentVM.kt b/app/src/main/java/eu/darken/capod/devices/ui/DeviceProfileCreationFragmentVM.kt new file mode 100644 index 00000000..fd55bca4 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/devices/ui/DeviceProfileCreationFragmentVM.kt @@ -0,0 +1,207 @@ +package eu.darken.capod.devices.ui + +import androidx.lifecycle.LiveData +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.asLiveData +import dagger.hilt.android.lifecycle.HiltViewModel +import eu.darken.capod.common.bluetooth.BluetoothDevice2 +import eu.darken.capod.common.bluetooth.BluetoothManager2 +import eu.darken.capod.common.coroutine.DispatcherProvider +import eu.darken.capod.common.debug.logging.log +import eu.darken.capod.common.debug.logging.logTag +import eu.darken.capod.common.uix.ViewModel3 +import eu.darken.capod.devices.core.AppleDeviceProfile +import eu.darken.capod.devices.core.DeviceProfilesRepo +import eu.darken.capod.pods.core.PodDevice +import eu.darken.capod.pods.core.apple.protocol.IdentityResolvingKey +import eu.darken.capod.pods.core.apple.protocol.ProximityEncryptionKey +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import java.util.UUID +import javax.inject.Inject + +@HiltViewModel +class DeviceProfileCreationFragmentVM @Inject constructor( + handle: SavedStateHandle, + dispatcherProvider: DispatcherProvider, + private val deviceProfilesRepo: DeviceProfilesRepo, + private val bluetoothManager: BluetoothManager2, +) : ViewModel3(dispatcherProvider = dispatcherProvider) { + + private val profileId: String? = handle.get("profileId") + val isEditMode: Boolean = profileId != null + + private val _name = MutableStateFlow("") + private val _selectedModel = MutableStateFlow(null) + private val _identityKey = MutableStateFlow(null) + private val _encryptionKey = MutableStateFlow(null) + private val _selectedDevice = MutableStateFlow(null) + private val _minimumSignalQuality = MutableStateFlow(0.20f) + + private val _nameError = MutableStateFlow(null) + + init { + if (isEditMode && profileId != null) { + loadProfile(profileId) + } + } + + val name: LiveData = _name.asLiveData() + val selectedModel: LiveData = _selectedModel.asLiveData() + val nameError: LiveData = _nameError.asLiveData() + val identityKey: LiveData = _identityKey.asLiveData() + val encryptionKey: LiveData = _encryptionKey.asLiveData() + val selectedDevice: LiveData = _selectedDevice.asLiveData() + val minimumSignalQuality: LiveData = _minimumSignalQuality.asLiveData() + + val availableModels: List = PodDevice.Model.entries + + val bondedDevices: LiveData> = bluetoothManager.bondedDevices() + .catch { emit(emptySet()) } + .map { it.toList() } + .asLiveData() + + private val isFormValid = combine( + _name, + _selectedModel, + _nameError + ) { name, model, nameError -> + name.isNotBlank() && + model != null && + nameError == null + } + + val canSave: LiveData = isFormValid.asLiveData() + + fun updateName(name: String) { + _name.value = name + _nameError.value = if (name.isBlank()) "Profile name is required" else null + } + + fun updateModel(model: PodDevice.Model) { + _selectedModel.value = model + log(TAG) { "Selected model: $model" } + } + + fun updateIdentityKey(key: IdentityResolvingKey?) { + _identityKey.value = key + log(TAG) { "Identity key updated: ${key != null}" } + } + + fun updateEncryptionKey(key: ProximityEncryptionKey?) { + _encryptionKey.value = key + log(TAG) { "Encryption key updated: ${key != null}" } + } + + fun updateSelectedDevice(device: BluetoothDevice2?) { + _selectedDevice.value = device + log(TAG) { "Selected device updated: ${device?.name} (${device?.address})" } + } + + fun updateMinimumSignalQuality(quality: Float) { + _minimumSignalQuality.value = quality + log(TAG) { "Minimum signal quality updated: $quality" } + } + + private fun loadProfile(profileId: String) { + launch { + try { + val profiles = deviceProfilesRepo.profiles.first() + val profile = profiles.find { it.id == profileId } + if (profile != null) { + _name.value = profile.label + _selectedModel.value = profile.model + _minimumSignalQuality.value = profile.minimumSignalQuality ?: 0.20f + + if (profile is AppleDeviceProfile) { + _identityKey.value = profile.identityKey + _encryptionKey.value = profile.encryptionKey + } + + log(TAG) { "Profile loaded: ${profile.label}" } + } else { + log(TAG) { "Profile not found: $profileId" } + errorEvents.postValue(IllegalArgumentException("Profile not found")) + } + } catch (e: Exception) { + log(TAG) { "Failed to load profile: $e" } + errorEvents.postValue(e) + } + } + } + + + fun saveProfile() { + log(TAG) { "saveProfile()" } + + val name = _name.value.trim() + val model = _selectedModel.value + + if (name.isBlank()) { + _nameError.value = "Profile name is required" + return + } + + if (model == null) { + log(TAG) { "No model selected" } + return + } + + + launch { + try { + val profile = AppleDeviceProfile( + id = if (isEditMode) profileId!! else UUID.randomUUID().toString(), + label = name, + model = model, + minimumSignalQuality = _minimumSignalQuality.value, + identityKey = _identityKey.value, + encryptionKey = _encryptionKey.value + ) + + if (isEditMode) { + deviceProfilesRepo.updateProfile(profile) + log(TAG) { "Profile updated: $profile" } + } else { + deviceProfilesRepo.addProfile(profile) + log(TAG) { "Profile created: $profile" } + } + popBackStack() + } catch (e: Exception) { + log(TAG) { "Failed to save profile: $e" } + errorEvents.postValue(e) + } + } + } + + fun deleteProfile() { + if (isEditMode && profileId != null) { + launch { + try { + deviceProfilesRepo.removeProfile(profileId) + log(TAG) { "Profile deleted: $profileId" } + popBackStack() + } catch (e: Exception) { + log(TAG) { "Failed to delete profile: $e" } + errorEvents.postValue(e) + } + } + } + } + + fun onBackPressed() { + log(TAG) { "onBackPressed()" } + popBackStack() + } + + private fun popBackStack() { + navEvents.postValue(null) + } + + companion object { + private val TAG = logTag("DeviceProfileCreation", "ViewModel") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/devices/ui/DeviceProfileVH.kt b/app/src/main/java/eu/darken/capod/devices/ui/DeviceProfileVH.kt index 71bca170..6b2ac6d4 100644 --- a/app/src/main/java/eu/darken/capod/devices/ui/DeviceProfileVH.kt +++ b/app/src/main/java/eu/darken/capod/devices/ui/DeviceProfileVH.kt @@ -19,7 +19,8 @@ class DeviceProfileVH(parent: ViewGroup) : ) -> Unit = { item, _ -> val profile = item.profile - deviceName.text = profile.name + deviceIcon.setImageResource(profile.model.iconRes) + deviceName.text = profile.label deviceDetails.text = buildString { profile.address?.let { append(it.toString()) @@ -29,13 +30,11 @@ class DeviceProfileVH(parent: ViewGroup) : } itemView.setOnClickListener { item.onItemClick(profile) } - menuButton.setOnClickListener { item.onMenuClick(profile) } } data class Item( val profile: DeviceProfile, val onItemClick: (DeviceProfile) -> Unit, - val onMenuClick: (DeviceProfile) -> Unit, ) : DeviceManagerAdapter.Item { override val stableId: Long = profile.id.hashCode().toLong() } diff --git a/app/src/main/java/eu/darken/capod/devices/ui/NoProfilesCardVH.kt b/app/src/main/java/eu/darken/capod/devices/ui/NoProfilesCardVH.kt new file mode 100644 index 00000000..597eab72 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/devices/ui/NoProfilesCardVH.kt @@ -0,0 +1,34 @@ +package eu.darken.capod.devices.ui + +import android.view.ViewGroup +import eu.darken.capod.R +import eu.darken.capod.common.lists.binding +import eu.darken.capod.common.lists.differ.DifferItem +import eu.darken.capod.databinding.DeviceManagerNoprofilesItemBinding + +class NoProfilesCardVH(parent: ViewGroup) : + DeviceManagerAdapter.BaseVH( + R.layout.device_manager_noprofiles_item, + parent + ) { + + override val viewBinding = lazy { + DeviceManagerNoprofilesItemBinding.bind(itemView) + } + + override val onBindData: DeviceManagerNoprofilesItemBinding.( + item: Item, + payloads: List + ) -> Unit = binding(payload = true) { item -> + addProfileAction.setOnClickListener { item.onAddProfile() } + } + + data class Item( + val onAddProfile: () -> Unit, + ) : DeviceManagerAdapter.Item { + override val stableId: Long = Item::class.hashCode().toLong() + + override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?) + get() = { old, new -> if (new::class.isInstance(old)) new else null } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodMonitor.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodMonitor.kt index 85a60ea5..ffd283f5 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodMonitor.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodMonitor.kt @@ -72,7 +72,9 @@ class PodMonitor @Inject constructor( } .map { results -> results?.mapNotNull { podFactory.createPod(it) } } .map { processWithCache(it).values } - .map { sortPodsToInterest(it) } + .flatMapLatest { devices -> + flowOf(sortPodsToInterest(devices)) + } .retryWhen { cause, attempt -> log(TAG, WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" } delay(3000) @@ -81,10 +83,17 @@ class PodMonitor @Inject constructor( .onStart { emit(emptyList()) } .replayingShare(appScope) - private fun sortPodsToInterest(devices: Collection): List { + private suspend fun sortPodsToInterest(devices: Collection): List { val now = Instant.now() + val profiles = profilesRepo.profiles.firstOrNull() ?: emptyList() + return devices.sortedWith( - compareBy { it.meta.profile?.priority ?: Int.MAX_VALUE } + compareBy { device -> + // Use profile position in list as priority (0 = highest priority) + device.meta.profile?.let { profile -> + profiles.indexOfFirst { it.id == profile.id }.takeIf { it >= 0 } ?: Int.MAX_VALUE + } ?: Int.MAX_VALUE + } .thenBy { val age = Duration.between(it.seenLastAt, now).seconds if (age < 5) 0L else (age / 3L) diff --git a/app/src/main/res/drawable/ic_baseline_delete_24.xml b/app/src/main/res/drawable/ic_baseline_delete_24.xml new file mode 100644 index 00000000..9bdf6515 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_delete_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_drag_handle_24.xml b/app/src/main/res/drawable/ic_baseline_drag_handle_24.xml new file mode 100644 index 00000000..05273e5f --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_drag_handle_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_save_24.xml b/app/src/main/res/drawable/ic_baseline_save_24.xml new file mode 100644 index 00000000..49499764 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_save_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/layout/device_manager_item.xml b/app/src/main/res/layout/device_manager_item.xml index d3b170f9..87c5fd03 100644 --- a/app/src/main/res/layout/device_manager_item.xml +++ b/app/src/main/res/layout/device_manager_item.xml @@ -53,13 +53,14 @@ + android:contentDescription="@string/device_profiles_drag_handle_description" + android:src="@drawable/ic_baseline_drag_handle_24" + android:alpha="0.6" /> diff --git a/app/src/main/res/layout/device_manager_noprofiles_item.xml b/app/src/main/res/layout/device_manager_noprofiles_item.xml new file mode 100644 index 00000000..f2748091 --- /dev/null +++ b/app/src/main/res/layout/device_manager_noprofiles_item.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/device_profile_creation_fragment.xml b/app/src/main/res/layout/device_profile_creation_fragment.xml new file mode 100644 index 00000000..02fb9da4 --- /dev/null +++ b/app/src/main/res/layout/device_profile_creation_fragment.xml @@ -0,0 +1,316 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/model_dropdown_item.xml b/app/src/main/res/layout/model_dropdown_item.xml new file mode 100644 index 00000000..fcac6348 --- /dev/null +++ b/app/src/main/res/layout/model_dropdown_item.xml @@ -0,0 +1,28 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/overview_noprofiles_item.xml b/app/src/main/res/layout/overview_noprofiles_item.xml index c90e191b..2ac3a942 100644 --- a/app/src/main/res/layout/overview_noprofiles_item.xml +++ b/app/src/main/res/layout/overview_noprofiles_item.xml @@ -40,10 +40,11 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/device_profile_creation_menu.xml b/app/src/main/res/menu/device_profile_creation_menu.xml new file mode 100644 index 00000000..83eff474 --- /dev/null +++ b/app/src/main/res/menu/device_profile_creation_menu.xml @@ -0,0 +1,18 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index 54a6bb9d..9d0748a5 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -44,7 +44,28 @@ android:id="@+id/deviceManagerFragment" android:name="eu.darken.capod.devices.ui.DeviceManagerFragment" android:label="DeviceManagerFragment" - tools:layout="@layout/device_manager_fragment" /> + tools:layout="@layout/device_manager_fragment"> + + + + + + + Show notifications "Allow CAPod to show notifications about your AirPods, e.g. their current status while connected." + + No device profiles configured + Create device profiles to manage multiple devices with custom settings and priorities. + Add profile + Create Profile + Edit Profile + Profile name + My AirPods Pro + Device model + Select your device model + Paired device (optional) + Select paired Bluetooth device + Priority + 0 (highest priority) + Lower numbers have higher priority. Use 0 for your main device. + Save profile + Profile name is required + Priority must be a number between 0 and 999 + 0 + Identity Key: Not set + Configured + Encryption Key: Not set + Configured + Identity Key (IRK) + Not configured + Optional: Identity key for enhanced device recognition + Proximity Encryption Key + Not configured + Optional: Encryption key for proximity data decryption + Drag to reorder + Delete Profile + Are you sure you want to delete this profile? This action cannot be undone. + Delete + Device Information + Configure your device name, model, and optional Bluetooth pairing. + Minimum Signal Quality + Only detect devices with signal strength above this threshold. Lower values increase detection range but may cause false positives. + \ No newline at end of file