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