Support for more pods + tests

This commit is contained in:
darken
2022-01-03 23:02:54 +01:00
parent e23304d8d7
commit 840c1c1a50
46 changed files with 615 additions and 398 deletions
@@ -0,0 +1,9 @@
package eu.darken.capod.common
import android.os.SystemClock
object SystemClockWrap {
val elapsedRealtimeNanos: Long
get() = SystemClock.elapsedRealtimeNanos()
}
@@ -10,7 +10,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.debug.logging.Logging.Priority.*
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.airpods.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
@@ -18,7 +18,7 @@ import eu.darken.capod.main.ui.overview.cards.pods.UnknownPodDeviceCardVH
import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.monitor.core.worker.MonitorControl
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.airpods.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.*
import java.util.*
@@ -6,9 +6,9 @@ import eu.darken.capod.R
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.databinding.OverviewPodsAppleDualItemBinding
import eu.darken.capod.pods.core.DualPods
import eu.darken.capod.pods.core.airpods.DualApplePods
import eu.darken.capod.pods.core.airpods.DualApplePods.DeviceColor
import eu.darken.capod.pods.core.airpods.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.DeviceColor
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.getBatteryCase
import eu.darken.capod.pods.core.getBatteryLeftPod
import eu.darken.capod.pods.core.getBatteryRightPod
@@ -13,7 +13,7 @@ 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.monitor.core.worker.MonitorControl
import eu.darken.capod.pods.core.airpods.protocol.ContinuityProtocol
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -20,7 +20,7 @@ import eu.darken.capod.monitor.core.MonitorComponent
import eu.darken.capod.monitor.core.MonitorCoroutineScope
import eu.darken.capod.monitor.core.MonitorSettings
import eu.darken.capod.monitor.ui.MonitorNotifications
import eu.darken.capod.pods.core.airpods.protocol.ContinuityProtocol
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.delay
@@ -20,7 +20,7 @@ import eu.darken.capod.common.hasApiLevel
import eu.darken.capod.common.notifications.PendingIntentCompat
import eu.darken.capod.main.ui.MainActivity
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.airpods.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.getBatteryCase
import eu.darken.capod.pods.core.getBatteryLeftPod
import eu.darken.capod.pods.core.getBatteryRightPod
@@ -53,7 +53,7 @@ class MonitorNotifications @Inject constructor(
.setChannelId(NOTIFICATION_CHANNEL_ID)
.setContentIntent(openPi)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setSmallIcon(R.drawable.ic_baseline_earbuds_24)
.setSmallIcon(R.drawable.ic_device_generic_earbuds)
.setOngoing(true)
.setContentTitle(context.getString(R.string.app_name))
}
@@ -61,7 +61,7 @@ class MonitorNotifications @Inject constructor(
fun getBuilder(device: PodDevice?): NotificationCompat.Builder {
if (device == null) {
builder.setContentTitle(context.getString(R.string.device_status_loading_message))
builder.setSmallIcon(R.drawable.ic_baseline_earbuds_24)
builder.setSmallIcon(R.drawable.ic_device_generic_earbuds)
return builder
}
@@ -28,5 +28,5 @@ interface PodDevice {
@get:DrawableRes
val iconRes: Int
get() = R.drawable.ic_baseline_earbuds_24
get() = R.drawable.ic_device_generic_earbuds
}
@@ -6,18 +6,18 @@ 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.airpods.AirPodsFactory
import eu.darken.capod.pods.core.apple.AppleFactory
import javax.inject.Inject
@Reusable
class PodFactory @Inject constructor(
private val airPodsFactory: AirPodsFactory
private val appleFactory: AppleFactory
) {
suspend fun createPod(scanResult: ScanResult): PodDevice? {
log(TAG, VERBOSE) { "Trying to create Pod for $scanResult" }
val pod = airPodsFactory.create(scanResult)
val pod = appleFactory.create(scanResult)
if (pod != null) {
log(TAG) { "Pod created: $pod" }
return pod
@@ -1,15 +0,0 @@
package eu.darken.capod.pods.core.airpods
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.airpods.protocol.ProximityPairing
interface ApplePods : PodDevice {
val proximityMessage: ProximityPairing.Message
companion object {
val TAG = logTag("Pod", "AppleDevice")
}
}
@@ -1,188 +0,0 @@
package eu.darken.capod.pods.core.airpods
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.DualPods
import eu.darken.capod.pods.core.DualPods.Pod
interface SingleApplePods : ApplePods, DualPods {
val tag: String
// We start counting at the airpods prefix byte
val rawPrefix: UByte
get() = proximityMessage.data[0]
val rawDeviceModel: UShort
get() = (((proximityMessage.data[1].toInt() and 255) shl 8) or (proximityMessage.data[2].toInt() and 255)).toUShort()
val rawStatus: UByte
get() = proximityMessage.data[3]
val rawPodsBattery: UByte
get() = proximityMessage.data[4]
val rawCaseBattery: UByte
get() = proximityMessage.data[5]
val rawCaseLidState: UByte
get() = proximityMessage.data[6]
val rawDeviceColor: UByte
get() = proximityMessage.data[7]
val rawSuffix: UByte
get() = proximityMessage.data[8]
override val microPhonePod: Pod
get() = when (rawStatus.isBitSet(5)) {
true -> Pod.LEFT
false -> Pod.RIGHT
}
override val batteryLeftPodPercent: Float?
get() {
val value = when (microPhonePod) {
Pod.LEFT -> rawPodsBattery.lowerNibble.toInt()
Pod.RIGHT -> rawPodsBattery.upperNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log(tag) { "Left pod: Above 100% battery: $value" }
1.0f
} else {
(value / 10f)
}
}
}
override val batteryRightPodPercent: Float?
get() {
val value = when (microPhonePod) {
Pod.LEFT -> rawPodsBattery.upperNibble.toInt()
Pod.RIGHT -> rawPodsBattery.lowerNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log(tag) { "Right pod: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
}
override val batteryCasePercent: Float?
get() = when (val value = rawCaseBattery.lowerNibble.toInt()) {
15 -> null
else -> if (value > 10) {
log(tag) { "Case: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
override val isLeftPodInEar: Boolean
get() = when (microPhonePod) {
Pod.LEFT -> rawStatus.isBitSet(1)
Pod.RIGHT -> rawStatus.isBitSet(3)
}
override val isRightPodInEar: Boolean
get() = when (microPhonePod) {
Pod.LEFT -> rawStatus.isBitSet(3)
Pod.RIGHT -> rawStatus.isBitSet(1)
}
val status: Status
get() = Status.values().firstOrNull { it.raw == rawStatus } ?: Status.UNKNOWN
enum class Status(val raw: UByte?) {
BOTH_AIRPODS_IN_CASE(0x55),
LEFT_IN_EAR(0x33),
AIRPLANE(0x02),
UNKNOWN(null);
constructor(raw: Int) : this(raw.toUByte())
}
// 1010101 0x55 85 Both In Case
// 0101011 0x2b 43 Both In Ear
// 0101001 0x29 41 Right in Ear
// 0100011 0x23 35 Left In Ear
// 0100001 0x21 33 Neither in Ear or In Case
// 1110001 0x71 113 Left in Case, Right on Desk
// 0010001 0x11 17 Left in Case, Right on Desk
// 0010011 0x13 19 Left in Case, Right in Ear
// 1110011 0x73 115 Left in Case, Right in Ear
override val isCaseCharging: Boolean
get() = rawCaseBattery.upperNibble.isBitSet(2)
override val isLeftPodCharging: Boolean
get() = when (microPhonePod) {
Pod.LEFT -> rawCaseBattery.upperNibble.isBitSet(0)
Pod.RIGHT -> rawCaseBattery.upperNibble.isBitSet(1)
}
override val isRightPodCharging: Boolean
get() = when (microPhonePod) {
Pod.LEFT -> rawCaseBattery.upperNibble.isBitSet(1)
Pod.RIGHT -> rawCaseBattery.upperNibble.isBitSet(0)
}
val caseLidState: LidState
get() = LidState.values().firstOrNull { it.raw == rawCaseLidState } ?: LidState.UNKNOWN
enum class LidState(val raw: UByte?) {
OPEN(0x31),
CLOSED(0x38),
NOT_IN_CASE(0x01),
UNKNOWN(null);
constructor(raw: Int) : this(raw.toUByte())
}
val deviceColor: DeviceColor
get() = DeviceColor.values().firstOrNull { it.raw == rawDeviceColor } ?: DeviceColor.UNKNOWN
enum class DeviceColor(val raw: UByte?) {
WHITE(0x00),
BLACK(0x01),
RED(0x02),
BLUE(0x03),
PINK(0x04),
GRAY(0x05),
SILVER(0x06),
GOLD(0x07),
ROSE_GOLD(0x08),
SPACE_GRAY(0x09),
DARK_BLUE(0x0a),
LIGHT_BLUE(0x0b),
YELLOW(0x0c),
UNKNOWN(null);
constructor(raw: Int) : this(raw.toUByte())
}
val connectionState: ConnectionState
get() = ConnectionState.values().firstOrNull { rawSuffix == it.raw } ?: ConnectionState.UNKNOWN
enum class ConnectionState(val raw: UByte?) {
DISCONNECTED(0x00),
IDLE(0x04),
MUSIC(0x05),
CALL(0x06),
RINGING(0x07),
HANGING_UP(0x09),
UNKNOWN(null);
constructor(raw: Int) : this(raw.toUByte())
}
}
@@ -1,21 +0,0 @@
package eu.darken.capod.pods.core.airpods.models
import android.bluetooth.le.ScanResult
import android.content.Context
import eu.darken.capod.pods.core.airpods.ApplePods
import eu.darken.capod.pods.core.airpods.protocol.ProximityPairing
import java.time.Instant
import java.util.*
data class AirPodsMax constructor(
override val identifier: UUID = UUID.randomUUID(),
override val lastSeenAt: Instant = Instant.now(),
override val scanResult: ScanResult,
override val proximityMessage: ProximityPairing.Message
) : ApplePods {
override fun getLabel(context: Context): String {
return "AirPods Max"
}
}
@@ -1,19 +1,17 @@
package eu.darken.capod.pods.core.airpods
package eu.darken.capod.pods.core.apple
import android.bluetooth.le.ScanResult
import android.os.SystemClock
import eu.darken.capod.common.SystemClockWrap
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.PodDevice
import eu.darken.capod.pods.core.airpods.models.AirPodsGen1
import eu.darken.capod.pods.core.airpods.models.AirPodsGen2
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 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.time.Duration
@@ -22,7 +20,7 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AirPodsFactory @Inject constructor(
class AppleFactory @Inject constructor(
private val continuityProtocolDecoder: ContinuityProtocol.Decoder,
private val proximityPairingDecoder: ProximityPairing.Decoder
) {
@@ -41,7 +39,7 @@ class AirPodsFactory @Inject constructor(
get() = Duration.ofNanos(scanResult.timestampNanos)
fun isOlderThan(age: Duration): Boolean {
val now = Duration.ofNanos(SystemClock.elapsedRealtimeNanos())
val now = Duration.ofNanos(SystemClockWrap.elapsedRealtimeNanos)
return now - timestampNanos > age
}
}
@@ -139,42 +137,57 @@ class AirPodsFactory @Inject constructor(
val dmDirty = pm.data[1]
return when {
dm == 0x0220.toUShort() || dmDirty == 2.toUByte() -> AirPodsGen1(
dm == 0x0220.toUShort() -> AirPodsGen1(
identifier = identifier,
scanResult = scanResult,
proximityMessage = pm
)
dm == 0x0F20.toUShort() || dmDirty == 15.toUByte() -> AirPodsGen2(
dm == 0x0F20.toUShort() -> AirPodsGen2(
identifier = identifier,
scanResult = scanResult,
proximityMessage = pm
)
dm == 0x0e20.toUShort() || dmDirty == 14.toUByte() -> AirPodsPro(
dm == 0x0e20.toUShort() -> AirPodsPro(
identifier = identifier,
scanResult = scanResult,
proximityMessage = pm
)
// dmDirty == 10.toUByte() -> {
// TODO("Airpods Max")
// }
// dmDirty == 11.toUByte() -> {
// TODO("PowerBeatsPro")
// }
// dm == 0x0520.toUShort() || dmDirty == 5.toUByte() -> {
// TODO("BeatsX")
// }
// dmDirty == 0.toUByte() -> {
// TODO("BeatsFlex")
// }
// dm == 0x0620.toUShort() || dmDirty == 6.toUByte() -> {
// TODO("Beats Solo 3")
// }
// dmDirty == 9.toUByte() -> {
// TODO("Beats Studio 3")
// }
// dm == 0x0320.toUShort() || dmDirty == 3.toUByte() -> {
// TODO("Power Beats 3")
// }
dmDirty == 10.toUByte() -> AirPodsMax(
identifier = identifier,
scanResult = scanResult,
proximityMessage = pm
)
dm == 0x0320.toUShort() -> PowerBeats3(
identifier = identifier,
scanResult = scanResult,
proximityMessage = pm
)
dm == 0x0620.toUShort() -> BeatsSolo3(
identifier = identifier,
scanResult = scanResult,
proximityMessage = pm
)
dmDirty == 9.toUByte() -> BeatsStudio3(
identifier = identifier,
scanResult = scanResult,
proximityMessage = pm
)
dmDirty == 11.toUByte() -> PowerBeatsPro(
identifier = identifier,
scanResult = scanResult,
proximityMessage = pm
)
dm == 0x0520.toUShort() || dmDirty == 5.toUByte() -> BeatsX(
identifier = identifier,
scanResult = scanResult,
proximityMessage = pm
)
dm == 0x1020.toUShort() -> BeatsFlex(
identifier = identifier,
scanResult = scanResult,
proximityMessage = pm
)
else -> {
log(TAG, WARN) { "Unknown proximity message type" }
Bugs.report(IllegalArgumentException("Unknown ProximityMessage: $pm"))
@@ -0,0 +1,42 @@
package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
interface ApplePods : PodDevice {
val tag: String
val proximityMessage: ProximityPairing.Message
// We start counting at the airpods prefix byte
val rawPrefix: UByte
get() = proximityMessage.data[0]
val rawDeviceModel: UShort
get() = (((proximityMessage.data[1].toInt() and 255) shl 8) or (proximityMessage.data[2].toInt() and 255)).toUShort()
val rawStatus: UByte
get() = proximityMessage.data[3]
val rawPodsBattery: UByte
get() = proximityMessage.data[4]
val rawCaseBattery: UByte
get() = proximityMessage.data[5]
val rawCaseLidState: UByte
get() = proximityMessage.data[6]
val rawDeviceColor: UByte
get() = proximityMessage.data[7]
val rawSuffix: UByte
get() = proximityMessage.data[8]
companion object {
val TAG = logTag("Pod", "AppleDevice")
}
}
@@ -0,0 +1,18 @@
package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.lowerNibble
interface BasicSingleApplePods : ApplePods {
val batteryHeadsetPercent: Float?
get() = when (val value = rawPodsBattery.lowerNibble.toInt()) {
15 -> null
else -> if (value > 10) {
log(tag) { "Left pod: Above 100% battery: $value" }
1.0f
} else {
(value / 10f)
}
}
}
@@ -1,4 +1,4 @@
package eu.darken.capod.pods.core.airpods
package eu.darken.capod.pods.core.apple
import android.content.Context
import androidx.annotation.StringRes
@@ -12,33 +12,6 @@ import eu.darken.capod.pods.core.DualPods.Pod
interface DualApplePods : ApplePods, DualPods {
val tag: String
// We start counting at the airpods prefix byte
val rawPrefix: UByte
get() = proximityMessage.data[0]
val rawDeviceModel: UShort
get() = (((proximityMessage.data[1].toInt() and 255) shl 8) or (proximityMessage.data[2].toInt() and 255)).toUShort()
val rawStatus: UByte
get() = proximityMessage.data[3]
val rawPodsBattery: UByte
get() = proximityMessage.data[4]
val rawCaseBattery: UByte
get() = proximityMessage.data[5]
val rawCaseLidState: UByte
get() = proximityMessage.data[6]
val rawDeviceColor: UByte
get() = proximityMessage.data[7]
val rawSuffix: UByte
get() = proximityMessage.data[8]
override val microPhonePod: Pod
get() = when (rawStatus.isBitSet(5)) {
true -> Pod.LEFT
@@ -0,0 +1,29 @@
package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
interface SingleApplePods : BasicSingleApplePods {
val batteryCasePercent: Float?
get() = when (val value = rawCaseBattery.lowerNibble.toInt()) {
15 -> null
else -> if (value > 10) {
log(tag) { "Case: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
val isHeadsetBeingWorn: Boolean
get() = rawStatus.isBitSet(1)
val isCaseCharging: Boolean
get() = rawCaseBattery.upperNibble.isBitSet(2)
val isHeadsetBeingCharged: Boolean
get() = rawCaseBattery.upperNibble.isBitSet(0)
}
@@ -1,9 +1,10 @@
package eu.darken.capod.pods.core.airpods.models
package eu.darken.capod.pods.core.apple
import android.bluetooth.le.ScanResult
import android.content.Context
import eu.darken.capod.pods.core.airpods.ApplePods
import eu.darken.capod.pods.core.airpods.protocol.ProximityPairing
import eu.darken.capod.R
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import java.util.*
@@ -15,7 +16,8 @@ data class UnknownAppleDevice constructor(
) : ApplePods {
override fun getLabel(context: Context): String {
return "Unknown device"
return context.getString(R.string.device_unknown_label)
}
override val tag: String = logTag("Pod", "Apple", "Unknown")
}
@@ -1,11 +1,11 @@
package eu.darken.capod.pods.core.airpods.models
package eu.darken.capod.pods.core.apple.airpods
import android.bluetooth.le.ScanResult
import android.content.Context
import eu.darken.capod.R
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.airpods.DualApplePods
import eu.darken.capod.pods.core.airpods.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import java.util.*
@@ -16,12 +16,10 @@ data class AirPodsGen1 constructor(
override val proximityMessage: ProximityPairing.Message
) : DualApplePods {
override fun getLabel(context: Context): String {
return "AirPods (Gen 1)"
}
override fun getLabel(context: Context): String = "AirPods (Gen 1)"
override val iconRes: Int
get() = R.drawable.ic_device_airpods_gen1
override val tag: String = logTag("Pod", "AirPodsGen1")
override val tag: String = logTag("Pod", "Apple", "AirPods", "Gen1")
}
@@ -1,11 +1,11 @@
package eu.darken.capod.pods.core.airpods.models
package eu.darken.capod.pods.core.apple.airpods
import android.bluetooth.le.ScanResult
import android.content.Context
import eu.darken.capod.R
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.airpods.DualApplePods
import eu.darken.capod.pods.core.airpods.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import java.util.*
@@ -16,12 +16,10 @@ data class AirPodsGen2 constructor(
override val proximityMessage: ProximityPairing.Message
) : DualApplePods {
override fun getLabel(context: Context): String {
return "AirPods (Gen 2)"
}
override fun getLabel(context: Context): String = "AirPods (Gen 2)"
override val iconRes: Int
get() = R.drawable.ic_device_airpods_gen2
override val tag: String = logTag("Pod", "AirPodsGen2")
override val tag: String = logTag("Pod", "Apple", "AirPods", "Gen2")
}
@@ -0,0 +1,25 @@
package eu.darken.capod.pods.core.apple.airpods
import android.bluetooth.le.ScanResult
import android.content.Context
import eu.darken.capod.R
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import java.util.*
data class AirPodsMax constructor(
override val identifier: UUID = UUID.randomUUID(),
override val lastSeenAt: Instant = Instant.now(),
override val scanResult: ScanResult,
override val proximityMessage: ProximityPairing.Message
) : SingleApplePods {
override fun getLabel(context: Context): String = "AirPods Max"
override val iconRes: Int
get() = R.drawable.ic_device_generic_headphones
override val tag: String = logTag("Pod", "Apple", "AirPods", "Max")
}
@@ -1,10 +1,10 @@
package eu.darken.capod.pods.core.airpods.models
package eu.darken.capod.pods.core.apple.airpods
import android.bluetooth.le.ScanResult
import android.content.Context
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.airpods.DualApplePods
import eu.darken.capod.pods.core.airpods.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import java.util.*
@@ -19,5 +19,5 @@ data class AirPodsPro constructor(
return "AirPods Pro"
}
override val tag: String = logTag("Pod", "AirPodsPro")
override val tag: String = logTag("Pod", "Apple", "AirPods", "Pro")
}
@@ -0,0 +1,25 @@
package eu.darken.capod.pods.core.apple.beats
import android.bluetooth.le.ScanResult
import android.content.Context
import eu.darken.capod.R
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import java.util.*
data class BeatsFlex constructor(
override val identifier: UUID = UUID.randomUUID(),
override val lastSeenAt: Instant = Instant.now(),
override val scanResult: ScanResult,
override val proximityMessage: ProximityPairing.Message
) : BasicSingleApplePods {
override fun getLabel(context: Context): String = "Beats Flex"
override val iconRes: Int
get() = R.drawable.ic_device_generic_earbuds
override val tag: String = logTag("Pod", "Apple", "Beats", "Flex")
}
@@ -0,0 +1,25 @@
package eu.darken.capod.pods.core.apple.beats
import android.bluetooth.le.ScanResult
import android.content.Context
import eu.darken.capod.R
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import java.util.*
data class BeatsSolo3 constructor(
override val identifier: UUID = UUID.randomUUID(),
override val lastSeenAt: Instant = Instant.now(),
override val scanResult: ScanResult,
override val proximityMessage: ProximityPairing.Message
) : BasicSingleApplePods {
override fun getLabel(context: Context): String = "Beats Solo 3"
override val iconRes: Int
get() = R.drawable.ic_device_generic_earbuds
override val tag: String = logTag("Pod", "Apple", "Beats", "Solo3")
}
@@ -0,0 +1,25 @@
package eu.darken.capod.pods.core.apple.beats
import android.bluetooth.le.ScanResult
import android.content.Context
import eu.darken.capod.R
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import java.util.*
data class BeatsStudio3 constructor(
override val identifier: UUID = UUID.randomUUID(),
override val lastSeenAt: Instant = Instant.now(),
override val scanResult: ScanResult,
override val proximityMessage: ProximityPairing.Message
) : BasicSingleApplePods {
override fun getLabel(context: Context): String = "Beats Studio 3"
override val iconRes: Int
get() = R.drawable.ic_device_generic_earbuds
override val tag: String = logTag("Pod", "Apple", "Beats", "Studio3")
}
@@ -0,0 +1,25 @@
package eu.darken.capod.pods.core.apple.beats
import android.bluetooth.le.ScanResult
import android.content.Context
import eu.darken.capod.R
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import java.util.*
data class BeatsX constructor(
override val identifier: UUID = UUID.randomUUID(),
override val lastSeenAt: Instant = Instant.now(),
override val scanResult: ScanResult,
override val proximityMessage: ProximityPairing.Message
) : BasicSingleApplePods {
override fun getLabel(context: Context): String = "Beats X"
override val iconRes: Int
get() = R.drawable.ic_device_generic_earbuds
override val tag: String = logTag("Pod", "Apple", "Beats", "X")
}
@@ -0,0 +1,25 @@
package eu.darken.capod.pods.core.apple.beats
import android.bluetooth.le.ScanResult
import android.content.Context
import eu.darken.capod.R
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.apple.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import java.util.*
data class PowerBeats3 constructor(
override val identifier: UUID = UUID.randomUUID(),
override val lastSeenAt: Instant = Instant.now(),
override val scanResult: ScanResult,
override val proximityMessage: ProximityPairing.Message
) : BasicSingleApplePods {
override fun getLabel(context: Context): String = "Power Beats 3"
override val iconRes: Int
get() = R.drawable.ic_device_generic_earbuds
override val tag: String = logTag("Pod", "Apple", "Beats", "Power3")
}
@@ -0,0 +1,25 @@
package eu.darken.capod.pods.core.apple.beats
import android.bluetooth.le.ScanResult
import android.content.Context
import eu.darken.capod.R
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import java.util.*
data class PowerBeatsPro constructor(
override val identifier: UUID = UUID.randomUUID(),
override val lastSeenAt: Instant = Instant.now(),
override val scanResult: ScanResult,
override val proximityMessage: ProximityPairing.Message
) : DualApplePods {
override fun getLabel(context: Context): String = "Power Beats Pro"
override val iconRes: Int
get() = R.drawable.ic_device_generic_earbuds
override val tag: String = logTag("Pod", "Apple", "Beats", "PowerPro")
}
@@ -1,4 +1,4 @@
package eu.darken.capod.pods.core.airpods.protocol
package eu.darken.capod.pods.core.apple.protocol
import android.bluetooth.le.ScanResult
import android.os.ParcelUuid
@@ -1,9 +1,9 @@
package eu.darken.capod.pods.core.airpods.protocol
package eu.darken.capod.pods.core.apple.protocol
import android.bluetooth.le.ScanFilter
import dagger.Reusable
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.pods.core.airpods.ApplePods
import eu.darken.capod.pods.core.apple.ApplePods
import javax.inject.Inject
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,3c-4.97,0 -9,4.03 -9,9v7c0,1.1 0.9,2 2,2h4v-8H5v-1c0,-3.87 3.13,-7 7,-7s7,3.13 7,7v1h-4v8h4c1.1,0 2,-0.9 2,-2v-7C21,7.03 16.97,3 12,3z" />
</vector>
@@ -22,7 +22,7 @@
app:layout_constraintBottom_toBottomOf="@id/name"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/name"
app:srcCompat="@drawable/ic_baseline_earbuds_24" />
app:srcCompat="@drawable/ic_device_generic_earbuds" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/name"
+1
View File
@@ -81,4 +81,5 @@
<string name="settings_support_email_developer_description">Note that I can only respond in german or english.</string>
<string name="settings_debug_autoreports_label">Automatic bug reports</string>
<string name="settings_debug_autoreports_description">Automatically reports issues, e.g. details on an app crash so I can figure out how to fix it.</string>
<string name="device_unknown_label">Unknown device</string>
</resources>
@@ -1,70 +1,15 @@
package eu.darken.capod.pods.core.airpods
package eu.darken.capod.pods.core.apple
import android.bluetooth.BluetoothDevice
import android.bluetooth.le.ScanRecord
import android.bluetooth.le.ScanResult
import eu.darken.capod.pods.core.DualPods
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.airpods.models.AirPodsGen1
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 eu.darken.capod.pods.core.apple.airpods.AirPodsGen1
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.instanceOf
import io.mockk.MockKAnnotations
import io.mockk.every
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import testhelper.BaseTest
class AirPodsFactoryTest : BaseTest() {
@MockK lateinit var scanResult: ScanResult
@MockK lateinit var scanRecord: ScanRecord
@MockK lateinit var device: BluetoothDevice
val factory = AirPodsFactory(
proximityPairingDecoder = ProximityPairing.Decoder(),
continuityProtocolDecoder = ContinuityProtocol.Decoder(),
)
@BeforeEach
fun setup() {
MockKAnnotations.init(this)
every { scanResult.scanRecord } returns scanRecord
every { scanResult.rssi } returns -66
every { scanResult.timestampNanos } returns 136136027721826
every { scanResult.device } returns device
every { device.address } returns "77:49:4C:D8:25:0C"
}
private suspend inline fun <reified T : PodDevice?> create(hex: String, block: T.() -> Unit) {
val trimmed = hex
.replace(" ", "")
.replace(">", "")
.replace("<", "")
require(trimmed.length % 2 == 0) { "Not a HEX string" }
val bytes = trimmed.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
mockData(bytes)
block.invoke(factory.create(scanResult) as T)
}
private fun mockData(hex: String) {
val trimmed = hex
.replace(" ", "")
.replace(">", "")
.replace("<", "")
require(trimmed.length % 2 == 0) { "Not a HEX string" }
val bytes = trimmed.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
return mockData(bytes)
}
private fun mockData(data: ByteArray) {
every { scanRecord.getManufacturerSpecificData(ContinuityProtocol.APPLE_COMPANY_IDENTIFIER) } returns data
}
class AirPodsFactoryTest : BaseAirPodsTest() {
@Test
fun `test AirPodDevice - active microphone`() = runBlockingTest {
@@ -1,14 +1,16 @@
package eu.darken.capod.pods.core.airpods
package eu.darken.capod.pods.core.apple
import android.bluetooth.BluetoothDevice
import android.bluetooth.le.ScanRecord
import android.bluetooth.le.ScanResult
import eu.darken.capod.common.SystemClockWrap
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.airpods.protocol.ContinuityProtocol
import eu.darken.capod.pods.core.airpods.protocol.ProximityPairing
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.impl.annotations.MockK
import io.mockk.mockkObject
import org.junit.jupiter.api.BeforeEach
import testhelper.BaseTest
@@ -18,7 +20,7 @@ abstract class BaseAirPodsTest : BaseTest() {
@MockK lateinit var scanRecord: ScanRecord
@MockK lateinit var device: BluetoothDevice
val factory = AirPodsFactory(
val factory = AppleFactory(
proximityPairingDecoder = ProximityPairing.Decoder(),
continuityProtocolDecoder = ContinuityProtocol.Decoder(),
)
@@ -31,6 +33,9 @@ abstract class BaseAirPodsTest : BaseTest() {
every { scanResult.timestampNanos } returns 136136027721826
every { scanResult.device } returns device
every { device.address } returns "77:49:4C:D8:25:0C"
mockkObject(SystemClockWrap)
every { SystemClockWrap.elapsedRealtimeNanos } returns 1000L
}
suspend inline fun <reified T : PodDevice?> create(hex: String, block: T.() -> Unit) {
@@ -1,7 +1,7 @@
package eu.darken.capod.pods.core.airpods.models
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.airpods.BaseAirPodsTest
import eu.darken.capod.pods.core.airpods.DualApplePods
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.DualApplePods
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
@@ -1,7 +1,7 @@
package eu.darken.capod.pods.core.airpods.models
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.airpods.BaseAirPodsTest
import eu.darken.capod.pods.core.airpods.DualApplePods
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.DualApplePods
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
@@ -0,0 +1,55 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
class AirPodsMaxTest : BaseAirPodsTest() {
// Test data from https://github.com/adolfintel/OpenPods/issues/124
@Test
fun `default AirPods Max`() = runBlockingTest {
create<AirPodsMax>("07 19 01 0A 20 62 04 80 01 0F 40 0D 70 50 16 F2 40 83 16 BF 10 16 34 9B 74 84 E8") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0A20.toUShort()
rawStatus shouldBe 0x62.toUByte()
rawPodsBattery shouldBe 0x04.toUByte()
rawCaseBattery shouldBe 0x80.toUByte()
rawCaseLidState shouldBe 0x01.toUByte()
rawDeviceColor shouldBe 0x0F.toUByte()
rawSuffix shouldBe 0x40.toUByte()
batteryHeadsetPercent shouldBe 0.4f
isCaseCharging shouldBe false
isHeadsetBeingCharged shouldBe false
isHeadsetBeingWorn shouldBe true
batteryCasePercent shouldBe 0.0f
}
}
// Test data from https://github.com/adolfintel/OpenPods/issues/124
@Test
fun `default AirPods Max flipped values`() = runBlockingTest {
create<AirPodsMax>("07 19 01 0A 20 02 05 80 04 0F 44 A7 60 9B F8 3C FD B1 D8 1C 61 EA 82 60 A3 2C 4E") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0A20.toUShort()
rawStatus shouldBe 0x02.toUByte()
rawPodsBattery shouldBe 0x05.toUByte()
rawCaseBattery shouldBe 0x80.toUByte()
rawCaseLidState shouldBe 0x04.toUByte()
rawDeviceColor shouldBe 0x0F.toUByte()
rawSuffix shouldBe 0x44.toUByte()
batteryHeadsetPercent shouldBe 0.5f
isCaseCharging shouldBe false
isHeadsetBeingCharged shouldBe false
isHeadsetBeingWorn shouldBe true
batteryCasePercent shouldBe 0.0f
}
}
}
@@ -1,8 +1,8 @@
package eu.darken.capod.pods.core.airpods.models
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.DualPods
import eu.darken.capod.pods.core.airpods.BaseAirPodsTest
import eu.darken.capod.pods.core.airpods.DualApplePods
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.DualApplePods
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
@@ -0,0 +1,26 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
class BeatsFlexText : BaseAirPodsTest() {
// Raw data from https://github.com/adolfintel/OpenPods/issues/105
@Test
fun `default BeatsFlex`() = runBlockingTest {
create<BeatsFlex>("07 19 01 10 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x1020.toUShort()
rawStatus shouldBe 0x0A.toUByte()
rawPodsBattery shouldBe 0xF4.toUByte()
rawCaseBattery shouldBe 0x8F.toUByte()
rawCaseLidState shouldBe 0x00.toUByte()
rawDeviceColor shouldBe 0x01.toUByte()
rawSuffix shouldBe 0x00.toUByte()
batteryHeadsetPercent shouldBe 0.4f
}
}
}
@@ -0,0 +1,26 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
class BeatsSolo3Test : BaseAirPodsTest() {
// TODO This is handcrafted data, get actual data for tests
@Test
fun `default BeatsSolo3`() = runBlockingTest {
create<BeatsSolo3>("07 19 01 06 20 62 04 80 01 0F 40 0D 70 50 16 F2 40 83 16 BF 10 16 34 9B 74 84 E8") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0620.toUShort()
rawStatus shouldBe 0x62.toUByte()
rawPodsBattery shouldBe 0x04.toUByte()
rawCaseBattery shouldBe 0x80.toUByte()
rawCaseLidState shouldBe 0x01.toUByte()
rawDeviceColor shouldBe 0x0F.toUByte()
rawSuffix shouldBe 0x40.toUByte()
batteryHeadsetPercent shouldBe 0.4f
}
}
}
@@ -0,0 +1,26 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
class BeatsStudio3Test : BaseAirPodsTest() {
// TODO This is handcrafted data, get actual data for tests
@Test
fun `default BeatsStudio3`() = runBlockingTest {
create<BeatsStudio3>("07 19 01 09 20 62 04 80 01 0F 40 0D 70 50 16 F2 40 83 16 BF 10 16 34 9B 74 84 E8") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0920.toUShort()
rawStatus shouldBe 0x62.toUByte()
rawPodsBattery shouldBe 0x04.toUByte()
rawCaseBattery shouldBe 0x80.toUByte()
rawCaseLidState shouldBe 0x01.toUByte()
rawDeviceColor shouldBe 0x0F.toUByte()
rawSuffix shouldBe 0x40.toUByte()
batteryHeadsetPercent shouldBe 0.4f
}
}
}
@@ -0,0 +1,26 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
class BeatsXTest : BaseAirPodsTest() {
// Raw data from https://github.com/adolfintel/OpenPods/issues/105
@Test
fun `default BeatsX`() = runBlockingTest {
create<BeatsX>("07 19 01 05 20 00 F5 0F 01 01 00 6D CE C0 04 22 0A 85 31 2D 82 6B 42 80 01 20 1A") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0520.toUShort()
rawStatus shouldBe 0x00.toUByte()
rawPodsBattery shouldBe 0xF5.toUByte()
rawCaseBattery shouldBe 0x0F.toUByte()
rawCaseLidState shouldBe 0x01.toUByte()
rawDeviceColor shouldBe 0x01.toUByte()
rawSuffix shouldBe 0x00.toUByte()
batteryHeadsetPercent shouldBe 0.5f
}
}
}
@@ -0,0 +1,26 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
class PowerBeats3Test : BaseAirPodsTest() {
// TODO This is handcrafted data, get actual data for tests
@Test
fun `default PowerBeats3`() = runBlockingTest {
create<PowerBeats3>("07 19 01 03 20 62 04 80 01 0F 40 0D 70 50 16 F2 40 83 16 BF 10 16 34 9B 74 84 E8") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0320.toUShort()
rawStatus shouldBe 0x62.toUByte()
rawPodsBattery shouldBe 0x04.toUByte()
rawCaseBattery shouldBe 0x80.toUByte()
rawCaseLidState shouldBe 0x01.toUByte()
rawDeviceColor shouldBe 0x0F.toUByte()
rawSuffix shouldBe 0x40.toUByte()
batteryHeadsetPercent shouldBe 0.4f
}
}
}
@@ -0,0 +1,38 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.DualPods
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.DualApplePods
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
class PowerBeatsProTest : BaseAirPodsTest() {
// TODO This is handcrafted data, get actual data for tests
@Test
fun `test PowerBeatsPro`() = runBlockingTest {
create<PowerBeatsPro>("07 19 01 0B 20 54 AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0B20.toUShort()
rawStatus shouldBe 0x54.toUByte()
rawPodsBattery shouldBe 0xAA.toUByte()
rawCaseBattery shouldBe 0xB5.toUByte()
rawCaseLidState shouldBe 0x31.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x00.toUByte()
microPhonePod shouldBe DualPods.Pod.RIGHT
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
isCaseCharging shouldBe false
isRightPodCharging shouldBe true
isLeftPodCharging shouldBe true
batteryCasePercent shouldBe 0.5f
deviceColor shouldBe DualApplePods.DeviceColor.WHITE
}
}
}