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 61375fc5..fcea4145 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 @@ -2,13 +2,31 @@ package eu.darken.capod.pods.core import android.bluetooth.le.ScanResult import android.content.Context +import androidx.annotation.DrawableRes +import eu.darken.capod.R +import java.time.Instant +import java.util.* +import kotlin.math.abs interface PodDevice { + val identifier: UUID + val scanResult: ScanResult + val lastSeenAt: Instant + val rssi: Int get() = scanResult.rssi + fun getSignalQuality(context: Context): String { + val percentage = (100 - abs(rssi)) + return "~$percentage%" + } + fun getLabel(context: Context): String + + @get:DrawableRes + val iconRes: Int + get() = R.drawable.ic_baseline_earbuds_24 } \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/pods/core/airpods/AirPodsFactory.kt b/app/src/main/java/eu/darken/capod/pods/core/airpods/AirPodsFactory.kt index 22649562..fd762578 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/airpods/AirPodsFactory.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/airpods/AirPodsFactory.kt @@ -1,10 +1,9 @@ package eu.darken.capod.pods.core.airpods import android.bluetooth.le.ScanResult -import dagger.Reusable +import android.os.SystemClock import eu.darken.capod.common.debug.Bugs -import eu.darken.capod.common.debug.logging.Logging.Priority.INFO -import eu.darken.capod.common.debug.logging.Logging.Priority.WARN +import eu.darken.capod.common.debug.logging.Logging.Priority.* import eu.darken.capod.common.debug.logging.asLog import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag @@ -15,28 +14,42 @@ import eu.darken.capod.pods.core.airpods.models.AirPodsPro import eu.darken.capod.pods.core.airpods.models.UnknownAppleDevice import eu.darken.capod.pods.core.airpods.protocol.ContinuityProtocol import eu.darken.capod.pods.core.airpods.protocol.ProximityPairing +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.time.Duration +import java.util.* import javax.inject.Inject +import javax.inject.Singleton -@Reusable +@Singleton class AirPodsFactory @Inject constructor( private val continuityProtocolDecoder: ContinuityProtocol.Decoder, private val proximityPairingDecoder: ProximityPairing.Decoder ) { - data class Message( - // 0x07 ; AirPods message1 byte - // 25; Length 1byte - // 0x01 - val type: UByte, - val deviceModel: UShort, - val status: UByte, - val batteryIndicator: UShort, - val lidCount: UByte, - val deviceColor: UByte, - val encryptedData: UShort, - ) + data class KnownDevice( + val identifier: UUID, + val scanResult: ScanResult + ) { + val address: String + get() = scanResult.device.address - suspend fun create(scanResult: ScanResult): PodDevice? { + val rssi: Int + get() = scanResult.rssi + + val timestampNanos: Duration + get() = Duration.ofNanos(scanResult.timestampNanos) + + fun isOlderThan(age: Duration): Boolean { + val now = Duration.ofNanos(SystemClock.elapsedRealtimeNanos()) + return now - timestampNanos > age + } + } + + private val knownDevs = mutableMapOf() + private val mutex = Mutex() + + private suspend fun getMessage(scanResult: ScanResult): ProximityPairing.Message? { val messages = try { continuityProtocolDecoder.decode(scanResult) } catch (e: Exception) { @@ -58,6 +71,58 @@ class AirPodsFactory @Inject constructor( return null } + return proximityMessage + } + + private suspend fun recognizeDevice(scanResult: ScanResult): UUID { + val address = scanResult.device.address + + var identifier: UUID? = null + + knownDevs.values.firstOrNull { it.address == address }?.let { + log(TAG, VERBOSE) { "recognizeDevice: Recovered previous ID via address: $it" } + knownDevs[it.identifier] = it.copy(scanResult = scanResult) + identifier = it.identifier + } + + if (identifier == null) { + knownDevs.values + .firstOrNull { + it.rssi > -60 && !it.isOlderThan(Duration.ofSeconds(10)) + } + ?.let { + log(TAG, VERBOSE) { "recognizeDevice: Close match based on RSSI and timestamp." } + knownDevs[it.identifier] = it.copy(scanResult = scanResult) + identifier = it.identifier + } + } + + + if (identifier == null) { + log(TAG, VERBOSE) { "recognizeDevice: Mapping as new device" } + identifier = UUID.randomUUID() + } + + knownDevs[identifier!!] = KnownDevice( + identifier = identifier!!, + scanResult = scanResult + ) + + knownDevs.values.toList().forEach { knownDevice -> + if (knownDevice.isOlderThan(Duration.ofSeconds(20))) { + log(TAG, VERBOSE) { "recognizeDevice: Removing stale known device: $knownDevice" } + knownDevs.remove(knownDevice.identifier) + } + } + + return identifier!! + } + + suspend fun create(scanResult: ScanResult): PodDevice? = mutex.withLock { + val pm = getMessage(scanResult) ?: return@withLock null + + val identifier = recognizeDevice(scanResult) + log(TAG, INFO) { val data = scanResult.scanRecord!!.getManufacturerSpecificData( ContinuityProtocol.APPLE_COMPANY_IDENTIFIER @@ -69,14 +134,26 @@ class AirPodsFactory @Inject constructor( } val dm = ( - ((proximityMessage.data[1].toInt() and 255) shl 8) or (proximityMessage.data[2].toInt() and 255) + ((pm.data[1].toInt() and 255) shl 8) or (pm.data[2].toInt() and 255) ).toUShort() - val dmDirty = proximityMessage.data[1] + val dmDirty = pm.data[1] return when { - dm == 0x0220.toUShort() || dmDirty == 2.toUByte() -> AirPodsGen1(scanResult, proximityMessage) - dm == 0x0F20.toUShort() || dmDirty == 15.toUByte() -> AirPodsGen2(scanResult, proximityMessage) - dm == 0x0e20.toUShort() || dmDirty == 14.toUByte() -> AirPodsPro(scanResult, proximityMessage) + dm == 0x0220.toUShort() || dmDirty == 2.toUByte() -> AirPodsGen1( + identifier = identifier, + scanResult = scanResult, + proximityMessage = pm + ) + dm == 0x0F20.toUShort() || dmDirty == 15.toUByte() -> AirPodsGen2( + identifier = identifier, + scanResult = scanResult, + proximityMessage = pm + ) + dm == 0x0e20.toUShort() || dmDirty == 14.toUByte() -> AirPodsPro( + identifier = identifier, + scanResult = scanResult, + proximityMessage = pm + ) // dmDirty == 10.toUByte() -> { // TODO("Airpods Max") // } @@ -100,8 +177,12 @@ class AirPodsFactory @Inject constructor( // } else -> { log(TAG, WARN) { "Unknown proximity message type" } - Bugs.report(IllegalArgumentException("Unknown ProximityMessage: $proximityMessage")) - UnknownAppleDevice(scanResult, proximityMessage) + Bugs.report(IllegalArgumentException("Unknown ProximityMessage: $pm")) + UnknownAppleDevice( + identifier = identifier, + scanResult = scanResult, + proximityMessage = pm + ) } } }