mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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.
This commit is contained in:
@@ -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
|
||||
@@ -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<DeviceProfile> =
|
||||
|
||||
@@ -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<List<DeviceProfile>>(emptyList())
|
||||
val profiles: Flow<List<DeviceProfile>> = _profiles.asStateFlow()
|
||||
|
||||
private val listType = Types.newParameterizedType(List::class.java, DeviceProfile::class.java)
|
||||
private val adapter = moshi.adapter<List<DeviceProfile>>(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<List<DeviceProfile>> = 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<DeviceProfile>) {
|
||||
settings.profiles.value = profiles.toList()
|
||||
log(VERBOSE) { "Reordered ${profiles.size} device profiles" }
|
||||
}
|
||||
}
|
||||
@@ -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<List<DeviceProfile>>(
|
||||
"profiles",
|
||||
emptyList(),
|
||||
moshi
|
||||
)
|
||||
|
||||
override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper()
|
||||
}
|
||||
@@ -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<Item> = data.toList()
|
||||
|
||||
abstract class BaseVH<D : Item, B : ViewBinding>(
|
||||
@LayoutRes layoutId: Int,
|
||||
parent: ViewGroup
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -23,12 +23,19 @@ class DeviceManagerFragmentVM @Inject constructor(
|
||||
|
||||
val listItems: LiveData<List<DeviceManagerAdapter.Item>> = 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<DeviceManagerAdapter.Item>) {
|
||||
log(TAG) { "onProfilesReordered(): ${items.size} items" }
|
||||
val profiles = items.filterIsInstance<DeviceProfileVH.Item>().map { it.profile }
|
||||
if (profiles.isNotEmpty()) {
|
||||
launch {
|
||||
deviceProfilesRepo.reorderProfiles(profiles)
|
||||
log(TAG) { "Profiles reordered: ${profiles.map { it.label }}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -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<PodDevice.Model>
|
||||
) : ArrayAdapter<PodDevice.Model>(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<ImageView>(R.id.model_icon)
|
||||
val nameView = view.findViewById<TextView>(R.id.model_name)
|
||||
|
||||
iconView.setImageResource(model.iconRes)
|
||||
nameView.text = model.label
|
||||
|
||||
return view
|
||||
}
|
||||
}
|
||||
|
||||
private class DeviceArrayAdapter(
|
||||
context: Context,
|
||||
devices: List<BluetoothDevice2>
|
||||
) : ArrayAdapter<BluetoothDevice2>(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<TextView>(R.id.device_name)
|
||||
val addressView = view.findViewById<TextView>(R.id.device_address)
|
||||
|
||||
nameView.text = device.name ?: "Unknown Device"
|
||||
addressView.text = device.address
|
||||
|
||||
return view
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>("profileId")
|
||||
val isEditMode: Boolean = profileId != null
|
||||
|
||||
private val _name = MutableStateFlow("")
|
||||
private val _selectedModel = MutableStateFlow<PodDevice.Model?>(null)
|
||||
private val _identityKey = MutableStateFlow<IdentityResolvingKey?>(null)
|
||||
private val _encryptionKey = MutableStateFlow<ProximityEncryptionKey?>(null)
|
||||
private val _selectedDevice = MutableStateFlow<BluetoothDevice2?>(null)
|
||||
private val _minimumSignalQuality = MutableStateFlow(0.20f)
|
||||
|
||||
private val _nameError = MutableStateFlow<String?>(null)
|
||||
|
||||
init {
|
||||
if (isEditMode && profileId != null) {
|
||||
loadProfile(profileId)
|
||||
}
|
||||
}
|
||||
|
||||
val name: LiveData<String> = _name.asLiveData()
|
||||
val selectedModel: LiveData<PodDevice.Model?> = _selectedModel.asLiveData()
|
||||
val nameError: LiveData<String?> = _nameError.asLiveData()
|
||||
val identityKey: LiveData<IdentityResolvingKey?> = _identityKey.asLiveData()
|
||||
val encryptionKey: LiveData<ProximityEncryptionKey?> = _encryptionKey.asLiveData()
|
||||
val selectedDevice: LiveData<BluetoothDevice2?> = _selectedDevice.asLiveData()
|
||||
val minimumSignalQuality: LiveData<Float> = _minimumSignalQuality.asLiveData()
|
||||
|
||||
val availableModels: List<PodDevice.Model> = PodDevice.Model.entries
|
||||
|
||||
val bondedDevices: LiveData<List<BluetoothDevice2>> = 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<Boolean> = 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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<NoProfilesCardVH.Item, DeviceManagerNoprofilesItemBinding>(
|
||||
R.layout.device_manager_noprofiles_item,
|
||||
parent
|
||||
) {
|
||||
|
||||
override val viewBinding = lazy {
|
||||
DeviceManagerNoprofilesItemBinding.bind(itemView)
|
||||
}
|
||||
|
||||
override val onBindData: DeviceManagerNoprofilesItemBinding.(
|
||||
item: Item,
|
||||
payloads: List<Any>
|
||||
) -> 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 }
|
||||
}
|
||||
}
|
||||
@@ -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<PodDevice>): List<PodDevice> {
|
||||
private suspend fun sortPodsToInterest(devices: Collection<PodDevice>): List<PodDevice> {
|
||||
val now = Instant.now()
|
||||
val profiles = profilesRepo.profiles.firstOrNull() ?: emptyList()
|
||||
|
||||
return devices.sortedWith(
|
||||
compareBy<PodDevice> { it.meta.profile?.priority ?: Int.MAX_VALUE }
|
||||
compareBy<PodDevice> { 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)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorOnSurface">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorOnSurface">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M20,9H4v2h16V9zM4,15h16v-2H4V15z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorOnSurface">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M17,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,7l-4,-4zM12,19c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3zM15,9L5,9L5,5h10v4z"/>
|
||||
</vector>
|
||||
@@ -53,13 +53,14 @@
|
||||
</LinearLayout>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/menu_button"
|
||||
android:id="@+id/drag_handle"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:background="?selectableItemBackgroundBorderless"
|
||||
android:contentDescription="Menu"
|
||||
android:src="@drawable/abc_ic_menu_overflow_material" />
|
||||
android:contentDescription="@string/device_profiles_drag_handle_description"
|
||||
android:src="@drawable/ic_baseline_drag_handle_24"
|
||||
android:alpha="0.6" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/card"
|
||||
style="@style/MyCardView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:context=".main.ui.MainActivity">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<com.google.android.material.textview.MaterialTextView
|
||||
android:id="@+id/title_label"
|
||||
style="@style/TextAppearance.Material3.TitleMedium"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/device_profiles_empty_title"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<com.google.android.material.textview.MaterialTextView
|
||||
android:id="@+id/description_label"
|
||||
style="@style/TextAppearance.Material3.BodyMedium"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/device_profiles_empty_description"
|
||||
app:layout_constraintBottom_toTopOf="@id/add_profile_action"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/title_label" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/add_profile_action"
|
||||
style="@style/Widget.Material3.Button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="16dp"
|
||||
android:text="@string/device_profiles_add_action"
|
||||
app:icon="@drawable/ic_baseline_add_24"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/description_label" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
@@ -0,0 +1,316 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/root"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
style="@style/Widget.Material3.Toolbar"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:menu="@menu/device_profile_creation_menu"
|
||||
app:navigationIcon="@drawable/ic_baseline_arrow_back_24"
|
||||
app:title="@string/device_profiles_create_title"
|
||||
app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge"
|
||||
app:titleMarginStart="16dp" />
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/toolbar">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<!-- Basic Profile Info Card -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<com.google.android.material.textview.MaterialTextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/device_profiles_basic_info_title"
|
||||
android:textAppearance="?attr/textAppearanceSubtitle1"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<com.google.android.material.textview.MaterialTextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:text="@string/device_profiles_basic_info_description"
|
||||
android:textAppearance="?attr/textAppearanceCaption"
|
||||
android:textColor="?android:attr/textColorTertiary" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/name_input_layout"
|
||||
style="@style/Widget.Material3.TextInputLayout.FilledBox"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:hint="@string/device_profiles_name_label">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/name_input"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text"
|
||||
android:maxLines="1" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/model_input_layout"
|
||||
style="@style/Widget.Material3.TextInputLayout.FilledBox.ExposedDropdownMenu"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:hint="@string/device_profiles_model_label">
|
||||
|
||||
<AutoCompleteTextView
|
||||
android:id="@+id/model_input"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="none"
|
||||
android:focusable="false"
|
||||
android:cursorVisible="false" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/device_input_layout"
|
||||
style="@style/Widget.Material3.TextInputLayout.FilledBox.ExposedDropdownMenu"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/device_profiles_paired_device_label">
|
||||
|
||||
<AutoCompleteTextView
|
||||
android:id="@+id/device_input"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="none"
|
||||
android:focusable="false"
|
||||
android:cursorVisible="false" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Signal Quality Card -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<com.google.android.material.textview.MaterialTextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/device_profiles_signal_quality_title"
|
||||
android:textAppearance="?attr/textAppearanceSubtitle1"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<com.google.android.material.textview.MaterialTextView
|
||||
android:id="@+id/signal_quality_status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="20%"
|
||||
android:textAppearance="?attr/textAppearanceBody2"
|
||||
android:textColor="?android:attr/textColorSecondary" />
|
||||
|
||||
<com.google.android.material.textview.MaterialTextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:text="@string/device_profiles_signal_quality_description"
|
||||
android:textAppearance="?attr/textAppearanceCaption"
|
||||
android:textColor="?android:attr/textColorTertiary" />
|
||||
|
||||
<com.google.android.material.slider.Slider
|
||||
android:id="@+id/signal_quality_slider"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:valueFrom="10"
|
||||
android:valueTo="100"
|
||||
android:stepSize="5"
|
||||
android:value="20" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Identity Key Card -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<com.google.android.material.textview.MaterialTextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/settings_maindevice_identitykey_label"
|
||||
android:textAppearance="?attr/textAppearanceSubtitle1"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/identity_key_guide_button"
|
||||
style="@style/Widget.Material3.Button.TextButton.Icon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/general_guide_action"
|
||||
android:textSize="12sp"
|
||||
app:icon="@drawable/ic_baseline_question_mark_24"
|
||||
app:iconSize="16dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.textview.MaterialTextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:text="@string/settings_maindevice_identitykey_explanation"
|
||||
android:textAppearance="?attr/textAppearanceCaption"
|
||||
android:textColor="?android:attr/textColorTertiary" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/identity_key_input_layout"
|
||||
style="@style/Widget.Material3.TextInputLayout.FilledBox"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/identity_key_input"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textCapCharacters"
|
||||
android:maxLines="1"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Encryption Key Card -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<com.google.android.material.textview.MaterialTextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/settings_maindevice_encryptionkey_label"
|
||||
android:textAppearance="?attr/textAppearanceSubtitle1"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/encryption_key_guide_button"
|
||||
style="@style/Widget.Material3.Button.TextButton.Icon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/general_guide_action"
|
||||
android:textSize="12sp"
|
||||
app:icon="@drawable/ic_baseline_question_mark_24"
|
||||
app:iconSize="16dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.textview.MaterialTextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:text="@string/settings_maindevice_encryptionkey_explanation"
|
||||
android:textAppearance="?attr/textAppearanceCaption"
|
||||
android:textColor="?android:attr/textColorTertiary" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/encryption_key_input_layout"
|
||||
style="@style/Widget.Material3.TextInputLayout.FilledBox"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/encryption_key_input"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textCapCharacters"
|
||||
android:maxLines="1"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/model_icon"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:scaleType="centerInside"
|
||||
tools:src="@drawable/devic_airpods_gen1_both" />
|
||||
|
||||
<com.google.android.material.textview.MaterialTextView
|
||||
android:id="@+id/model_name"
|
||||
android:layout_width="0dp"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textAppearance="?attr/textAppearanceBody1"
|
||||
android:textColor="?android:attr/textColorPrimary"
|
||||
tools:text="AirPods Pro (Gen 2)" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -40,10 +40,11 @@
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/manage_devices_action"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
style="@style/Widget.Material3.Button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="16dp"
|
||||
app:icon="@drawable/ic_baseline_devices_other_24"
|
||||
android:text="@string/general_manage_devices_action"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<com.google.android.material.textview.MaterialTextView
|
||||
android:id="@+id/device_name"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBody1"
|
||||
android:textColor="?android:attr/textColorPrimary"
|
||||
tools:text="My AirPods Pro" />
|
||||
|
||||
<com.google.android.material.textview.MaterialTextView
|
||||
android:id="@+id/device_address"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:textAppearance="?attr/textAppearanceCaption"
|
||||
android:textColor="?android:attr/textColorSecondary"
|
||||
tools:text="AA:BB:CC:DD:EE:FF" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<item
|
||||
android:id="@+id/action_delete"
|
||||
android:icon="@drawable/ic_baseline_delete_24"
|
||||
android:title="@string/device_profiles_delete_action"
|
||||
android:visible="false"
|
||||
app:showAsAction="ifRoom" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_save"
|
||||
android:icon="@drawable/ic_baseline_save_24"
|
||||
android:title="@string/device_profiles_save_action"
|
||||
app:showAsAction="always" />
|
||||
|
||||
</menu>
|
||||
@@ -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">
|
||||
<action
|
||||
android:id="@+id/action_deviceManagerFragment_to_deviceProfileCreationFragment"
|
||||
app:destination="@id/deviceProfileCreationFragment">
|
||||
<argument
|
||||
android:name="profileId"
|
||||
app:argType="string"
|
||||
app:nullable="true"
|
||||
android:defaultValue="@null" />
|
||||
</action>
|
||||
</fragment>
|
||||
<fragment
|
||||
android:id="@+id/deviceProfileCreationFragment"
|
||||
android:name="eu.darken.capod.devices.ui.DeviceProfileCreationFragment"
|
||||
android:label="DeviceProfileCreationFragment"
|
||||
tools:layout="@layout/device_profile_creation_fragment">
|
||||
<argument
|
||||
android:name="profileId"
|
||||
app:argType="string"
|
||||
app:nullable="true"
|
||||
android:defaultValue="@null" />
|
||||
</fragment>
|
||||
<fragment
|
||||
android:id="@+id/troubleShooterFragment"
|
||||
android:name="eu.darken.capod.troubleshooter.ui.TroubleShooterFragment"
|
||||
|
||||
@@ -226,5 +226,43 @@
|
||||
<string name="permission_post_notifications_label">Show notifications</string>
|
||||
<string name="permission_post_notifications_description">"Allow CAPod to show notifications about your AirPods, e.g. their current status while connected."</string>
|
||||
|
||||
<!-- Device profiles -->
|
||||
<string name="device_profiles_empty_title">No device profiles configured</string>
|
||||
<string name="device_profiles_empty_description">Create device profiles to manage multiple devices with custom settings and priorities.</string>
|
||||
<string name="device_profiles_add_action">Add profile</string>
|
||||
<string name="device_profiles_create_title">Create Profile</string>
|
||||
<string name="device_profiles_edit_title">Edit Profile</string>
|
||||
<string name="device_profiles_name_label">Profile name</string>
|
||||
<string name="device_profiles_name_hint">My AirPods Pro</string>
|
||||
<string name="device_profiles_model_label">Device model</string>
|
||||
<string name="device_profiles_model_hint">Select your device model</string>
|
||||
<string name="device_profiles_paired_device_label">Paired device (optional)</string>
|
||||
<string name="device_profiles_paired_device_hint">Select paired Bluetooth device</string>
|
||||
<string name="device_profiles_priority_label">Priority</string>
|
||||
<string name="device_profiles_priority_hint">0 (highest priority)</string>
|
||||
<string name="device_profiles_priority_description">Lower numbers have higher priority. Use 0 for your main device.</string>
|
||||
<string name="device_profiles_save_action">Save profile</string>
|
||||
<string name="device_profiles_validation_name_required">Profile name is required</string>
|
||||
<string name="device_profiles_validation_priority_invalid">Priority must be a number between 0 and 999</string>
|
||||
<string name="device_profiles_priority_default">0</string>
|
||||
<string name="device_profiles_identity_key_label">Identity Key: Not set</string>
|
||||
<string name="device_profiles_identity_key_set">Configured</string>
|
||||
<string name="device_profiles_encryption_key_label">Encryption Key: Not set</string>
|
||||
<string name="device_profiles_encryption_key_set">Configured</string>
|
||||
<string name="device_profiles_identity_key_title">Identity Key (IRK)</string>
|
||||
<string name="device_profiles_identity_key_not_set">Not configured</string>
|
||||
<string name="device_profiles_identity_key_description">Optional: Identity key for enhanced device recognition</string>
|
||||
<string name="device_profiles_encryption_key_title">Proximity Encryption Key</string>
|
||||
<string name="device_profiles_encryption_key_not_set">Not configured</string>
|
||||
<string name="device_profiles_encryption_key_description">Optional: Encryption key for proximity data decryption</string>
|
||||
<string name="device_profiles_drag_handle_description">Drag to reorder</string>
|
||||
<string name="device_profiles_delete_title">Delete Profile</string>
|
||||
<string name="device_profiles_delete_message">Are you sure you want to delete this profile? This action cannot be undone.</string>
|
||||
<string name="device_profiles_delete_action">Delete</string>
|
||||
<string name="device_profiles_basic_info_title">Device Information</string>
|
||||
<string name="device_profiles_basic_info_description">Configure your device name, model, and optional Bluetooth pairing.</string>
|
||||
<string name="device_profiles_signal_quality_title">Minimum Signal Quality</string>
|
||||
<string name="device_profiles_signal_quality_description">Only detect devices with signal strength above this threshold. Lower values increase detection range but may cause false positives.</string>
|
||||
|
||||
|
||||
</resources>
|
||||
Reference in New Issue
Block a user