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.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview", "-opt-in=kotlinx.coroutines.FlowPreview",
"-opt-in=kotlin.time.ExperimentalTime", "-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.DataBinderMod
import eu.darken.capod.common.lists.modular.mods.TypedVHCreatorMod 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.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.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.DualPodsCardVH
import eu.darken.capod.main.ui.overview.cards.pods.SinglePodsCardVH import eu.darken.capod.main.ui.overview.cards.pods.SinglePodsCardVH
import eu.darken.capod.main.ui.overview.cards.pods.UnknownPodDeviceCardVH 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 PermissionCardVH.Item }) { PermissionCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is DualPodsCardVH.Item }) { DualPodsCardVH(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 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 BluetoothDisabledVH.Item }) { BluetoothDisabledVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is UnmatchedDevicesCardVH.Item }) { UnmatchedDevicesCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is UnknownPodDeviceCardVH.Item }) { UnknownPodDeviceCardVH(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.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.DebugSettings import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.logging.log 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.combine
import eu.darken.capod.common.flow.throttleLatest import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.common.livedata.SingleLiveEvent import eu.darken.capod.common.livedata.SingleLiveEvent
import eu.darken.capod.common.permissions.Permission import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.uix.ViewModel3 import eu.darken.capod.common.uix.ViewModel3
import eu.darken.capod.common.upgrade.UpgradeRepo 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.GeneralSettings
import eu.darken.capod.main.core.MonitorMode import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.PermissionTool import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledVH 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.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.DualPodsCardVH
import eu.darken.capod.main.ui.overview.cards.pods.SinglePodsCardVH import eu.darken.capod.main.ui.overview.cards.pods.SinglePodsCardVH
import eu.darken.capod.main.ui.overview.cards.pods.UnknownPodDeviceCardVH 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.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import java.time.Duration
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
import kotlin.collections.sortedWith
@HiltViewModel @HiltViewModel
class OverviewFragmentVM @Inject constructor( class OverviewFragmentVM @Inject constructor(
@@ -52,6 +57,7 @@ class OverviewFragmentVM @Inject constructor(
debugSettings: DebugSettings, debugSettings: DebugSettings,
private val upgradeRepo: UpgradeRepo, private val upgradeRepo: UpgradeRepo,
private val bluetoothManager: BluetoothManager2, private val bluetoothManager: BluetoothManager2,
private val profilesRepo: DeviceProfilesRepo,
) : ViewModel3(dispatcherProvider = dispatcherProvider) { ) : ViewModel3(dispatcherProvider = dispatcherProvider) {
init { init {
@@ -98,6 +104,8 @@ class OverviewFragmentVM @Inject constructor(
val requestPermissionEvent = SingleLiveEvent<Permission>() val requestPermissionEvent = SingleLiveEvent<Permission>()
private var showUnmatchedDevices = false
private val pods: Flow<List<PodDevice>> = permissionTool.missingPermissions private val pods: Flow<List<PodDevice>> = permissionTool.missingPermissions
.flatMapLatest { permissions -> .flatMapLatest { permissions ->
if (permissions.isNotEmpty()) { if (permissions.isNotEmpty()) {
@@ -115,8 +123,8 @@ class OverviewFragmentVM @Inject constructor(
pods, pods,
debugSettings.isDebugModeEnabled.flow, debugSettings.isDebugModeEnabled.flow,
bluetoothManager.isBluetoothEnabled, bluetoothManager.isBluetoothEnabled,
podMonitor.mainDevice, profilesRepo.profiles,
) { _, permissions, pods, isDebugMode, isBluetoothEnabled, mainPod -> ) { _, permissions, devices, isDebugMode, isBluetoothEnabled, profiles ->
val items = mutableListOf<OverviewAdapter.Item>() val items = mutableListOf<OverviewAdapter.Item>()
permissions permissions
@@ -131,42 +139,79 @@ class OverviewFragmentVM @Inject constructor(
if (permissions.isEmpty()) { if (permissions.isEmpty()) {
if (!isBluetoothEnabled) { if (!isBluetoothEnabled) {
items.add(0, BluetoothDisabledVH.Item) items.add(0, BluetoothDisabledVH.Item)
} else if (mainPod == null) { } else if (profiles.isEmpty()) {
items.add(0, MissingMainDeviceVH.Item( items.add(
onManageDevices = { 0, NoProfilesVH.Item(
OverviewFragmentDirections.actionOverviewFragmentToDeviceManagerFragment().navigate() onManageDevices = {
} OverviewFragmentDirections.actionOverviewFragmentToDeviceManagerFragment().navigate()
)) }
))
} }
} }
if (permissions.isEmpty() && isBluetoothEnabled) { if (permissions.isEmpty() && isBluetoothEnabled) {
pods.map { val now = Instant.now()
val now = Instant.now()
val isMainPod = it.identifier == mainPod?.identifier // Split devices into profiled and unmatched
when (it) { 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( is DualPodDevice -> DualPodsCardVH.Item(
now = now, now = now,
device = it, device = device,
showDebug = isDebugMode, showDebug = isDebugMode,
isMainPod = isMainPod,
) )
is SinglePodDevice -> SinglePodsCardVH.Item( is SinglePodDevice -> SinglePodsCardVH.Item(
now = now, now = now,
device = it, device = device,
showDebug = isDebugMode, showDebug = isDebugMode,
isMainPod = isMainPod,
) )
else -> UnknownPodDeviceCardVH.Item( else -> UnknownPodDeviceCardVH.Item(
now = now, now = now,
device = it, device = device,
showDebug = isDebugMode, showDebug = isDebugMode,
isMainPod = isMainPod,
) )
} }
}.run { items.addAll(this) } }.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 items
@@ -193,4 +238,11 @@ class OverviewFragmentVM @Inject constructor(
launchUpgradeFlow.postValue(call) 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 package eu.darken.capod.monitor.core
import android.bluetooth.le.ScanFilter 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.BleScanner
import eu.darken.capod.common.bluetooth.BluetoothManager2 import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.bluetooth.ScannerMode 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.coroutine.AppScope
import eu.darken.capod.common.debug.DebugSettings import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE 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.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.replayingShare 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.common.flow.throttleLatest
import eu.darken.capod.devices.core.DeviceProfilesRepo
import eu.darken.capod.main.core.GeneralSettings import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.PermissionTool import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.PodFactory 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 eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
@@ -48,6 +48,7 @@ class PodMonitor @Inject constructor(
private val debugSettings: DebugSettings, private val debugSettings: DebugSettings,
private val podDeviceCache: PodDeviceCache, private val podDeviceCache: PodDeviceCache,
permissionTool: PermissionTool, permissionTool: PermissionTool,
private val profilesRepo: DeviceProfilesRepo,
) { ) {
private val deviceCache = mutableMapOf<PodDevice.Id, PodDevice>() private val deviceCache = mutableMapOf<PodDevice.Id, PodDevice>()
@@ -69,17 +70,9 @@ class PodMonitor @Inject constructor(
createBleScanner() createBleScanner()
} }
} }
.map { newPods -> .map { results -> results?.mapNotNull { podFactory.createPod(it) } }
val pods = processWithCache(newPods) .map { processWithCache(it).values }
.map { sortPodsToInterest(it) }
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 }
}
.retryWhen { cause, attempt -> .retryWhen { cause, attempt ->
log(TAG, WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" } log(TAG, WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" }
delay(3000) delay(3000)
@@ -88,10 +81,18 @@ class PodMonitor @Inject constructor(
.onStart { emit(emptyList()) } .onStart { emit(emptyList()) }
.replayingShare(appScope) .replayingShare(appScope)
val mainDevice: Flow<PodDevice?> = devices private fun sortPodsToInterest(devices: Collection<PodDevice>): List<PodDevice> {
.map { determineMainDevice(it) } val now = Instant.now()
.setupCommonEventHandlers(TAG) { "mainDevice" } return devices.sortedWith(
.replayingShare(appScope) 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( private data class ScannerOptions(
val scannerMode: ScannerMode, val scannerMode: ScannerMode,
@@ -139,8 +140,9 @@ class PodMonitor @Inject constructor(
disableOffloadFiltering = options.offloadedFilteringDisabled, disableOffloadFiltering = options.offloadedFilteringDisabled,
disableOffloadBatching = options.offloadedBatchingDisabled, disableOffloadBatching = options.offloadedBatchingDisabled,
disableDirectScanCallback = options.disableDirectCallback, disableDirectScanCallback = options.disableDirectCallback,
).map { preFilterAndMap(it) } )
} }
.map { it.onlyNewAndUnique() }
private suspend fun processWithCache( private suspend fun processWithCache(
newPods: List<PodFactory.Result>? newPods: List<PodFactory.Result>?
@@ -170,61 +172,12 @@ class PodMonitor @Inject constructor(
return pods 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? { suspend fun latestMainDevice(): PodDevice? {
val currentMain = mainDevice.firstOrNull() val currentMain = devices.firstOrNull()?.firstOrNull()
log(TAG) { "Live mainDevice is $currentMain" } 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 } ?.let { podFactory.createPod(it)?.device }
.also { log(TAG) { "Cached mainDevice is $it" } } .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_save_action">Save</string>
<string name="general_guide_action">Guide</string> <string name="general_guide_action">Guide</string>
<string name="general_continue_action">Continue</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> <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_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_label">Bluetooth is disabled</string>
<string name="overview_bluetooth_disabled_description">Bluetooth is disabled, enable it ;)</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_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> <string name="permission_bluetooth_connect_description">This app requires the \"Bluetooth connect\" permission to interact with paired devices and initiate connections.</string>