mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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
|
||||
|
||||
+110
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user