diff --git a/gradle/dependencies.gradle b/gradle/dependencies.gradle index f6993e1..ee4e444 100644 --- a/gradle/dependencies.gradle +++ b/gradle/dependencies.gradle @@ -7,7 +7,7 @@ ext.versions = [ ext.deps = [ kotlin: [ stdlib: "org.jetbrains.kotlin:kotlin-stdlib", - coroutines: "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.5", + coroutines: "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.7", junit: "org.jetbrains.kotlin:kotlin-test-junit", ], diff --git a/keep-alive/src/main/java/KeepAliveGatt.kt b/keep-alive/src/main/java/KeepAliveGatt.kt index 0d1d02f..2ac1602 100644 --- a/keep-alive/src/main/java/KeepAliveGatt.kt +++ b/keep-alive/src/main/java/KeepAliveGatt.kt @@ -22,19 +22,15 @@ import com.juul.able.gatt.OnDescriptorWrite import com.juul.able.gatt.OnMtuChanged import com.juul.able.gatt.OnReadRemoteRssi import com.juul.able.gatt.WriteType -import com.juul.able.keepalive.KeepAliveGatt.Configuration import com.juul.able.keepalive.State.Connected import com.juul.able.keepalive.State.Connecting import com.juul.able.keepalive.State.Disconnected -import java.util.UUID -import java.util.concurrent.atomic.AtomicBoolean -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletionHandler -import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.CoroutineStart.UNDISPATCHED import kotlinx.coroutines.DisposableHandle import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -43,18 +39,20 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.channels.BroadcastChannel import kotlinx.coroutines.channels.Channel.Factory.BUFFERED -import kotlinx.coroutines.channels.Channel.Factory.CONFLATED -import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.channels.consume import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asFlow -import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext typealias ConnectAction = suspend GattIo.() -> Unit @@ -70,58 +68,43 @@ enum class State { fun CoroutineScope.keepAliveGatt( androidContext: Context, bluetoothDevice: BluetoothDevice, - configuration: Configuration + disconnectTimeoutMillis: Long, + onConnectAction: ConnectAction? = null ) = KeepAliveGatt( parentCoroutineContext = coroutineContext, androidContext = androidContext, bluetoothDevice = bluetoothDevice, - configuration = configuration + disconnectTimeoutMillis = disconnectTimeoutMillis, + onConnectAction = onConnectAction ) class KeepAliveGatt( parentCoroutineContext: CoroutineContext = EmptyCoroutineContext, androidContext: Context, private val bluetoothDevice: BluetoothDevice, - configuration: Configuration + private val disconnectTimeoutMillis: Long, + private val onConnectAction: ConnectAction? = null ) : GattIo { - data class Configuration( - val disconnectTimeoutMillis: Long, - val exceptionHandler: CoroutineExceptionHandler? = null, - val onConnectAction: ConnectAction? = null, - internal val stateCapacity: Int = CONFLATED - ) - private val applicationContext = androidContext.applicationContext private val job = SupervisorJob(parentCoroutineContext[Job]) - private val scope = CoroutineScope( - parentCoroutineContext + job + (configuration.exceptionHandler ?: EmptyCoroutineContext) - ) + private val scope = CoroutineScope(parentCoroutineContext + job) private val isRunning = AtomicBoolean() - private val disconnectTimeoutMillis = configuration.disconnectTimeoutMillis - private val onConnectAction = configuration.onConnectAction - @Volatile private var _gatt: GattIo? = null private val gatt: GattIo inline get() = _gatt ?: throw NotReady(toString()) - private val _state = BroadcastChannel(configuration.stateCapacity) + private val _state = MutableStateFlow(Disconnected) @FlowPreview - val state: Flow = _state.asFlow() + val state: Flow = _state private val _onCharacteristicChanged = BroadcastChannel(BUFFERED) - // todo: Replace with `trySend` when Kotlin/kotlinx.coroutines#974 is fixed. - // https://github.com/Kotlin/kotlinx.coroutines/issues/974 - private fun setState(state: State) { - _state.offerCatching(state) - } - fun connect(): Boolean { check(!job.isCancelled) { "Cannot connect, $this is closed" } isRunning.compareAndSet(false, true) || return false @@ -131,7 +114,7 @@ class KeepAliveGatt( try { spawnConnection() } finally { - setState(Disconnected) + _state.value = Disconnected } } } @@ -146,7 +129,6 @@ class KeepAliveGatt( private fun cancel(cause: CancellationException?) { job.cancel(cause) _onCharacteristicChanged.cancel() - _state.cancel() } fun cancel() { @@ -156,7 +138,6 @@ class KeepAliveGatt( suspend fun cancelAndJoin() { job.cancelAndJoin() _onCharacteristicChanged.cancel() - _state.cancel() } // todo: Fix `@see` documentation link when https://github.com/Kotlin/dokka/issues/80 is fixed. @@ -166,7 +147,7 @@ class KeepAliveGatt( ): DisposableHandle = job.invokeOnCompletion(handler) private suspend fun spawnConnection() { - setState(Connecting) + _state.value = Connecting val gatt = when (val result = bluetoothDevice.connectGatt(applicationContext)) { is Success -> result.gatt @@ -184,14 +165,14 @@ class KeepAliveGatt( coroutineScope { gatt.onCharacteristicChanged .onEach(_onCharacteristicChanged::send) - .launchIn(this) + .launchIn(this, start = UNDISPATCHED) onConnectAction?.invoke(gatt) _gatt = gatt - setState(Connected) + _state.value = Connected } } finally { _gatt = null - setState(State.Disconnecting) + _state.value = State.Disconnecting withContext(NonCancellable) { withTimeoutOrNull(disconnectTimeoutMillis) { gatt.disconnect() @@ -236,10 +217,12 @@ class KeepAliveGatt( override suspend fun readRemoteRssi(): OnReadRemoteRssi = gatt.readRemoteRssi() override fun toString() = - "KeepAliveGatt(device=$bluetoothDevice, gatt=$_gatt, state=${_state.consume { poll() }})" + "KeepAliveGatt(device=$bluetoothDevice, gatt=$_gatt, state=${_state.value})" } -// https://github.com/Kotlin/kotlinx.coroutines/issues/974 -private fun SendChannel.offerCatching(element: E): Boolean { - return runCatching { offer(element) }.getOrDefault(false) +private fun Flow.launchIn( + scope: CoroutineScope, + start: CoroutineStart = CoroutineStart.DEFAULT +): Job = scope.launch(start = start) { + collect() } diff --git a/keep-alive/src/test/java/KeepAliveGattTest.kt b/keep-alive/src/test/java/KeepAliveGattTest.kt index 8a239af..e899503 100644 --- a/keep-alive/src/test/java/KeepAliveGattTest.kt +++ b/keep-alive/src/test/java/KeepAliveGattTest.kt @@ -20,13 +20,10 @@ import com.juul.able.gatt.Gatt import com.juul.able.gatt.OnCharacteristicChanged import com.juul.able.gatt.OnReadRemoteRssi import com.juul.able.keepalive.KeepAliveGatt -import com.juul.able.keepalive.KeepAliveGatt.Configuration import com.juul.able.keepalive.NotReady -import com.juul.able.keepalive.State import com.juul.able.keepalive.State.Connected import com.juul.able.keepalive.State.Connecting import com.juul.able.keepalive.State.Disconnected -import com.juul.able.keepalive.State.Disconnecting import com.juul.able.keepalive.keepAliveGatt import com.juul.able.logger.Logger import io.mockk.coEvery @@ -34,16 +31,8 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic -import java.util.UUID -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertFalse -import kotlin.test.assertTrue import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CoroutineStart.UNDISPATCHED import kotlinx.coroutines.Job import kotlinx.coroutines.async @@ -53,19 +42,24 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.BUFFERED import kotlinx.coroutines.channels.Channel.Factory.CONFLATED import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex +import java.util.UUID +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +private const val BLUETOOTH_DEVICE_CLASS = "com.juul.able.android.BluetoothDeviceKt" private const val MAC_ADDRESS = "00:11:22:33:FF:EE" -private const val DISCONNECT_TIMEOUT = 5_000L +private const val DISCONNECT_TIMEOUT = 5_000L // milliseconds private val testUuid = UUID.fromString("01234567-89ab-cdef-0123-456789abcdef") @@ -90,7 +84,7 @@ class KeepAliveGattTest { coEvery { disconnect() } returns Unit } - mockkStatic("com.juul.able.android.BluetoothDeviceKt") { + mockkStatic(BLUETOOTH_DEVICE_CLASS) { coEvery { bluetoothDevice.connectGatt(any()) } returns ConnectGattResult.Success(gatt) val mutex = Mutex(locked = true) @@ -98,7 +92,7 @@ class KeepAliveGattTest { keepAliveGatt( androidContext = mockk(relaxed = true), bluetoothDevice = bluetoothDevice, - configuration = Configuration(DISCONNECT_TIMEOUT) + disconnectTimeoutMillis = DISCONNECT_TIMEOUT ).apply { connect() }.state.first { it == Connected } @@ -128,48 +122,31 @@ class KeepAliveGattTest { coEvery { disconnect() } returns Unit } - mockkStatic("com.juul.able.android.BluetoothDeviceKt") { + mockkStatic(BLUETOOTH_DEVICE_CLASS) { coEvery { bluetoothDevice.connectGatt(any()) } returnsMany listOf(gatt1, gatt2).map { ConnectGattResult.Success(it) } - val states = Channel(BUFFERED) - val keepAlive = keepAliveGatt( + val keepAlive = KeepAliveGatt( androidContext = mockk(relaxed = true), bluetoothDevice = bluetoothDevice, - configuration = Configuration(DISCONNECT_TIMEOUT, stateCapacity = BUFFERED) + disconnectTimeoutMillis = DISCONNECT_TIMEOUT ) - keepAlive.state.onEach(states::send).launchIn(this, start = UNDISPATCHED) - keepAlive.connect() - assertEquals( - expected = Connecting, - actual = states.receive() - ) - assertEquals( - expected = Connected, - actual = states.receive() + expected = Disconnected, + actual = keepAlive.state.first() ) - onCharacteristicChanged1.close() // Simulates connection drop. + keepAlive.connect() + keepAlive.state.first { it == Connected } // Wait until connected. - assertEquals( - expected = Disconnecting, - actual = states.receive() - ) - assertEquals( - expected = Disconnected, - actual = states.receive() - ) - assertEquals( - expected = Connecting, - actual = states.receive() - ) - assertEquals( - expected = Connected, - actual = states.receive() - ) + val dropped = async(start = UNDISPATCHED) { + keepAlive.state.first { it != Connected } + } + onCharacteristicChanged1.close() // Simulates connection drop. + dropped.await() // Validates that connection dropped. + keepAlive.state.first { it == Connected } // Wait until reconnected. keepAlive.cancelAndJoin() coVerify(exactly = 2) { bluetoothDevice.connectGatt(any()) } @@ -186,13 +163,13 @@ class KeepAliveGattTest { coEvery { disconnect() } returns Unit } - mockkStatic("com.juul.able.android.BluetoothDeviceKt") { + mockkStatic(BLUETOOTH_DEVICE_CLASS) { coEvery { bluetoothDevice.connectGatt(any()) } returns ConnectGattResult.Success(gatt) val keepAlive = KeepAliveGatt( androidContext = mockk(relaxed = true), bluetoothDevice = bluetoothDevice, - configuration = Configuration(DISCONNECT_TIMEOUT) + disconnectTimeoutMillis = DISCONNECT_TIMEOUT ) assertTrue(keepAlive.connect()) @@ -206,7 +183,7 @@ class KeepAliveGattTest { val keepAlive = KeepAliveGatt( androidContext = mockk(relaxed = true), bluetoothDevice = mockk(), - configuration = Configuration(DISCONNECT_TIMEOUT, stateCapacity = BUFFERED) + disconnectTimeoutMillis = DISCONNECT_TIMEOUT ) assertFailsWith { @@ -220,12 +197,13 @@ class KeepAliveGattTest { fun `Retries connection on connection failure`() { val connectionAttempts = 5 val bluetoothDevice = mockBluetoothDevice() + val lock = Mutex(locked = true) - val exceptionHandler = CoroutineExceptionHandler { _, cause -> + val scope = CoroutineScope(Job() + CoroutineExceptionHandler { _, cause -> if (cause is EndOfTest) lock.unlock() - } + }) - mockkStatic("com.juul.able.android.BluetoothDeviceKt") { + mockkStatic(BLUETOOTH_DEVICE_CLASS) { var attempt = 0 coEvery { bluetoothDevice.connectGatt(any()) @@ -234,10 +212,10 @@ class KeepAliveGattTest { Failure.Connection(ConnectionLost()) } - val keepAlive = KeepAliveGatt( + val keepAlive = scope.keepAliveGatt( androidContext = mockk(relaxed = true), bluetoothDevice = bluetoothDevice, - configuration = Configuration(DISCONNECT_TIMEOUT, exceptionHandler) + disconnectTimeoutMillis = DISCONNECT_TIMEOUT ) assertTrue(keepAlive.connect()) @@ -248,35 +226,31 @@ class KeepAliveGattTest { @Test fun `Does not retry connection when connection is rejected`() = runBlocking { - val bluetoothDevice = mockBluetoothDevice() - - mockkStatic("com.juul.able.android.BluetoothDeviceKt") { - coEvery { - bluetoothDevice.connectGatt(any()) - } returns Failure.Rejected(RemoteException()) + val bluetoothDevice = mockk { + every { this@mockk.toString() } returns MAC_ADDRESS + every { connectGatt(any(), false, any()) } returns null + } - val scope = CoroutineScope(Job()) - val keepAlive = scope.keepAliveGatt( - androidContext = mockk(relaxed = true), - bluetoothDevice = bluetoothDevice, - configuration = Configuration(DISCONNECT_TIMEOUT) - ) + val scope = CoroutineScope(Job()) + val keepAlive = scope.keepAliveGatt( + androidContext = mockk(relaxed = true), + bluetoothDevice = bluetoothDevice, + disconnectTimeoutMillis = DISCONNECT_TIMEOUT + ) - val completion = Channel(CONFLATED) - keepAlive.invokeOnCompletion { cause -> completion.offer(cause) } + val completion = Channel(CONFLATED) + keepAlive.invokeOnCompletion { cause -> completion.offer(cause) } - assertTrue(keepAlive.connect()) - keepAlive.state.collect() // Collection aborts when `keepAlive` cancels due to rejection. + assertTrue(keepAlive.connect()) - assertTrue(scope.isActive, "KeepAlive cancellation should not cancel parent") - coVerify(exactly = 1) { bluetoothDevice.connectGatt(any()) } + val cancellation = completion.receive() + assertEquals?>( + expected = RemoteException::class.java, + actual = cancellation?.cause?.javaClass + ) - val cancellation = completion.receive() - assertEquals?>( - expected = RemoteException::class.java, - actual = cancellation?.cause?.javaClass - ) - } + assertTrue(scope.isActive, "KeepAlive cancellation should not cancel parent") + coVerify(exactly = 1) { bluetoothDevice.connectGatt(any(), false, any()) } } @Test @@ -287,13 +261,13 @@ class KeepAliveGattTest { coEvery { disconnect() } returns Unit } - mockkStatic("com.juul.able.android.BluetoothDeviceKt") { + mockkStatic(BLUETOOTH_DEVICE_CLASS) { coEvery { bluetoothDevice.connectGatt(any()) } returns ConnectGattResult.Success(gatt) val keepAlive = KeepAliveGatt( androidContext = mockk(relaxed = true), bluetoothDevice = bluetoothDevice, - configuration = Configuration(DISCONNECT_TIMEOUT) + disconnectTimeoutMillis = DISCONNECT_TIMEOUT ) val connected = async(start = UNDISPATCHED) { keepAlive.state.first { it == Connected } @@ -325,7 +299,7 @@ class KeepAliveGattTest { coEvery { disconnect() } returns Unit } - mockkStatic("com.juul.able.android.BluetoothDeviceKt") { + mockkStatic(BLUETOOTH_DEVICE_CLASS) { coEvery { bluetoothDevice.connectGatt(any()) } returnsMany listOf(gatt1, gatt2).map { ConnectGattResult.Success(it) } @@ -333,7 +307,7 @@ class KeepAliveGattTest { val keepAlive = KeepAliveGatt( androidContext = mockk(relaxed = true), bluetoothDevice = bluetoothDevice, - configuration = Configuration(DISCONNECT_TIMEOUT) + disconnectTimeoutMillis = DISCONNECT_TIMEOUT ) val ready = async(start = UNDISPATCHED) { keepAlive.state.first { it == Connecting || it == Connected } @@ -383,13 +357,13 @@ class KeepAliveGattTest { val data = byteArrayOf(0xF0.toByte(), 0x0D) val writeType = WRITE_TYPE_DEFAULT - mockkStatic("com.juul.able.android.BluetoothDeviceKt") { + mockkStatic(BLUETOOTH_DEVICE_CLASS) { coEvery { bluetoothDevice.connectGatt(any()) } returns ConnectGattResult.Success(gatt) val keepAlive = KeepAliveGatt( androidContext = mockk(relaxed = true), bluetoothDevice = bluetoothDevice, - configuration = Configuration(DISCONNECT_TIMEOUT) + disconnectTimeoutMillis = DISCONNECT_TIMEOUT ) val connected = async(start = UNDISPATCHED) { @@ -432,16 +406,15 @@ class KeepAliveGattTest { } @Test - fun `toString after close doesn't throw Exception`() { + fun `toString shows state as Disconnected before connecting`() { val bluetoothDevice = mockBluetoothDevice() val keepAlive = KeepAliveGatt( androidContext = mockk(relaxed = true), bluetoothDevice = bluetoothDevice, - configuration = Configuration(DISCONNECT_TIMEOUT) + disconnectTimeoutMillis = DISCONNECT_TIMEOUT ) - keepAlive.cancel() assertEquals( - expected = "KeepAliveGatt(device=$MAC_ADDRESS, gatt=null, state=null)", + expected = "KeepAliveGatt(device=$MAC_ADDRESS, gatt=null, state=Disconnected)", actual = keepAlive.toString() ) } @@ -449,11 +422,5 @@ class KeepAliveGattTest { private fun mockBluetoothDevice(): BluetoothDevice = mockk { every { this@mockk.toString() } returns MAC_ADDRESS -} - -private fun Flow.launchIn( - scope: CoroutineScope, - start: CoroutineStart = CoroutineStart.DEFAULT -): Job = scope.launch(start = start) { - collect() + every { connectGatt(any(), any(), any()) } returns null }