diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewAdapter.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewAdapter.kt index 4dafad34..1f6d05a8 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewAdapter.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewAdapter.kt @@ -32,9 +32,9 @@ class OverviewAdapter @Inject constructor() : modules.add(TypedVHCreatorMod({ data[it] is DualApplePodsCardVH.Item }) { DualApplePodsCardVH(it) }) modules.add(TypedVHCreatorMod({ data[it] is SingleApplePodsCardVH.Item }) { SingleApplePodsCardVH(it) }) modules.add(TypedVHCreatorMod({ data[it] is BasicSingleApplePodsCardVH.Item }) { BasicSingleApplePodsCardVH(it) }) - modules.add(TypedVHCreatorMod({ data[it] is UnknownPodDeviceCardVH.Item }) { UnknownPodDeviceCardVH(it) }) modules.add(TypedVHCreatorMod({ data[it] is MissingMainDeviceVH.Item }) { MissingMainDeviceVH(it) }) modules.add(TypedVHCreatorMod({ data[it] is BluetoothDisabledVH.Item }) { BluetoothDisabledVH(it) }) + modules.add(TypedVHCreatorMod({ data[it] is UnknownPodDeviceCardVH.Item }) { UnknownPodDeviceCardVH(it) }) } override fun getItemCount(): Int = data.size diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/pods/UnknownPodDeviceCardVH.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/pods/UnknownPodDeviceCardVH.kt index 2874ff38..0423d535 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/pods/UnknownPodDeviceCardVH.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/pods/UnknownPodDeviceCardVH.kt @@ -6,6 +6,7 @@ import eu.darken.capod.R import eu.darken.capod.common.lists.binding import eu.darken.capod.databinding.OverviewPodsUnknownItemBinding import eu.darken.capod.pods.core.PodDevice +import eu.darken.capod.pods.core.apple.ApplePods import eu.darken.capod.pods.core.lastSeenFormatted import java.time.Instant @@ -28,10 +29,14 @@ class UnknownPodDeviceCardVH(parent: ViewGroup) : } lastSeen.text = device.lastSeenFormatted(item.now) - reception.text = item.getReceptionText() - rawdata.text = device.rawDataHex + details.text = when (item.device) { + is ApplePods -> getString(R.string.pods_unknown_contact_dev) + else -> getString(R.string.pods_unknown_label) + } + + rawdata.text = device.rawDataHex.joinToString("\n") } data class Item( diff --git a/app/src/main/java/eu/darken/capod/pods/core/PodDevice.kt b/app/src/main/java/eu/darken/capod/pods/core/PodDevice.kt index ced5316a..710ae7f4 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/PodDevice.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/PodDevice.kt @@ -40,10 +40,13 @@ interface PodDevice { val signalQuality: Float get() = ((100 - abs(rssi)) / 100f) * (max(BASE_CONFIDENCE, confidence)) - val rawData: ByteArray + val rawData: Map + get() = scanResult.manufacturerSpecificData - val rawDataHex: String - get() = rawData.joinToString(separator = " ") { String.format("%02X", it) } + val rawDataHex: List + get() = rawData.entries.map { entry -> + "${entry.key}: ${entry.value.joinToString(separator = " ") { String.format("%02X", it) }}" + } fun getLabel(context: Context): String = model.label diff --git a/app/src/main/java/eu/darken/capod/pods/core/PodFactory.kt b/app/src/main/java/eu/darken/capod/pods/core/PodFactory.kt index edb096ca..af3fc912 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/PodFactory.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/PodFactory.kt @@ -2,32 +2,34 @@ package eu.darken.capod.pods.core import dagger.Reusable import eu.darken.capod.common.bluetooth.BleScanResult +import eu.darken.capod.common.debug.logging.Logging.Priority.INFO import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE -import eu.darken.capod.common.debug.logging.Logging.Priority.WARN import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.pods.core.apple.AppleFactory +import eu.darken.capod.pods.core.unknown.UnknownDeviceFactory import javax.inject.Inject @Reusable class PodFactory @Inject constructor( - private val appleFactory: AppleFactory + private val appleFactory: AppleFactory, + private val unknownFactory: UnknownDeviceFactory, ) { suspend fun createPod(scanResult: BleScanResult): PodDevice? { log(TAG, VERBOSE) { "Trying to create Pod for $scanResult" } - val pod = appleFactory.create(scanResult) - if (pod != null) { - log(TAG) { "Pod created: $pod" } - return pod - } else { - log(TAG, WARN) { "Not an AirPod : $scanResult" } + log(TAG, INFO) { "Decoding $scanResult" } + + var device = appleFactory.create(scanResult) + + if (device == null) { + log(TAG, VERBOSE) { "Using fallback factory" } + device = unknownFactory.create(scanResult) } - - log(TAG, WARN) { "Failed to find matching PodFactory for $scanResult" } - return null + log(TAG, INFO) { "Pod created: $device" } + return device } companion object { diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/AppleFactory.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/AppleFactory.kt index 8df795aa..926222dd 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/AppleFactory.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/AppleFactory.kt @@ -2,18 +2,15 @@ package eu.darken.capod.pods.core.apple import eu.darken.capod.common.bluetooth.BleScanResult import eu.darken.capod.common.debug.Bugs -import eu.darken.capod.common.debug.logging.Logging.Priority.* +import eu.darken.capod.common.debug.logging.Logging.Priority.WARN 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.pods.core.PodDevice -import eu.darken.capod.pods.core.apple.airpods.* -import eu.darken.capod.pods.core.apple.beats.* import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol import eu.darken.capod.pods.core.apple.protocol.ProximityPairing import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import java.util.* import javax.inject.Inject import javax.inject.Singleton @@ -55,8 +52,6 @@ class AppleFactory @Inject constructor( suspend fun create(scanResult: BleScanResult): PodDevice? = lock.withLock { val pm = getMessage(scanResult) ?: return@withLock null - log(TAG, INFO) { "Decoding $scanResult" } - val factory = podFactories.firstOrNull { it.isResponsible(pm) } val device = (factory ?: unknownAppleFactory).create( diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/ApplePods.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/ApplePods.kt index 1e43d3a3..476b10a6 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/ApplePods.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/ApplePods.kt @@ -4,16 +4,12 @@ import eu.darken.capod.common.lowerNibble import eu.darken.capod.common.toHex import eu.darken.capod.common.upperNibble import eu.darken.capod.pods.core.PodDevice -import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol import eu.darken.capod.pods.core.apple.protocol.ProximityPairing interface ApplePods : PodDevice { val proximityMessage: ProximityPairing.Message - override val rawData: ByteArray - get() = scanResult.getManufacturerSpecificData(ContinuityProtocol.APPLE_COMPANY_IDENTIFIER)!! - // We start counting at the airpods prefix byte val rawPrefix: UByte get() = proximityMessage.data[0] diff --git a/app/src/main/java/eu/darken/capod/pods/core/unknown/UnknownDevice.kt b/app/src/main/java/eu/darken/capod/pods/core/unknown/UnknownDevice.kt new file mode 100644 index 00000000..ec64a41f --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/unknown/UnknownDevice.kt @@ -0,0 +1,30 @@ +package eu.darken.capod.pods.core.unknown + +import android.content.Context +import eu.darken.capod.R +import eu.darken.capod.common.bluetooth.BleScanResult +import eu.darken.capod.common.debug.logging.logTag +import eu.darken.capod.pods.core.PodDevice +import java.time.Instant + +data class UnknownDevice( + override val identifier: PodDevice.Id = PodDevice.Id(), + override val seenLastAt: Instant = Instant.now(), + override val seenFirstAt: Instant = Instant.now(), + override val seenCounter: Int = 1, + override val scanResult: BleScanResult, + override val confidence: Float = 0f, + private val rssiAverage: Int? = null, +) : PodDevice { + + override val model: PodDevice.Model = PodDevice.Model.UNKNOWN + + override fun getLabel(context: Context): String = context.getString(R.string.pods_unknown_label) + + override val rssi: Int + get() = rssiAverage ?: super.rssi + + companion object { + private val TAG = logTag("PodDevice", "Unknown") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/pods/core/unknown/UnknownDeviceFactory.kt b/app/src/main/java/eu/darken/capod/pods/core/unknown/UnknownDeviceFactory.kt new file mode 100644 index 00000000..5c78119b --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/unknown/UnknownDeviceFactory.kt @@ -0,0 +1,131 @@ +package eu.darken.capod.pods.core.unknown + +import eu.darken.capod.common.bluetooth.BleScanResult +import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.capod.common.debug.logging.log +import eu.darken.capod.common.debug.logging.logTag +import eu.darken.capod.pods.core.PodDevice +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.time.Duration +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class UnknownDeviceFactory @Inject constructor() { + + private val lock = Mutex() + + suspend fun create(scanResult: BleScanResult): PodDevice? = lock.withLock { + var basic = UnknownDevice( + identifier = PodDevice.Id(), + scanResult = scanResult, + ) + val result = searchHistory(basic) + + if (result != null) basic = basic.copy(identifier = result.id) + updateHistory(basic) + + if (result == null) return basic + + return basic.copy( + identifier = result.id, + seenFirstAt = result.seenFirstAt, + seenCounter = result.seenCounter, + confidence = result.confidence, + rssiAverage = result.averageRssi(basic.rssi), + ) + } + + data class KnownDevice( + val id: PodDevice.Id, + val seenFirstAt: Instant, + val seenCounter: Int, + val history: List + ) { + + val lastAddress: String + get() = history.last().address + + val confidence: Float + get() = 0.75f + (history.size / (MAX_HISTORY * 4f)) + + fun averageRssi(latest: Int): Int = + history.map { it.rssi }.plus(latest).takeLast(10).median() + + fun isOlderThan(age: Duration): Boolean { + val now = Instant.now() + return Duration.between(history.last().seenLastAt, now) > age + } + + private fun List.median(): Int = this.sorted().let { + if (it.size % 2 == 0) + (it[it.size / 2] + it[(it.size - 1) / 2]) / 2 + else + it[it.size / 2] + } + + override fun toString(): String = "KnownDevice(history=${history.size}, last=${history.last()})" + + companion object { + const val MAX_HISTORY = 10 + } + } + + private val knownDevices = mutableMapOf() + + private fun searchHistory(current: PodDevice): KnownDevice? { + val scanResult = current.scanResult + + knownDevices.values.toList().forEach { knownDevice -> + if (knownDevice.isOlderThan(Duration.ofSeconds(20))) { + log(TAG, VERBOSE) { "searchHistory1: Removing stale known device: $knownDevice" } + knownDevices.remove(knownDevice.id) + } + } + + knownDevices.values + .filter { it.history.size > KnownDevice.MAX_HISTORY } + .toList() + .forEach { + knownDevices[it.id] = it.copy(history = it.history.takeLast(KnownDevice.MAX_HISTORY)) + } + + val recognizedDevice: KnownDevice? = knownDevices.values + .firstOrNull { it.lastAddress == scanResult.address } + ?.also { log(TAG, VERBOSE) { "searchHistory1: Recovered previous ID via address: $it" } } + + if (recognizedDevice == null) { + log(TAG) { "searchHistory1: Didn't recognize: $current" } + } + + return recognizedDevice + } + + private fun updateHistory(device: PodDevice) { + val existing = knownDevices[device.identifier] + + knownDevices[device.identifier] = when { + existing != null -> { + existing.copy( + seenCounter = existing.seenCounter + 1, + history = existing.history.plus(device) + ) + } + else -> { + log(TAG) { "searchHistory1: Creating new history for $device" } + KnownDevice( + id = device.identifier, + seenFirstAt = device.seenFirstAt, + seenCounter = 1, + history = listOf(device) + ) + } + } + } + + companion object { + private val TAG = logTag("Pod", "Unknown", "Factory") + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/overview_pods_unknown_item.xml b/app/src/main/res/layout/overview_pods_unknown_item.xml index 6e3ea4a9..ed636d4e 100644 --- a/app/src/main/res/layout/overview_pods_unknown_item.xml +++ b/app/src/main/res/layout/overview_pods_unknown_item.xml @@ -70,7 +70,7 @@ tools:text="Yours (RSSI -61)" /> + app:layout_constraintTop_toBottomOf="@id/details" />