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:
darken
2025-09-29 13:37:00 +02:00
parent 679c9cd070
commit 98cf4ff3e7
24 changed files with 1189 additions and 91 deletions
@@ -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>
+22 -1
View File
@@ -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"
+38
View File
@@ -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>