mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-16 19:26:12 -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.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
|
||||||
|
|
||||||
+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.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>
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user