Implement profile-based device separation in overview UI

- Add UnmatchedDevicesCard to show/hide devices without profiles
- Separate devices with profiles from unmatched devices in overview
- Add priority-based sorting (profile.priority with 0 = highest)
- Add compiler args for experimental unsigned types and annotation targets
- Add string resources for unmatched devices UI
- Refactor overview to handle both profiled and non-profiled devices

The UI now shows:
1. Devices with configured profiles first (sorted by priority)
2. Collapsible section for unmatched devices with toggle button
3. Session-persistent show/hide state for unmatched devices
This commit is contained in:
darken
2025-09-29 13:36:46 +02:00
parent 520458b107
commit 1689153070
14 changed files with 341 additions and 93 deletions
+3 -1
View File
@@ -114,7 +114,9 @@ android {
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview",
"-opt-in=kotlin.time.ExperimentalTime",
"-opt-in=kotlin.RequiresOptIn"
"-opt-in=kotlin.RequiresOptIn",
"-opt-in=kotlin.ExperimentalUnsignedTypes",
"-Xannotation-default-target=param-property"
)
}
@@ -0,0 +1,2 @@
package eu.darken.capod.common.bluetooth
@@ -0,0 +1,110 @@
@file:Suppress("MemberVisibilityCanBePrivate")
package eu.darken.sdmse.common.serialization
import com.squareup.moshi.*
import java.lang.reflect.Type
import java.util.*
import javax.annotation.CheckReturnValue
class NameBasedPolyJsonAdapterFactory<T> internal constructor(
val baseType: Class<T>,
val keyLabels: List<String> = emptyList(),
val subtypes: List<Type> = emptyList(),
) : JsonAdapter.Factory {
fun withSubtype(subtype: Class<out T>, label: String): NameBasedPolyJsonAdapterFactory<T> {
require(!keyLabels.contains(label)) { "Labels must be unique." }
return NameBasedPolyJsonAdapterFactory(
baseType = baseType,
keyLabels = keyLabels + label,
subtypes = subtypes + subtype,
)
}
override fun create(type: Type, annotations: Set<Annotation>, moshi: Moshi): JsonAdapter<*>? {
if (Types.getRawType(type) != baseType || annotations.isNotEmpty()) {
return null
}
val jsonAdapters = ArrayList<JsonAdapter<Any>>(subtypes.size)
var i = 0
val size = subtypes.size
while (i < size) {
jsonAdapters.add(moshi.adapter(subtypes[i]))
i++
}
return PolymorphicJsonAdapter(
nameLabels = keyLabels,
subTypes = subtypes,
jsonAdapters = jsonAdapters,
).nullSafe()
}
internal class PolymorphicJsonAdapter(
val nameLabels: List<String>,
val subTypes: List<Type>,
val jsonAdapters: List<JsonAdapter<Any>>,
) : JsonAdapter<Any>() {
private val nameOptions: JsonReader.Options = JsonReader.Options.of(*nameLabels.toTypedArray())
override fun fromJson(reader: JsonReader): Any? {
val peeked = reader.peekJson().apply {
setFailOnUnknown(false)
}
val labelIndex = peeked.use(::labelIndex)
if (labelIndex == -1) {
throw JsonDataException("No matching Field names for $nameLabels")
}
return jsonAdapters[labelIndex].fromJson(reader)
}
private fun labelIndex(reader: JsonReader): Int {
reader.beginObject()
while (reader.hasNext()) {
val labelIndex = reader.selectName(nameOptions)
if (labelIndex == -1) {
reader.skipName()
reader.skipValue()
continue
}
return labelIndex
}
return -1
// throw JsonDataException("Missing label for $labelKey")
}
override fun toJson(writer: JsonWriter, value: Any?) {
val type = value!!.javaClass
val typeIndex = subTypes.indexOf(type)
if (typeIndex == -1) {
throw JsonDataException("No matching name label for $value. Valid labels are $nameLabels")
}
val adapter = jsonAdapters[typeIndex]
writer.beginObject()
val flattenToken = writer.beginFlatten()
adapter.toJson(writer, value)
writer.endFlatten(flattenToken)
writer.endObject()
}
override fun toString(): String = "KeyBasedPolyJsonAdapterFactory($nameLabels)"
}
companion object {
@CheckReturnValue
fun <T> of(baseType: Class<T>): NameBasedPolyJsonAdapterFactory<T> = NameBasedPolyJsonAdapterFactory(
baseType
)
}
}
@@ -0,0 +1,3 @@
package eu.darken.capod.devices.core
data class AppleDeviceProfile()
@@ -0,0 +1,2 @@
package eu.darken.capod.devices.core
@@ -12,8 +12,9 @@ import eu.darken.capod.common.lists.modular.ModularAdapter
import eu.darken.capod.common.lists.modular.mods.DataBinderMod
import eu.darken.capod.common.lists.modular.mods.TypedVHCreatorMod
import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledVH
import eu.darken.capod.main.ui.overview.cards.MissingMainDeviceVH
import eu.darken.capod.main.ui.overview.cards.NoProfilesVH
import eu.darken.capod.main.ui.overview.cards.PermissionCardVH
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCardVH
import eu.darken.capod.main.ui.overview.cards.pods.DualPodsCardVH
import eu.darken.capod.main.ui.overview.cards.pods.SinglePodsCardVH
import eu.darken.capod.main.ui.overview.cards.pods.UnknownPodDeviceCardVH
@@ -30,8 +31,9 @@ class OverviewAdapter @Inject constructor() :
modules.add(TypedVHCreatorMod({ data[it] is PermissionCardVH.Item }) { PermissionCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is DualPodsCardVH.Item }) { DualPodsCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is SinglePodsCardVH.Item }) { SinglePodsCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is MissingMainDeviceVH.Item }) { MissingMainDeviceVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is NoProfilesVH.Item }) { NoProfilesVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is BluetoothDisabledVH.Item }) { BluetoothDisabledVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is UnmatchedDevicesCardVH.Item }) { UnmatchedDevicesCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is UnknownPodDeviceCardVH.Item }) { UnknownPodDeviceCardVH(it) })
}
@@ -8,18 +8,21 @@ import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.combine
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.common.livedata.SingleLiveEvent
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.uix.ViewModel3
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.devices.core.DeviceProfilesRepo
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledVH
import eu.darken.capod.main.ui.overview.cards.MissingMainDeviceVH
import eu.darken.capod.main.ui.overview.cards.NoProfilesVH
import eu.darken.capod.main.ui.overview.cards.PermissionCardVH
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCardVH
import eu.darken.capod.main.ui.overview.cards.pods.DualPodsCardVH
import eu.darken.capod.main.ui.overview.cards.pods.SinglePodsCardVH
import eu.darken.capod.main.ui.overview.cards.pods.UnknownPodDeviceCardVH
@@ -38,8 +41,10 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive
import java.time.Duration
import java.time.Instant
import javax.inject.Inject
import kotlin.collections.sortedWith
@HiltViewModel
class OverviewFragmentVM @Inject constructor(
@@ -52,6 +57,7 @@ class OverviewFragmentVM @Inject constructor(
debugSettings: DebugSettings,
private val upgradeRepo: UpgradeRepo,
private val bluetoothManager: BluetoothManager2,
private val profilesRepo: DeviceProfilesRepo,
) : ViewModel3(dispatcherProvider = dispatcherProvider) {
init {
@@ -98,6 +104,8 @@ class OverviewFragmentVM @Inject constructor(
val requestPermissionEvent = SingleLiveEvent<Permission>()
private var showUnmatchedDevices = false
private val pods: Flow<List<PodDevice>> = permissionTool.missingPermissions
.flatMapLatest { permissions ->
if (permissions.isNotEmpty()) {
@@ -115,8 +123,8 @@ class OverviewFragmentVM @Inject constructor(
pods,
debugSettings.isDebugModeEnabled.flow,
bluetoothManager.isBluetoothEnabled,
podMonitor.mainDevice,
) { _, permissions, pods, isDebugMode, isBluetoothEnabled, mainPod ->
profilesRepo.profiles,
) { _, permissions, devices, isDebugMode, isBluetoothEnabled, profiles ->
val items = mutableListOf<OverviewAdapter.Item>()
permissions
@@ -131,42 +139,79 @@ class OverviewFragmentVM @Inject constructor(
if (permissions.isEmpty()) {
if (!isBluetoothEnabled) {
items.add(0, BluetoothDisabledVH.Item)
} else if (mainPod == null) {
items.add(0, MissingMainDeviceVH.Item(
onManageDevices = {
OverviewFragmentDirections.actionOverviewFragmentToDeviceManagerFragment().navigate()
}
))
} else if (profiles.isEmpty()) {
items.add(
0, NoProfilesVH.Item(
onManageDevices = {
OverviewFragmentDirections.actionOverviewFragmentToDeviceManagerFragment().navigate()
}
))
}
}
if (permissions.isEmpty() && isBluetoothEnabled) {
pods.map {
val now = Instant.now()
val isMainPod = it.identifier == mainPod?.identifier
when (it) {
val now = Instant.now()
// Split devices into profiled and unmatched
val profiledDevices = devices.filter { it.meta.profile != null }
val unmatchedDevices = devices.filter { it.meta.profile == null }
// Add profiled devices first
profiledDevices.map { device ->
when (device) {
is DualPodDevice -> DualPodsCardVH.Item(
now = now,
device = it,
device = device,
showDebug = isDebugMode,
isMainPod = isMainPod,
)
is SinglePodDevice -> SinglePodsCardVH.Item(
now = now,
device = it,
device = device,
showDebug = isDebugMode,
isMainPod = isMainPod,
)
else -> UnknownPodDeviceCardVH.Item(
now = now,
device = it,
device = device,
showDebug = isDebugMode,
isMainPod = isMainPod,
)
}
}.run { items.addAll(this) }
// Add unmatched devices section if any exist
if (unmatchedDevices.isNotEmpty()) {
items.add(UnmatchedDevicesCardVH.Item(
count = unmatchedDevices.size,
isExpanded = showUnmatchedDevices,
onToggle = { toggleUnmatchedDevices() }
))
// Show unmatched devices if expanded
if (showUnmatchedDevices) {
unmatchedDevices.map { device ->
when (device) {
is DualPodDevice -> DualPodsCardVH.Item(
now = now,
device = device,
showDebug = isDebugMode,
)
is SinglePodDevice -> SinglePodsCardVH.Item(
now = now,
device = device,
showDebug = isDebugMode,
)
else -> UnknownPodDeviceCardVH.Item(
now = now,
device = device,
showDebug = isDebugMode,
)
}
}.run { items.addAll(this) }
}
}
}
items
@@ -193,4 +238,11 @@ class OverviewFragmentVM @Inject constructor(
launchUpgradeFlow.postValue(call)
}
private fun toggleUnmatchedDevices() {
showUnmatchedDevices = !showUnmatchedDevices
}
companion object {
private val TAG = logTag("Overview", "OverviewFragmentVM")
}
}
@@ -0,0 +1,50 @@
package eu.darken.capod.main.ui.overview.cards
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.OverviewUnmatchedDevicesItemBinding
import eu.darken.capod.main.ui.overview.OverviewAdapter
class UnmatchedDevicesCardVH(parent: ViewGroup) :
OverviewAdapter.BaseVH<UnmatchedDevicesCardVH.Item, OverviewUnmatchedDevicesItemBinding>(
R.layout.overview_unmatched_devices_item,
parent
) {
override val viewBinding = lazy {
OverviewUnmatchedDevicesItemBinding.bind(itemView)
}
override val onBindData: OverviewUnmatchedDevicesItemBinding.(
item: Item,
payloads: List<Any>
) -> Unit = binding(payload = true) { item ->
val countText = when (item.count) {
1 -> context.getString(R.string.overview_unmatched_devices_count_single)
else -> context.getString(R.string.overview_unmatched_devices_count_plural, item.count)
}
unmatchedCount.text = countText
val toggleText = if (item.isExpanded) {
context.getString(R.string.general_hide_action)
} else {
context.getString(R.string.general_show_action)
}
toggleAction.text = toggleText
toggleAction.setOnClickListener { item.onToggle() }
}
data class Item(
val count: Int,
val isExpanded: Boolean,
val onToggle: () -> Unit,
) : OverviewAdapter.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 }
}
}
@@ -1,10 +1,10 @@
package eu.darken.capod.monitor.core
import android.bluetooth.le.ScanFilter
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.bluetooth.BleScanner
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.bluetooth.onlyNewAndUnique
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
@@ -13,18 +13,18 @@ import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.replayingShare
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.devices.core.DeviceProfilesRepo
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.PodFactory
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
@@ -48,6 +48,7 @@ class PodMonitor @Inject constructor(
private val debugSettings: DebugSettings,
private val podDeviceCache: PodDeviceCache,
permissionTool: PermissionTool,
private val profilesRepo: DeviceProfilesRepo,
) {
private val deviceCache = mutableMapOf<PodDevice.Id, PodDevice>()
@@ -69,17 +70,9 @@ class PodMonitor @Inject constructor(
createBleScanner()
}
}
.map { newPods ->
val pods = processWithCache(newPods)
val presorted = sortPodsToInterest(pods.values)
val main = determineMainDevice(presorted)
newPods?.firstOrNull { it.device.identifier == main?.identifier }?.let {
podDeviceCache.saveMainDevice(it.scanResult)
}
presorted.sortedByDescending { it == main }
}
.map { results -> results?.mapNotNull { podFactory.createPod(it) } }
.map { processWithCache(it).values }
.map { sortPodsToInterest(it) }
.retryWhen { cause, attempt ->
log(TAG, WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" }
delay(3000)
@@ -88,10 +81,18 @@ class PodMonitor @Inject constructor(
.onStart { emit(emptyList()) }
.replayingShare(appScope)
val mainDevice: Flow<PodDevice?> = devices
.map { determineMainDevice(it) }
.setupCommonEventHandlers(TAG) { "mainDevice" }
.replayingShare(appScope)
private fun sortPodsToInterest(devices: Collection<PodDevice>): List<PodDevice> {
val now = Instant.now()
return devices.sortedWith(
compareBy<PodDevice> { it.meta.profile?.priority ?: Int.MAX_VALUE }
.thenBy {
val age = Duration.between(it.seenLastAt, now).seconds
if (age < 5) 0L else (age / 3L)
}
.thenByDescending { it.signalQuality }
.thenByDescending { (it.seenCounter / 10) }
)
}
private data class ScannerOptions(
val scannerMode: ScannerMode,
@@ -139,8 +140,9 @@ class PodMonitor @Inject constructor(
disableOffloadFiltering = options.offloadedFilteringDisabled,
disableOffloadBatching = options.offloadedBatchingDisabled,
disableDirectScanCallback = options.disableDirectCallback,
).map { preFilterAndMap(it) }
)
}
.map { it.onlyNewAndUnique() }
private suspend fun processWithCache(
newPods: List<PodFactory.Result>?
@@ -170,61 +172,12 @@ class PodMonitor @Inject constructor(
return pods
}
private suspend fun preFilterAndMap(rawResults: Collection<BleScanResult>): List<PodFactory.Result> = rawResults
.groupBy { it.address }
.values
.map { sameAdrDevs ->
// For each address we only want the newest result, upstream may batch data
val newest = sameAdrDevs.maxByOrNull { it.generatedAtNanos }!!
sameAdrDevs.minus(newest).let {
if (it.isNotEmpty()) log(TAG, VERBOSE) { "Discarding stale results: $it" }
}
newest
}
.mapNotNull { podFactory.createPod(it) }
private fun sortPodsToInterest(pods: Collection<PodDevice>): List<PodDevice> {
val now = Instant.now()
return pods.sortedWith(
compareByDescending<PodDevice> { true }
.thenBy {
val age = Duration.between(it.seenLastAt, now).seconds
if (age < 5) 0L else (age / 3L)
}
.thenByDescending { it.signalQuality }
.thenByDescending { (it.seenCounter / 10) }
)
}
private fun determineMainDevice(pods: List<PodDevice>): PodDevice? {
val identityKey = generalSettings.mainDeviceIdentityKey.value?.takeIf { it.isNotEmpty() }
if (identityKey != null) {
val irkHit = pods.filterIsInstance<ApplePods>().firstOrNull { it.flags.isIRKMatch }
log(TAG) { "IRK is configured, main device irkHit=$irkHit" }
return irkHit
}
val mainDeviceModel = generalSettings.mainDeviceModel.value
val presorted = sortPodsToInterest(pods).sortedByDescending {
it.model == mainDeviceModel && it.model != PodDevice.Model.UNKNOWN
}
return presorted.firstOrNull()?.let { candidate ->
when {
candidate.model == PodDevice.Model.UNKNOWN -> null
mainDeviceModel != PodDevice.Model.UNKNOWN && candidate.model != mainDeviceModel -> null
candidate.signalQuality <= generalSettings.minimumSignalQuality.value -> null
else -> candidate
}
}
}
suspend fun latestMainDevice(): PodDevice? {
val currentMain = mainDevice.firstOrNull()
val currentMain = devices.firstOrNull()?.firstOrNull()
log(TAG) { "Live mainDevice is $currentMain" }
return currentMain ?: podDeviceCache.loadMainDevice()
return currentMain ?: profilesRepo.profiles.first().firstOrNull()
?.let { podDeviceCache.load(it.id) }
?.let { podFactory.createPod(it)?.device }
.also { log(TAG) { "Cached mainDevice is $it" } }
}
@@ -0,0 +1,11 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.pods.core.PodDevice
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlin.collections.firstOrNull
fun PodMonitor.devicesWithProfiles(): Flow<List<PodDevice>> = devices
.map { devices -> devices.filter { it.profile != null } }
fun PodMonitor.primaryDevice(): Flow<PodDevice?> = devicesWithProfiles().map { it.firstOrNull() }
@@ -0,0 +1,56 @@
<?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/unmatched_label"
style="@style/TextAppearance.Material3.TitleMedium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="8dp"
android:text="@string/overview_unmatched_devices_label"
app:layout_constraintEnd_toStartOf="@id/toggle_action"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/unmatched_count"
style="@style/TextAppearance.Material3.BodyMedium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="16dp"
tools:text="3 devices without matching profile"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/toggle_action"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/unmatched_label" />
<com.google.android.material.button.MaterialButton
android:id="@+id/toggle_action"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:text="@string/general_show_action"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
+5
View File
@@ -10,6 +10,8 @@
<string name="general_save_action">Save</string>
<string name="general_guide_action">Guide</string>
<string name="general_continue_action">Continue</string>
<string name="general_show_action">Show</string>
<string name="general_hide_action">Hide</string>
<string name="general_example_label">E.g.: %s</string>
@@ -163,6 +165,9 @@
<string name="overview_nomaindevice_description">Configure your device to start monitoring battery levels and enable additional features.</string>
<string name="overview_bluetooth_disabled_label">Bluetooth is disabled</string>
<string name="overview_bluetooth_disabled_description">Bluetooth is disabled, enable it ;)</string>
<string name="overview_unmatched_devices_label">Unmatched devices</string>
<string name="overview_unmatched_devices_count_single">1 device without matching profile</string>
<string name="overview_unmatched_devices_count_plural">%d devices without matching profile</string>
<string name="permission_bluetooth_connect_label">Bluetooth connect</string>
<string name="permission_bluetooth_connect_description">This app requires the \"Bluetooth connect\" permission to interact with paired devices and initiate connections.</string>