Refactoring of device creation, more autonomy.

This commit is contained in:
darken
2022-01-18 13:50:55 +01:00
parent 220c358f5d
commit 6a0b1ffdb1
18 changed files with 520 additions and 258 deletions
+2
View File
@@ -191,6 +191,8 @@ dependencies {
kapt "androidx.hilt:hilt-compiler:1.0.0"
implementation 'androidx.hilt:hilt-common:1.0.0'
kaptTest "androidx.hilt:hilt-compiler:1.0.0"
testImplementation 'androidx.hilt:hilt-common:1.0.0'
// Support libs
implementation 'androidx.core:core-ktx:1.7.0'
@@ -2,6 +2,8 @@ package eu.darken.capod.pods.core
import android.content.Context
import androidx.annotation.DrawableRes
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import java.time.Instant
@@ -12,6 +14,8 @@ interface PodDevice {
val identifier: Id
val model: Model
val lastSeenAt: Instant
val scanResult: BleScanResult
@@ -31,12 +35,60 @@ interface PodDevice {
val rawDataHex: String
get() = rawData.joinToString(separator = " ") { String.format("%02X", it) }
fun getLabel(context: Context): String
fun getLabel(context: Context): String = model.label
@get:DrawableRes
val iconRes: Int
get() = R.drawable.ic_device_generic_earbuds
get() = model.iconRes
@JvmInline
value class Id(private val id: UUID = UUID.randomUUID())
@JsonClass(generateAdapter = false)
enum class Model(
val label: String,
@DrawableRes val iconRes: Int = R.drawable.ic_device_generic_earbuds,
) {
@Json(name = "airpods.gen1") AIRPODS_GEN1(
label = "AirPods (Gen 1)",
iconRes = R.drawable.ic_device_airpods_gen1,
),
@Json(name = "airpods.gen2") AIRPODS_GEN2(
"AirPods (Gen 2)",
R.drawable.ic_device_airpods_gen2,
),
@Json(name = "airpods.gen3") AIRPODS_GEN3(
"AirPods (Gen 3)",
R.drawable.ic_device_airpods_gen2,
),
@Json(name = "airpods.pro") AIRPODS_PRO(
"AirPods Pro",
R.drawable.ic_device_airpods_gen2
),
@Json(name = "airpods.max") AIRPODS_MAX(
"AirPods Max",
R.drawable.ic_device_generic_headphones
),
@Json(name = "beats.flex") BEATS_FLEX(
"Beats Flex"
),
@Json(name = "beats.solo.3") BEATS_SOLO_3(
"Beats Solo 3"
),
@Json(name = "beats.studio.3") BEATS_STUDIO_3(
"Beats Studio 3"
),
@Json(name = "beats.x") BEATS_X(
"Beats X"
),
@Json(name = "beats.powerbeats.3") POWERBEATS_3(
"Power Beats 3"
),
@Json(name = "beats.powerbeats.pro") POWERBEATS_PRO(
"Power Beats Pro"
),
@Json(name = "unknown") UNKNOWN(
"Unknown"
);
}
}
@@ -1,13 +1,11 @@
package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.SystemClockWrap
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.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.airpods.*
import eu.darken.capod.pods.core.apple.beats.*
@@ -15,7 +13,6 @@ 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.time.Duration
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
@@ -24,38 +21,13 @@ import javax.inject.Singleton
class AppleFactory @Inject constructor(
private val continuityProtocolDecoder: ContinuityProtocol.Decoder,
private val proximityPairingDecoder: ProximityPairing.Decoder,
private val podFactories: @JvmSuppressWildcards Set<ApplePods.Factory>,
private val unknownAppleFactory: UnknownAppleDevice.Factory,
) {
data class KnownDevice(
val identifier: PodDevice.Id,
val scanResult: BleScanResult,
val message: ProximityPairing.Message,
) {
val address: String
get() = scanResult.address
val rssi: Int
get() = scanResult.rssi
val timestampNanos: Duration
get() = Duration.ofNanos(scanResult.generatedAtNanos)
fun isOlderThan(age: Duration): Boolean {
val now = Duration.ofNanos(SystemClockWrap.elapsedRealtimeNanos)
return now - timestampNanos > age
}
}
private val lock = Mutex()
private val knownDevs = mutableMapOf<PodDevice.Id, KnownDevice>()
data class ValueCache(
val caseBatteryPercentage: Float?
)
private val cachedValues = mutableMapOf<PodDevice.Id, ValueCache>()
private suspend fun getMessage(scanResult: BleScanResult): ProximityPairing.Message? {
private fun getMessage(scanResult: BleScanResult): ProximityPairing.Message? {
val messages = try {
continuityProtocolDecoder.decode(scanResult)
} catch (e: Exception) {
@@ -80,162 +52,29 @@ class AppleFactory @Inject constructor(
return proximityMessage
}
private suspend fun recognizeDevice(scanResult: BleScanResult, message: ProximityPairing.Message): PodDevice.Id {
val address = scanResult.address
var identifier: PodDevice.Id? = 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,
message = message,
)
identifier = it.identifier
}
if (identifier == null) {
val currentMarkers = message.getRecogMarkers()
knownDevs.values
.firstOrNull { it.message.getRecogMarkers() == currentMarkers }
?.let {
log(TAG, DEBUG) { "recognizeDevice: Close match based similarity markers." }
log(TAG, DEBUG) { "recognizeDevice: Old marker: ${it.message.getRecogMarkers()}" }
log(TAG, DEBUG) { "recognizeDevice: New marker: $currentMarkers" }
knownDevs[it.identifier] = it.copy(
scanResult = scanResult,
message = message,
)
identifier = it.identifier
}
}
if (identifier == null) {
log(TAG, VERBOSE) { "recognizeDevice: Mapping as new device" }
identifier = PodDevice.Id()
}
knownDevs[identifier!!] = KnownDevice(
identifier = identifier!!,
scanResult = scanResult,
message = message
)
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: BleScanResult): PodDevice? = lock.withLock {
val pm = getMessage(scanResult) ?: return@withLock null
val identifier = recognizeDevice(scanResult, pm)
log(TAG, INFO) { "Decoding $scanResult" }
return createSpecificDevice(scanResult, pm, identifier)
}
var factory = podFactories.firstOrNull { it.isResponsible(pm) }
private fun createSpecificDevice(
scanResult: BleScanResult,
pm: ProximityPairing.Message,
identifier: PodDevice.Id
): ApplePods {
val cachedCaseBattery = cachedValues[identifier]?.caseBatteryPercentage
val dm = (((pm.data[1].toInt() and 255) shl 8) or (pm.data[2].toInt() and 255)).toUShort()
val dmDirty = pm.data[1]
val specificDevice = when {
dm == AirPodsGen1.DEVICE_CODE -> AirPodsGen1(
identifier = identifier,
scanResult = scanResult,
proximityMessage = pm,
cachedBatteryPercentage = cachedCaseBattery,
if (factory == null) {
Bugs.report(
tag = TAG,
message = "Unknown proximity message type",
exception = IllegalArgumentException("Unknown ProximityMessage: $pm")
)
dm == AirPodsGen2.DEVICE_CODE -> AirPodsGen2(
identifier = identifier,
scanResult = scanResult,
proximityMessage = pm,
cachedBatteryPercentage = cachedCaseBattery,
)
dm == AirPodsGen3.DEVICE_CODE -> AirPodsGen3(
identifier = identifier,
scanResult = scanResult,
proximityMessage = pm,
cachedBatteryPercentage = cachedCaseBattery,
)
dm == AirPodsPro.DEVICE_CODE -> AirPodsPro(
identifier = identifier,
scanResult = scanResult,
proximityMessage = pm,
cachedBatteryPercentage = cachedCaseBattery,
)
dmDirty == PowerBeatsPro.DEVICE_CODE_DIRTY -> PowerBeatsPro(
identifier = identifier,
scanResult = scanResult,
proximityMessage = pm,
cachedBatteryPercentage = cachedCaseBattery,
)
dmDirty == AirPodsMax.DEVICE_CODE_DIRTY -> AirPodsMax(
identifier = identifier,
scanResult = scanResult,
proximityMessage = pm,
)
dm == PowerBeats3.DEVICE_CODE -> PowerBeats3(
identifier = identifier,
scanResult = scanResult,
proximityMessage = pm
)
dm == BeatsSolo3.DEVICE_CODE -> BeatsSolo3(
identifier = identifier,
scanResult = scanResult,
proximityMessage = pm
)
dmDirty == BeatsStudio3.DEVICE_CODE_DIRTY -> BeatsStudio3(
identifier = identifier,
scanResult = scanResult,
proximityMessage = pm
)
dm == BeatsX.DEVICE_CODE -> BeatsX(
identifier = identifier,
scanResult = scanResult,
proximityMessage = pm
)
dm == BeatsFlex.DEVICE_CODE -> BeatsFlex(
identifier = identifier,
scanResult = scanResult,
proximityMessage = pm
)
else -> {
Bugs.report(
tag = TAG,
message = "Unknown proximity message type",
exception = IllegalArgumentException("Unknown ProximityMessage: $pm")
)
UnknownAppleDevice(
identifier = identifier,
scanResult = scanResult,
proximityMessage = pm
)
}
factory = unknownAppleFactory
}
cachedValues[identifier] = ValueCache(
caseBatteryPercentage = (specificDevice as? HasCase)?.batteryCasePercent
return factory.create(
scanResult = scanResult,
proximityMessage = pm,
)
return specificDevice
}
companion object {
private val TAG = logTag("Pod", "AirPods", "Factory")
private val TAG = logTag("Pod", "Apple", "Factory")
}
}
@@ -0,0 +1,28 @@
package eu.darken.capod.pods.core.apple
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import eu.darken.capod.pods.core.apple.airpods.*
import eu.darken.capod.pods.core.apple.beats.*
@InstallIn(SingletonComponent::class)
@Module
abstract class AppleFactoryModule {
@Binds @IntoSet abstract fun airPodsGen1(factory: AirPodsGen1.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun airPodsGen2(factory: AirPodsGen2.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun airPodsGen3(factory: AirPodsGen3.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun airPodsPro(factory: AirPodsPro.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun beatsFlex(factory: BeatsFlex.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun beatsSolo3(factory: BeatsSolo3.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun beatsStudio3(factory: BeatsStudio3.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePods.Factory
}
@@ -1,8 +1,14 @@
package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.SystemClockWrap
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
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
import java.time.Duration
interface ApplePods : PodDevice {
@@ -35,4 +41,104 @@ interface ApplePods : PodDevice {
val rawSuffix: UByte
get() = proximityMessage.data[8]
abstract class Factory(private val tag: String) {
internal data class ValueCache(
val caseBatteryPercentage: Float?
)
internal val cachedValues = mutableMapOf<PodDevice.Id, ValueCache>()
internal data class KnownDevice(
val identifier: PodDevice.Id,
val scanResult: BleScanResult,
val message: ProximityPairing.Message,
) {
val address: String
get() = scanResult.address
val rssi: Int
get() = scanResult.rssi
val timestampNanos: Duration
get() = Duration.ofNanos(scanResult.generatedAtNanos)
fun isOlderThan(age: Duration): Boolean {
val now = Duration.ofNanos(SystemClockWrap.elapsedRealtimeNanos)
return now - timestampNanos > age
}
}
private val knownDevs = mutableMapOf<PodDevice.Id, KnownDevice>()
internal fun recognizeDevice(scanResult: BleScanResult, message: ProximityPairing.Message): PodDevice.Id {
val address = scanResult.address
var identifier: PodDevice.Id? = 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,
message = message,
)
identifier = it.identifier
}
if (identifier == null) {
val currentMarkers = message.getRecogMarkers()
knownDevs.values
.firstOrNull { it.message.getRecogMarkers() == currentMarkers }
?.let {
log(tag, DEBUG) { "recognizeDevice: Close match based similarity markers." }
log(tag, DEBUG) { "recognizeDevice: Old marker: ${it.message.getRecogMarkers()}" }
log(tag, DEBUG) { "recognizeDevice: New marker: $currentMarkers" }
knownDevs[it.identifier] = it.copy(
scanResult = scanResult,
message = message,
)
identifier = it.identifier
}
}
if (identifier == null) {
log(tag, VERBOSE) { "recognizeDevice: Mapping as new device" }
identifier = PodDevice.Id()
}
knownDevs[identifier!!] = KnownDevice(
identifier = identifier!!,
scanResult = scanResult,
message = message
)
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!!
}
data class ModelInfo(
val full: UShort,
val dirty: UByte,
)
fun ProximityPairing.Message.getModelInfo(): ModelInfo = ModelInfo(
full = (((data[1].toInt() and 255) shl 8) or (data[2].toInt() and 255)).toUShort(),
dirty = data[1]
)
abstract fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean
abstract fun create(
scanResult: BleScanResult,
proximityMessage: ProximityPairing.Message,
): ApplePods
}
}
@@ -3,9 +3,11 @@ package eu.darken.capod.pods.core.apple
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 eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
data class UnknownAppleDevice(
override val identifier: PodDevice.Id = PodDevice.Id(),
@@ -14,8 +16,28 @@ data class UnknownAppleDevice(
override val proximityMessage: ProximityPairing.Message
) : ApplePods {
override fun getLabel(context: Context): String {
return context.getString(R.string.pods_unknown_label)
override val model: PodDevice.Model = PodDevice.Model.UNKNOWN
override fun getLabel(context: Context): String = context.getString(R.string.pods_unknown_label)
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = true
override fun create(
scanResult: BleScanResult,
proximityMessage: ProximityPairing.Message,
): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage)
return UnknownAppleDevice(
identifier = identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
)
}
}
companion object {
private val TAG = logTag("PodDevice", "Apple", "Unknown")
}
}
@@ -1,12 +1,13 @@
package eu.darken.capod.pods.core.apple.airpods
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 eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
data class AirPodsGen1 constructor(
override val identifier: PodDevice.Id = PodDevice.Id(),
@@ -16,15 +17,36 @@ data class AirPodsGen1 constructor(
private val cachedBatteryPercentage: Float?
) : DualApplePods {
override fun getLabel(context: Context): String = "AirPods (Gen 1)"
override val iconRes: Int
get() = R.drawable.ic_device_airpods_gen1
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN1
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage)
val device = AirPodsGen1(
identifier = identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
cachedBatteryPercentage = cachedValues[identifier]?.caseBatteryPercentage
)
cachedValues[identifier] = ValueCache(
caseBatteryPercentage = device.batteryCasePercent
)
return device
}
}
companion object {
val DEVICE_CODE = 0x0220.toUShort()
private val DEVICE_CODE = 0x0220.toUShort()
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Gen1")
}
}
@@ -1,12 +1,13 @@
package eu.darken.capod.pods.core.apple.airpods
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 eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
data class AirPodsGen2 constructor(
override val identifier: PodDevice.Id = PodDevice.Id(),
@@ -16,15 +17,36 @@ data class AirPodsGen2 constructor(
private val cachedBatteryPercentage: Float?,
) : DualApplePods {
override fun getLabel(context: Context): String = "AirPods (Gen 2)"
override val iconRes: Int
get() = R.drawable.ic_device_airpods_gen2
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN2
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage)
val device = AirPodsGen2(
identifier = identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
cachedBatteryPercentage = cachedValues[identifier]?.caseBatteryPercentage
)
cachedValues[identifier] = ValueCache(
caseBatteryPercentage = device.batteryCasePercent
)
return device
}
}
companion object {
val DEVICE_CODE = 0x0F20.toUShort()
private val DEVICE_CODE = 0x0F20.toUShort()
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Gen2")
}
}
@@ -1,12 +1,13 @@
package eu.darken.capod.pods.core.apple.airpods
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 eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
data class AirPodsGen3 constructor(
override val identifier: PodDevice.Id = PodDevice.Id(),
@@ -15,16 +16,37 @@ data class AirPodsGen3 constructor(
override val proximityMessage: ProximityPairing.Message,
private val cachedBatteryPercentage: Float?,
) : DualApplePods {
override fun getLabel(context: Context): String = "AirPods (Gen 3)"
override val iconRes: Int
get() = R.drawable.ic_device_airpods_gen2
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN3
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage)
val device = AirPodsGen3(
identifier = identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
cachedBatteryPercentage = cachedValues[identifier]?.caseBatteryPercentage
)
cachedValues[identifier] = ValueCache(
caseBatteryPercentage = device.batteryCasePercent
)
return device
}
}
companion object {
val DEVICE_CODE = 0x1320.toUShort()
private val DEVICE_CODE = 0x1320.toUShort()
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Gen3")
}
}
@@ -1,12 +1,13 @@
package eu.darken.capod.pods.core.apple.airpods
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 eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
data class AirPodsMax(
override val identifier: PodDevice.Id = PodDevice.Id(),
@@ -15,12 +16,27 @@ data class AirPodsMax(
override val proximityMessage: ProximityPairing.Message,
) : SingleApplePods {
override fun getLabel(context: Context): String = "AirPods Max"
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_MAX
override val iconRes: Int
get() = R.drawable.ic_device_generic_headphones
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage)
return AirPodsMax(
identifier = identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
)
}
}
companion object {
val DEVICE_CODE_DIRTY = 10.toUByte()
private val DEVICE_CODE_DIRTY = 10.toUByte()
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Max")
}
}
@@ -1,12 +1,13 @@
package eu.darken.capod.pods.core.apple.airpods
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 eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
data class AirPodsPro(
override val identifier: PodDevice.Id = PodDevice.Id(),
@@ -16,17 +17,37 @@ data class AirPodsPro(
private val cachedBatteryPercentage: Float?,
) : DualApplePods {
override fun getLabel(context: Context): String {
return "AirPods Pro"
}
override val iconRes: Int
get() = R.drawable.ic_device_airpods_gen2
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage)
val device = AirPodsPro(
identifier = identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
cachedBatteryPercentage = cachedValues[identifier]?.caseBatteryPercentage
)
cachedValues[identifier] = ValueCache(
caseBatteryPercentage = device.batteryCasePercent
)
return device
}
}
companion object {
val DEVICE_CODE = 0x0e20.toUShort()
private val DEVICE_CODE = 0x0e20.toUShort()
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Pro")
}
}
@@ -1,12 +1,13 @@
package eu.darken.capod.pods.core.apple.beats
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 eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
data class BeatsFlex(
override val identifier: PodDevice.Id = PodDevice.Id(),
@@ -15,12 +16,27 @@ data class BeatsFlex(
override val proximityMessage: ProximityPairing.Message
) : BasicSingleApplePods {
override fun getLabel(context: Context): String = "Beats Flex"
override val model: PodDevice.Model = PodDevice.Model.BEATS_FLEX
override val iconRes: Int
get() = R.drawable.ic_device_generic_earbuds
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage)
return BeatsFlex(
identifier = identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
)
}
}
companion object {
val DEVICE_CODE = 0x1020.toUShort()
private val DEVICE_CODE = 0x1020.toUShort()
private val TAG = logTag("PodDevice", "Beats", "Flex")
}
}
@@ -1,12 +1,13 @@
package eu.darken.capod.pods.core.apple.beats
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 eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
data class BeatsSolo3(
override val identifier: PodDevice.Id = PodDevice.Id(),
@@ -15,12 +16,27 @@ data class BeatsSolo3(
override val proximityMessage: ProximityPairing.Message
) : BasicSingleApplePods {
override fun getLabel(context: Context): String = "Beats Solo 3"
override val model: PodDevice.Model = PodDevice.Model.BEATS_SOLO_3
override val iconRes: Int
get() = R.drawable.ic_device_generic_earbuds
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage)
return BeatsSolo3(
identifier = identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
)
}
}
companion object {
val DEVICE_CODE = 0x0620.toUShort()
private val DEVICE_CODE = 0x0620.toUShort()
private val TAG = logTag("PodDevice", "Beats", "Solo", "3")
}
}
@@ -1,12 +1,13 @@
package eu.darken.capod.pods.core.apple.beats
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 eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
data class BeatsStudio3(
override val identifier: PodDevice.Id = PodDevice.Id(),
@@ -15,12 +16,27 @@ data class BeatsStudio3(
override val proximityMessage: ProximityPairing.Message
) : BasicSingleApplePods {
override fun getLabel(context: Context): String = "Beats Studio 3"
override val model: PodDevice.Model = PodDevice.Model.BEATS_STUDIO_3
override val iconRes: Int
get() = R.drawable.ic_device_generic_earbuds
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage)
return BeatsStudio3(
identifier = identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
)
}
}
companion object {
val DEVICE_CODE_DIRTY = 9.toUByte()
private val DEVICE_CODE_DIRTY = 9.toUByte()
private val TAG = logTag("PodDevice", "Beats", "Studio", "3")
}
}
@@ -1,12 +1,13 @@
package eu.darken.capod.pods.core.apple.beats
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 eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
data class BeatsX(
override val identifier: PodDevice.Id = PodDevice.Id(),
@@ -15,12 +16,27 @@ data class BeatsX(
override val proximityMessage: ProximityPairing.Message
) : BasicSingleApplePods {
override fun getLabel(context: Context): String = "Beats X"
override val model: PodDevice.Model = PodDevice.Model.BEATS_X
override val iconRes: Int
get() = R.drawable.ic_device_generic_earbuds
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage)
return BeatsX(
identifier = identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
)
}
}
companion object {
val DEVICE_CODE = 0x0520.toUShort()
private val DEVICE_CODE = 0x0520.toUShort()
private val TAG = logTag("PodDevice", "Beats", "X")
}
}
@@ -1,12 +1,13 @@
package eu.darken.capod.pods.core.apple.beats
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 eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
data class PowerBeats3(
override val identifier: PodDevice.Id = PodDevice.Id(),
@@ -14,13 +15,26 @@ data class PowerBeats3(
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message
) : BasicSingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_3
override fun getLabel(context: Context): String = "Power Beats 3"
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val iconRes: Int
get() = R.drawable.ic_device_generic_earbuds
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage)
return PowerBeats3(
identifier = identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
)
}
}
companion object {
val DEVICE_CODE = 0x0320.toUShort()
private val DEVICE_CODE = 0x0320.toUShort()
private val TAG = logTag("PodDevice", "Beats", "PowerBeats", "3")
}
}
@@ -1,12 +1,13 @@
package eu.darken.capod.pods.core.apple.beats
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 eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
data class PowerBeatsPro(
override val identifier: PodDevice.Id = PodDevice.Id(),
@@ -16,15 +17,37 @@ data class PowerBeatsPro(
private val cachedBatteryPercentage: Float?,
) : DualApplePods {
override fun getLabel(context: Context): String = "Power Beats Pro"
override val iconRes: Int
get() = R.drawable.ic_device_generic_earbuds
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_PRO
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val identifier = recognizeDevice(scanResult, proximityMessage)
val device = PowerBeatsPro(
identifier = identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
cachedBatteryPercentage = cachedValues[identifier]?.caseBatteryPercentage
)
cachedValues[identifier] = ValueCache(
caseBatteryPercentage = device.batteryCasePercent
)
return device
}
}
companion object {
val DEVICE_CODE_DIRTY = 11.toUByte()
private val DEVICE_CODE_DIRTY = 11.toUByte()
private val TAG = logTag("PodDevice", "Beats", "PowerBeats", "Pro")
}
}
@@ -1,17 +1,29 @@
package eu.darken.capod.pods.core.apple
import dagger.Component
import eu.darken.capod.common.SystemClockWrap
import eu.darken.capod.common.bluetooth.BleScanResult
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
import io.mockk.MockKAnnotations
import io.mockk.every
import io.mockk.mockkObject
import org.junit.jupiter.api.BeforeEach
import testhelper.BaseTest
import javax.inject.Singleton
abstract class BaseAirPodsTest : BaseTest() {
@Singleton
@Component(modules = [AppleFactoryModule::class])
interface AppleFactoryTestComponent {
val appleFactory: AppleFactory
@Component.Factory
interface Factory {
fun create(): AppleFactoryTestComponent
}
}
private val baseBleScanResult = BleScanResult(
address = "77:49:4C:D8:25:0C",
@@ -20,10 +32,7 @@ abstract class BaseAirPodsTest : BaseTest() {
manufacturerSpecificData = emptyMap()
)
val factory = AppleFactory(
proximityPairingDecoder = ProximityPairing.Decoder(),
continuityProtocolDecoder = ContinuityProtocol.Decoder(),
)
val factory: AppleFactory = DaggerBaseAirPodsTest_AppleFactoryTestComponent.factory().create().appleFactory
@BeforeEach
fun setup() {