POC, decoding works.

This commit is contained in:
darken
2021-12-28 17:11:50 +01:00
parent 19ab34dcd7
commit 443af25fce
160 changed files with 6875 additions and 1 deletions
@@ -0,0 +1,351 @@
package eu.darken.cap.common.flow
import eu.darken.cap.common.collections.mutate
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.instanceOf
import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.TestCoroutineScope
import org.junit.jupiter.api.Test
import testhelper.BaseTest
import testhelper.coroutine.runBlockingTest2
import testhelper.flow.test
import java.io.IOException
import java.lang.Thread.sleep
import kotlin.concurrent.thread
class DynamicStateFlowTest : BaseTest() {
// Without an init value, there isn't a way to keep using the flow
@Test
fun `exceptions on initialization are rethrown`() {
val testScope = TestCoroutineScope()
val hotData = DynamicStateFlow<String>(
loggingTag = "tag",
parentScope = testScope,
coroutineContext = Dispatchers.Unconfined,
startValueProvider = { throw IOException() }
)
runBlocking {
withTimeoutOrNull(500) {
// This blocking scope gets the init exception as the first caller
hotData.flow.firstOrNull()
} shouldBe null
}
testScope.advanceUntilIdle()
testScope.uncaughtExceptions.single() shouldBe instanceOf(IOException::class)
}
@Test
fun `subscription doesn't end when no subscriber is collecting, mode Lazily`() {
val testScope = TestCoroutineScope()
val valueProvider = mockk<suspend CoroutineScope.() -> String>()
coEvery { valueProvider.invoke(any()) } returns "Test"
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = testScope,
coroutineContext = Dispatchers.Unconfined,
startValueProvider = valueProvider,
)
testScope.apply {
runBlockingTest2(allowUncompleted = true) {
hotData.flow.first() shouldBe "Test"
hotData.flow.first() shouldBe "Test"
}
coVerify(exactly = 1) { valueProvider.invoke(any()) }
}
}
@Test
fun `value updates`() {
val testScope = TestCoroutineScope()
val valueProvider = mockk<suspend CoroutineScope.() -> Long>()
coEvery { valueProvider.invoke(any()) } returns 1
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = testScope,
startValueProvider = valueProvider,
)
val testCollector = hotData.flow.test(startOnScope = testScope)
testCollector.silent = true
(1..16).forEach { _ ->
thread {
(1..200).forEach { _ ->
sleep(10)
hotData.updateAsync(
onUpdate = { this + 1L },
onError = { throw it }
)
}
}
}
runBlocking {
testCollector.await { list, _ -> list.size == 3201 }
testCollector.latestValues shouldBe (1..3201).toList()
}
coVerify(exactly = 1) { valueProvider.invoke(any()) }
}
data class TestData(
val number: Long = 1
)
@Test
fun `check multi threading value updates with more complex data`() {
val testScope = TestCoroutineScope()
val valueProvider = mockk<suspend CoroutineScope.() -> Map<String, TestData>>()
coEvery { valueProvider.invoke(any()) } returns mapOf("data" to TestData())
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = testScope,
startValueProvider = valueProvider,
)
val testCollector = hotData.flow.test(startOnScope = testScope)
testCollector.silent = true
(1..10).forEach { _ ->
thread {
(1..400).forEach { _ ->
hotData.updateAsync {
mutate {
this["data"] = getValue("data").copy(
number = getValue("data").number + 1
)
}
}
}
}
}
runBlocking {
testCollector.await { list, _ -> list.size == 4001 }
testCollector.latestValues.map { it.values.single().number } shouldBe (1L..4001L).toList()
}
coVerify(exactly = 1) { valueProvider.invoke(any()) }
}
@Test
fun `only emit new values if they actually changed updates`() {
val testScope = TestCoroutineScope()
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = testScope,
startValueProvider = { "1" },
)
val testCollector = hotData.flow.test(startOnScope = testScope)
testCollector.silent = true
hotData.updateAsync { "1" }
hotData.updateAsync { "2" }
hotData.updateAsync { "2" }
hotData.updateAsync { "1" }
runBlocking {
testCollector.await { list, _ -> list.size == 3 }
testCollector.latestValues shouldBe listOf("1", "2", "1")
}
}
@Test
fun `multiple subscribers share the flow`() {
val testScope = TestCoroutineScope()
val valueProvider = mockk<suspend CoroutineScope.() -> String>()
coEvery { valueProvider.invoke(any()) } returns "Test"
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = testScope,
startValueProvider = valueProvider,
)
testScope.runBlockingTest2(allowUncompleted = true) {
val sub1 = hotData.flow.test(tag = "sub1", startOnScope = this)
val sub2 = hotData.flow.test(tag = "sub2", startOnScope = this)
val sub3 = hotData.flow.test(tag = "sub3", startOnScope = this)
hotData.updateAsync { "A" }
hotData.updateAsync { "B" }
hotData.updateAsync { "C" }
listOf(sub1, sub2, sub3).forEach {
it.await { list, _ -> list.size == 4 }
it.latestValues shouldBe listOf("Test", "A", "B", "C")
it.cancel()
}
hotData.flow.first() shouldBe "C"
}
coVerify(exactly = 1) { valueProvider.invoke(any()) }
}
@Test
fun `value is persisted between unsubscribes`() = runBlockingTest2(allowUncompleted = true) {
val valueProvider = mockk<suspend CoroutineScope.() -> Long>()
coEvery { valueProvider.invoke(any()) } returns 1
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = this,
coroutineContext = this.coroutineContext,
startValueProvider = valueProvider,
)
val testCollector1 = hotData.flow.test(tag = "collector1", startOnScope = this)
testCollector1.silent = false
(1..10).forEach { _ ->
hotData.updateAsync {
this + 1L
}
}
advanceUntilIdle()
testCollector1.await { list, _ -> list.size == 11 }
testCollector1.latestValues shouldBe (1L..11L).toList()
testCollector1.cancel()
testCollector1.awaitFinal()
val testCollector2 = hotData.flow.test(tag = "collector2", startOnScope = this)
testCollector2.silent = false
advanceUntilIdle()
testCollector2.cancel()
testCollector2.awaitFinal()
testCollector2.latestValues shouldBe listOf(11L)
coVerify(exactly = 1) { valueProvider.invoke(any()) }
}
@Test
fun `blocking update is actually blocking`() = runBlocking {
val testScope = TestCoroutineScope()
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = testScope,
coroutineContext = testScope.coroutineContext,
startValueProvider = {
delay(2000)
2
},
)
hotData.updateAsync {
delay(2000)
this + 1
}
val testCollector = hotData.flow.test(startOnScope = testScope)
testScope.advanceUntilIdle()
hotData.updateBlocking { this - 3 } shouldBe 0
testCollector.await { _, i -> i == 3 }
testCollector.latestValues shouldBe listOf(2, 3, 0)
testCollector.cancel()
}
@Test
fun `blocking update rethrows error`() = runBlocking {
val testScope = TestCoroutineScope()
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = testScope,
coroutineContext = testScope.coroutineContext,
startValueProvider = {
delay(2000)
2
},
)
val testCollector = hotData.flow.test(startOnScope = testScope)
testScope.advanceUntilIdle()
shouldThrow<IOException> {
hotData.updateBlocking { throw IOException("Surprise") } shouldBe 0
}
hotData.flow.first() shouldBe 2
hotData.updateBlocking { 3 } shouldBe 3
hotData.flow.first() shouldBe 3
testScope.uncaughtExceptions.singleOrNull() shouldBe null
testCollector.cancel()
}
@Test
fun `async updates error handler`() {
val testScope = TestCoroutineScope()
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = testScope,
startValueProvider = { 1 },
)
val testCollector = hotData.flow.test(startOnScope = testScope)
testScope.advanceUntilIdle()
hotData.updateAsync { throw IOException("Surprise") }
testScope.advanceUntilIdle()
testScope.uncaughtExceptions.single() shouldBe instanceOf(IOException::class)
testCollector.cancel()
}
@Test
fun `async updates rethrow errors on HotDataFlow scope if no error handler is set`() = runBlocking {
val testScope = TestCoroutineScope()
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = testScope,
startValueProvider = { 1 },
)
val testCollector = hotData.flow.test(startOnScope = testScope)
testScope.advanceUntilIdle()
var thrownError: Exception? = null
hotData.updateAsync(
onUpdate = { throw IOException("Surprise") },
onError = { thrownError = it }
)
testScope.advanceUntilIdle()
thrownError!!.shouldBeInstanceOf<IOException>()
testScope.uncaughtExceptions.singleOrNull() shouldBe null
testCollector.cancel()
}
}
@@ -0,0 +1,290 @@
package eu.darken.cap.pods.core.airpods
import android.bluetooth.BluetoothDevice
import android.bluetooth.le.ScanRecord
import android.bluetooth.le.ScanResult
import eu.darken.cap.pods.core.PodDevice
import eu.darken.cap.pods.core.airpods.models.AirPodsGen1
import eu.darken.cap.pods.core.airpods.models.AirPodsPro
import eu.darken.cap.pods.core.airpods.models.UnknownAppleDevice
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
}
@Test
fun `test AirPodDevice - active microphone`() = runBlockingTest {
create<AirPodsDevice>("07 19 01 0E 20 >2B< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00101011
// --^-----
microPhonePod shouldBe AirPodsDevice.Pod.LEFT
}
create<AirPodsDevice>("07 19 01 0E 20 >0B< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00001011
// --^-----
microPhonePod shouldBe AirPodsDevice.Pod.RIGHT
}
}
@Test
fun `test AirPodDevice - left pod ear status`() = runBlockingTest {
// Left Pod primary
create<AirPodsDevice>("07 19 01 0E 20 >22< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00100010
// 765432¹0
isLeftPodInEar shouldBe true
}
create<AirPodsDevice>("07 19 01 0E 20 >20< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00100000
// 765432¹0
isLeftPodInEar shouldBe false
}
// Right Pod is primary
create<AirPodsDevice>("07 19 01 0E 20 >09< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00001001
// 7654³210
isLeftPodInEar shouldBe true
}
create<AirPodsDevice>("07 19 01 0E 20 >20< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00000001
// 7654³210
isLeftPodInEar shouldBe false
}
}
@Test
fun `test AirPodDevice - right pod ear status`() = runBlockingTest {
// Left Pod primary
create<AirPodsDevice>("07 19 01 0E 20 >29< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00101001
// 7654³210
isRightPodInEar shouldBe true
}
create<AirPodsDevice>("07 19 01 0E 20 >21< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00100001
// 7654³210
isRightPodInEar shouldBe false
}
// Right Pod is primary
create<AirPodsDevice>("07 19 01 0E 20 >03< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00000011
// 765432¹0
isRightPodInEar shouldBe true
}
create<AirPodsDevice>("07 19 01 0E 20 >01< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00000001
// 765432¹0
isRightPodInEar shouldBe false
}
}
@Test
fun `test AirPodDevice - battery status`() = runBlockingTest {
// Right Pod is primary
create<AirPodsDevice>("07 19 01 0E 20 0B >98< 94 52 00 05 09 73 3C 3D F9 2C 3E B3 DD 76 02 DD 4E 16 FD FB") {
// 88 10001000
batteryLeftPodPercent shouldBe 0.9f
batteryRightPodPercent shouldBe 0.8f
}
// Left Pod primary
create<AirPodsDevice>("07 19 01 0E 20 2B >89< 94 52 00 05 09 73 3C 3D F9 2C 3E B3 DD 76 02 DD 4E 16 FD FB") {
// F8 11111000
batteryLeftPodPercent shouldBe 0.9f
batteryRightPodPercent shouldBe 0.8f
}
}
@Test
fun `test AirPodDevice - pod charging`() = runBlockingTest {
/**
* Right pod is charging
*/
// This is the left
create<AirPodsDevice>("07 19 01 0E 20 51 89 >94< 52 00 00 F4 89 82 6D 3E 27 7F 26 62 57 D0 E2 A6 49 E9 35") {
// 1001 0100
isLeftPodCharging shouldBe false
isRightPodCharging shouldBe true
}
// This is the right
create<AirPodsDevice>("07 19 01 0E 20 31 98 >A4< 01 00 00 31 B9 A0 C4 80 CD D1 CF B9 3A 9A 6D 48 31 08 EB") {
// 1010 0100
isLeftPodCharging shouldBe false
isRightPodCharging shouldBe true
}
/**
* Left pod is charging
*/
// This is the left
create<AirPodsDevice>("07 19 01 0E 20 71 98 >94< 52 00 05 A5 37 31 B2 BD 42 68 0C 64 FD 00 99 4A E5 3E F4") {
// 1001 0100
isLeftPodCharging shouldBe true
isRightPodCharging shouldBe false
}
// This is the right
create<AirPodsDevice>("07 19 01 0E 20 11 89 >A4< 04 00 04 BA 79 1B C0 65 69 C6 9F 19 6E 37 7D 6D 86 8D D9") {
// 1010 0100
isLeftPodCharging shouldBe true
isRightPodCharging shouldBe false
}
// Both charging
create<AirPodsDevice>("07 19 01 0E 20 55 88 >B4< 59 00 05 4B FC DF 68 28 A5 45 52 65 9C FE 51 86 3A B5 DB") {
// 1011 0100
isLeftPodCharging shouldBe true
isRightPodCharging shouldBe true
}
// Both not charging
create<AirPodsDevice>("07 19 01 0E 20 00 F8 >8F< 03 00 05 4C 0F A0 C4 05 24 DD EB AF 92 99 FD 54 B1 06 48") {
// 1000 1111
isLeftPodCharging shouldBe false
isRightPodCharging shouldBe false
}
}
@Test
fun `test AirPodDevice - case charging`() = runBlockingTest {
create<AirPodsDevice>("07 19 01 0E 20 75 99 >B4< 31 00 05 77 C8 BA 0C 4E 1F BE AD 70 C5 40 71 D2 E9 17 A2") {
// 0011 0011
isCaseCharging shouldBe false
}
create<AirPodsDevice>("07 19 01 0E 20 75 A9 >F4< 51 00 05 A0 37 92 35 49 79 CC DC 27 94 8E FB 72 12 94 52") {
// 0101 0011
isCaseCharging shouldBe true
}
}
@Test
fun `test AirPodDevice - case lid test`() = runBlockingTest {
// Lid open
create<AirPodsDevice>("07 19 01 0E 20 55 AA B4 >31< 00 00 A1 D0 BD 82 D3 52 86 CA FC 11 62 DC 42 C6 92 8E") {
// 31 0011 0001
caseLidState shouldBe AirPodsDevice.LidState.OPEN
}
// Lid just closed
create<AirPodsDevice>("07 19 01 0E 20 55 AA B4 >39< 00 00 08 A6 DB 99 E0 5E 14 85 E5 C2 0B 68 D7 FF C3 A1") {
// 39 0011 1001
caseLidState shouldBe AirPodsDevice.LidState.UNKNOWN
}
// Lid closed
create<AirPodsDevice>("07 19 01 0E 20 55 AA B4 38 00 00 F3 F7 08 3B 98 09 C0 DD E4 BD BD 84 55 56 8B 81") {
// 38 0011 1000
caseLidState shouldBe AirPodsDevice.LidState.CLOSED
}
}
@Test
fun `test AirPodDevice - connection state`() = runBlockingTest {
// Disconnected
create<AirPodsDevice>("07 19 01 0E 20 2B AA 8F 01 00 >00< 62 D4 BB F1 A7 F8 64 98 D2 C8 BD 7B 3A EF 2E 15") {
// 31 0011 0001
connectionState shouldBe AirPodsDevice.ConnectionState.DISCONNECTED
}
// Connected idle
create<AirPodsDevice>("07 19 01 0E 20 2B AA 8F 01 00 >04< 1D 69 69 9C C2 51 F3 1F BF 6E 45 DA 90 4A A3 E3") {
// 39 0011 1001
connectionState shouldBe AirPodsDevice.ConnectionState.IDLE
}
// Connected and playing music
create<AirPodsDevice>("07 19 01 0E 20 2B A9 8F 01 00 >05< 14 F7 CB 49 9F D3 B3 22 77 D2 22 F1 74 8C AC A6") {
// 38 0011 1000
connectionState shouldBe AirPodsDevice.ConnectionState.MUSIC
}
// Connected and call active
create<AirPodsDevice>("07 19 01 0E 20 2B 99 8F 01 00 >06< 0F 4B 43 25 E0 4A 73 63 14 22 C2 3C 89 13 BD 97") {
// 38 0011 1000
connectionState shouldBe AirPodsDevice.ConnectionState.CALL
}
// Connected and call active
create<AirPodsDevice>("07 19 01 0E 20 2B 99 8F 01 00 >07< E7 DF 76 44 85 B5 30 F4 95 14 02 DC A1 A4 8A 09") {
// 38 0011 1000
connectionState shouldBe AirPodsDevice.ConnectionState.RINGING
}
// Switching?
create<AirPodsDevice>("07 19 01 0E 20 2B 99 8F 01 00 >09< 10 30 EE F3 41 B5 D8 9F A3 B0 B4 17 9F 85 97 5F") {
// 38 0011 1000
connectionState shouldBe AirPodsDevice.ConnectionState.HANGING_UP
}
}
@Test
fun `create AirPodsGen1`() = runBlockingTest {
create<AirPodsDevice>("07 19 01 02 20 55 AA 56 31 00 00 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88") {
this shouldBe instanceOf<AirPodsGen1>()
}
}
@Test
fun `create AirPodsPro`() = runBlockingTest {
create<AirPodsDevice>("07 19 01 0E 20 2B 99 8F 01 00 >09< 10 30 EE F3 41 B5 D8 9F A3 B0 B4 17 9F 85 97 5F") {
this shouldBe instanceOf<AirPodsPro>()
}
}
@Test
fun `unknown AppleDevice`() = runBlockingTest {
create<ApplePods>("07 19 01 FF FF 2B 99 8F 01 00 >09< 10 30 EE F3 41 B5 D8 9F A3 B0 B4 17 9F 85 97 5F") {
this shouldBe instanceOf<UnknownAppleDevice>()
}
}
@Test
fun `invalid data`() = runBlockingTest {
create<PodDevice?>("abcd") {
this shouldBe null
}
}
}
@@ -0,0 +1,58 @@
package eu.darken.cap.pods.core.airpods
import android.bluetooth.BluetoothDevice
import android.bluetooth.le.ScanRecord
import android.bluetooth.le.ScanResult
import eu.darken.cap.pods.core.PodDevice
import io.mockk.MockKAnnotations
import io.mockk.every
import io.mockk.impl.annotations.MockK
import org.junit.jupiter.api.BeforeEach
import testhelper.BaseTest
abstract class BaseAirPodsTest : 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"
}
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)
}
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)
}
fun mockData(data: ByteArray) {
every { scanRecord.getManufacturerSpecificData(ContinuityProtocol.APPLE_COMPANY_IDENTIFIER) } returns data
}
}
@@ -0,0 +1,42 @@
package eu.darken.cap.pods.core.airpods.models
import eu.darken.cap.pods.core.airpods.AirPodsDevice
import eu.darken.cap.pods.core.airpods.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
class AirPodsGen1Test : BaseAirPodsTest() {
// Test data from https://github.com/adolfintel/OpenPods/issues/39#issuecomment-557664269
@Test
fun `fake airpods`() = runBlockingTest {
create<AirPodsGen1>("07 19 01 02 20 55 AF 56 31 00 00 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0220.toUShort()
rawStatus shouldBe 0x55.toUByte()
rawPodsBattery shouldBe 0xAF.toUByte()
rawCaseBattery shouldBe 0x56.toUByte()
rawCaseLidState shouldBe 0x31.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x00.toUByte()
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe null
isCaseCharging shouldBe true
isLeftPodCharging shouldBe false
isRightPodCharging shouldBe true
isLeftPodInEar shouldBe false
isRightPodInEar shouldBe false
batteryCasePercent shouldBe 0.6f
caseLidState shouldBe AirPodsDevice.LidState.OPEN
connectionState shouldBe AirPodsDevice.ConnectionState.DISCONNECTED
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
}
}
}
@@ -0,0 +1,41 @@
package eu.darken.cap.pods.core.airpods.models
import eu.darken.cap.pods.core.airpods.AirPodsDevice
import eu.darken.cap.pods.core.airpods.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
class AirPodsGen2Test : BaseAirPodsTest() {
@Test
fun `random Neighbor AirPodsGen2`() = runBlockingTest {
create<AirPodsGen2>("07 19 01 0F 20 02 F9 8F 01 00 05 F2 7E 14 E0 54 0A 53 69 5B 7D F2 15 1F D7 B1 12") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0F20.toUShort()
rawStatus shouldBe 0x02.toUByte()
rawPodsBattery shouldBe 0xF9.toUByte()
rawCaseBattery shouldBe 0x8F.toUByte()
rawCaseLidState shouldBe 0x01.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x05.toUByte()
batteryLeftPodPercent shouldBe null
batteryRightPodPercent shouldBe 0.9f
isCaseCharging shouldBe false
isLeftPodCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodInEar shouldBe false
isRightPodInEar shouldBe true
batteryCasePercent shouldBe null
caseLidState shouldBe AirPodsDevice.LidState.NOT_IN_CASE
connectionState shouldBe AirPodsDevice.ConnectionState.MUSIC
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
}
}
}
@@ -0,0 +1,194 @@
package eu.darken.cap.pods.core.airpods.models
import eu.darken.cap.pods.core.airpods.AirPodsDevice
import eu.darken.cap.pods.core.airpods.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
class AirPodsProTest : BaseAirPodsTest() {
@Test
fun `test AirPods Pro - default changed and in case`() = runBlockingTest {
create<AirPodsPro>("07 19 01 0E 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 0x0e20.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 AirPodsDevice.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 AirPodsDevice.DeviceColor.WHITE
}
}
@Test
fun `test AirPods from my downstairs neighbour`() = runBlockingTest {
create<AirPodsPro>("07 19 01 0E 20 00 F3 8F 02 00 04 79 C6 3F F9 C3 15 D9 11 A1 3C B1 58 66 B9 8B 67") {
microPhonePod shouldBe AirPodsDevice.Pod.RIGHT
batteryLeftPodPercent shouldBe null
batteryRightPodPercent shouldBe 0.3f
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
}
}
// Test data from https://github.com/adolfintel/OpenPods/issues/34#issuecomment-565894487
@Test
fun `various AirPods Pro messages`() = runBlockingTest {
create<AirPodsPro>("0719010e202b668f01000500000000000000000000000000000000") {
batteryLeftPodPercent shouldBe 0.6f
batteryRightPodPercent shouldBe 0.6f
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
}
create<AirPodsPro>("0719010e202b668f01000500000000000000000000000000000000") {
batteryLeftPodPercent shouldBe 0.6f
batteryRightPodPercent shouldBe 0.6f
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
}
create<AirPodsPro>("0719010e202b668f01000400000000000000000000000000000000") {
batteryLeftPodPercent shouldBe 0.6f
batteryRightPodPercent shouldBe 0.6f
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
}
create<AirPodsPro>("0719010e200b668f01000500000000000000000000000000000000") {
batteryLeftPodPercent shouldBe 0.6f
batteryRightPodPercent shouldBe 0.6f
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
}
create<AirPodsPro>("0719010e2003668f01000500000000000000000000000000000000") {
batteryLeftPodPercent shouldBe 0.6f
batteryRightPodPercent shouldBe 0.6f
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
}
create<AirPodsPro>("0719010e2001668f01000500000000000000000000000000000000") {
batteryLeftPodPercent shouldBe 0.6f
batteryRightPodPercent shouldBe 0.6f
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
}
create<AirPodsPro>("0719010e2009668f01000500000000000000000000000000000000") {
batteryLeftPodPercent shouldBe 0.6f
batteryRightPodPercent shouldBe 0.6f
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
}
create<AirPodsPro>("0719010e2053669653000500000000000000000000000000000000") {
batteryLeftPodPercent shouldBe 0.6f
batteryRightPodPercent shouldBe 0.6f
isCaseCharging shouldBe false
isRightPodCharging shouldBe true
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe 0.6f
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
}
create<AirPodsPro>("0719010e203366a602000500000000000000000000000000000000") {
batteryLeftPodPercent shouldBe 0.6f
batteryRightPodPercent shouldBe 0.6f
isCaseCharging shouldBe false
isRightPodCharging shouldBe true
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe 0.6f
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
}
create<AirPodsPro>("0719010e202b768f02000500000000000000000000000000000000") {
batteryLeftPodPercent shouldBe 0.6f
batteryRightPodPercent shouldBe 0.7f
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
}
}
// Test data from https://github.com/adolfintel/OpenPods/issues/39#issuecomment-557664269
@Test
fun `fake airpods`() = runBlockingTest {
create<AirPodsGen1>("071901022055AA563100006FE4DF10AF106081033B76D9C7112288") {
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
isCaseCharging shouldBe true
isRightPodCharging shouldBe true
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe 0.6f
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
}
}
}
+29
View File
@@ -0,0 +1,29 @@
package testhelper
import eu.darken.cap.common.debug.logging.Logging
import eu.darken.cap.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.cap.common.debug.logging.log
import io.mockk.unmockkAll
import org.junit.jupiter.api.AfterAll
import testhelpers.logging.JUnitLogger
open class BaseTest {
init {
Logging.clearAll()
Logging.install(JUnitLogger())
testClassName = this.javaClass.simpleName
}
companion object {
private var testClassName: String? = null
@JvmStatic
@AfterAll
fun onTestClassFinished() {
unmockkAll()
log(testClassName!!, VERBOSE) { "onTestClassFinished()" }
Logging.clearAll()
}
}
}
@@ -0,0 +1,26 @@
package testhelper.coroutine
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.TestCoroutineScope
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.extension.AfterEachCallback
import org.junit.jupiter.api.extension.BeforeEachCallback
import org.junit.jupiter.api.extension.ExtensionContext
@ExperimentalCoroutinesApi
class CoroutinesTestExtension(
private val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
) : BeforeEachCallback, AfterEachCallback, TestCoroutineScope by TestCoroutineScope(dispatcher) {
override fun beforeEach(context: ExtensionContext?) {
Dispatchers.setMain(dispatcher)
}
override fun afterEach(context: ExtensionContext?) {
cleanupTestCoroutines()
Dispatchers.resetMain()
}
}
@@ -0,0 +1,23 @@
package testhelper.coroutine
import eu.darken.cap.common.coroutine.DispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlin.coroutines.CoroutineContext
class TestDispatcherProvider(private val context: CoroutineContext? = null) : DispatcherProvider {
override val Default: CoroutineContext
get() = context ?: Dispatchers.Unconfined
override val Main: CoroutineContext
get() = context ?: Dispatchers.Unconfined
override val MainImmediate: CoroutineContext
get() = context ?: Dispatchers.Unconfined
override val Unconfined: CoroutineContext
get() = context ?: Dispatchers.Unconfined
override val IO: CoroutineContext
get() = context ?: Dispatchers.Unconfined
}
fun CoroutineScope.asDispatcherProvider() = this.coroutineContext.asDispatcherProvider()
fun CoroutineContext.asDispatcherProvider() = TestDispatcherProvider(context = this)
@@ -0,0 +1,49 @@
package testhelper.coroutine
import eu.darken.cap.common.debug.logging.log
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.TestCoroutineScope
import kotlinx.coroutines.test.UncompletedCoroutinesError
import kotlinx.coroutines.test.runBlockingTest
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
/**
* If you have a test that uses a coroutine that never stops, you may use this.
*/
@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0
fun TestCoroutineScope.runBlockingTest2(
allowUncompleted: Boolean = false,
block: suspend TestCoroutineScope.() -> Unit
): Unit = runBlockingTest2(
allowUncompleted = allowUncompleted,
context = coroutineContext,
testBody = block
)
fun runBlockingTest2(
allowUncompleted: Boolean = false,
context: CoroutineContext = EmptyCoroutineContext,
testBody: suspend TestCoroutineScope.() -> Unit
) {
try {
runBlocking {
try {
runBlockingTest(
context = context,
testBody = testBody
)
} catch (e: UncompletedCoroutinesError) {
if (!allowUncompleted) throw e
else log { "Ignoring active job." }
}
}
} catch (e: Exception) {
if (!allowUncompleted || (e.message != "This job has not completed yet")) {
throw e
}
}
}
@@ -0,0 +1,101 @@
package testhelper.flow
import eu.darken.cap.common.debug.logging.Logging.Priority.WARN
import eu.darken.cap.common.debug.logging.asLog
import eu.darken.cap.common.debug.logging.log
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.test.TestCoroutineScope
fun <T> Flow<T>.test(
tag: String? = null,
startOnScope: CoroutineScope = TestCoroutineScope()
): TestCollector<T> = createTest(tag ?: "FlowTest").start(scope = startOnScope)
fun <T> Flow<T>.createTest(
tag: String? = null
): TestCollector<T> = TestCollector(this, tag ?: "FlowTest")
class TestCollector<T>(
private val flow: Flow<T>,
private val tag: String
) {
private var error: Throwable? = null
private lateinit var job: Job
private val cache = MutableSharedFlow<T>(
replay = Int.MAX_VALUE,
extraBufferCapacity = Int.MAX_VALUE,
onBufferOverflow = BufferOverflow.SUSPEND
)
private var latestInternal: T? = null
private val collectedValuesMutex = Mutex()
private val collectedValues = mutableListOf<T>()
var silent = false
fun start(scope: CoroutineScope) = apply {
flow
.buffer(capacity = Int.MAX_VALUE)
.onStart { log(tag) { "Setting up." } }
.onCompletion { log(tag) { "Final." } }
.onEach {
collectedValuesMutex.withLock {
if (!silent) log(tag) { "Collecting: $it" }
latestInternal = it
collectedValues.add(it)
cache.emit(it)
}
}
.catch { e ->
log(tag, WARN) { "Caught error: ${e.asLog()}" }
error = e
}
.launchIn(scope)
.also { job = it }
}
fun emissions(): Flow<T> = cache
val latestValue: T?
get() = collectedValues.last()
val latestValues: List<T>
get() = collectedValues
fun await(
timeout: Long = 10_000,
condition: (List<T>, T) -> Boolean
): T = runBlocking {
withTimeout(timeMillis = timeout) {
emissions().first {
condition(collectedValues, it)
}
}
}
suspend fun awaitFinal(cancel: Boolean = false) = apply {
if (cancel) cancel()
try {
job.join()
} catch (e: Exception) {
error = e
}
}
suspend fun assertNoErrors() = apply {
awaitFinal()
require(error == null) { "Error was not null: $error" }
}
fun cancel() {
if (job.isCompleted) throw IllegalStateException("Flow is already canceled.")
runBlocking {
job.cancelAndJoin()
}
}
}
@@ -0,0 +1,26 @@
package testhelper.livedata
import androidx.arch.core.executor.ArchTaskExecutor
import androidx.arch.core.executor.TaskExecutor
import org.junit.jupiter.api.extension.AfterEachCallback
import org.junit.jupiter.api.extension.BeforeEachCallback
import org.junit.jupiter.api.extension.ExtensionContext
class InstantExecutorExtension : BeforeEachCallback, AfterEachCallback {
override fun beforeEach(context: ExtensionContext?) {
ArchTaskExecutor.getInstance().setDelegate(
object : TaskExecutor() {
override fun executeOnDiskIO(runnable: Runnable) = runnable.run()
override fun postToMainThread(runnable: Runnable) = runnable.run()
override fun isMainThread(): Boolean = true
}
)
}
override fun afterEach(context: ExtensionContext?) {
ArchTaskExecutor.getInstance().setDelegate(null)
}
}
@@ -0,0 +1,22 @@
package testhelper.preferences
import androidx.core.content.edit
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelper.BaseTest
import testhelpers.preferences.MockSharedPreferences
class MockSharedPreferencesTest : BaseTest() {
private fun createInstance() = MockSharedPreferences()
@Test
fun `test boolean insertion`() {
val prefs = createInstance()
prefs.dataMapPeek shouldBe emptyMap()
prefs.getBoolean("key", true) shouldBe true
prefs.edit { putBoolean("key", false) }
prefs.getBoolean("key", true) shouldBe false
prefs.dataMapPeek["key"] shouldBe false
}
}