diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbad973..ef191bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: key: grade-${{ hashFiles('**/*.gradle*') }} - name: Check - run: ./gradlew $GRADLE_ARGS check jacocoTestDebugUnitTestReport + run: ./gradlew $GRADLE_ARGS check jacocoTestReport - name: Codecov uses: codecov/codecov-action@v1 diff --git a/README.md b/README.md index 44a7649..5f086d1 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,7 @@ traditionally rely on [`BluetoothGattCallback`] calls with [suspension functions callback: BluetoothGattCallback ): BluetoothGatt
suspend fun connectGatt(
-    context: Context,
-    autoConnect: Boolean = false
+    context: Context
 ): ConnectGattResult1
@@ -37,7 +36,7 @@ traditionally rely on [`BluetoothGattCallback`] calls with [suspension functions sealed class ConnectGattResult { data class Success(val gatt: Gatt) : ConnectGattResult() data class Canceled(val cause: CancellationException) : ConnectGattResult() - data class Failure(val cause: Throwable) : ConnectGattResult() + data class Failure(val cause: Exception) : ConnectGattResult() } ``` @@ -112,8 +111,7 @@ sealed class ConnectGattResult { 1 _Suspends until `STATE_CONNECTED` or non-`GATT_SUCCESS` is received._
2 _Suspends until `STATE_DISCONNECTED` or non-`GATT_SUCCESS` is received._
3 _Throws [`RemoteException`] if underlying [`BluetoothGatt`] call returns `false`._
-3 _Throws `GattClosed` if `Gatt` is closed while method is executing._
-3 _Throws `GattConnectionLost` if `Gatt` is disconnects while method is executing._ +3 _Throws `ConnectionLost` if `Gatt` is closed while method is executing._
### Details @@ -142,11 +140,11 @@ corresponding [`BluetoothGatt`] will be closed: ```kotlin fun connect(context: Context, device: BluetoothDevice) { val deferred = async { - device.connectGatt(context, autoConnect = false) + device.connectGatt(context) } launch { - delay(1000L) // Assume, for this example, that BLE connection takes more than 1 second. + delay(1_000L) // Assume, for this example, that BLE connection takes more than 1 second. // Cancels the `async` Coroutine and automatically closes the underlying `BluetoothGatt`. deferred.cancel() @@ -157,8 +155,8 @@ fun connect(context: Context, device: BluetoothDevice) { ``` Note that in the above example, if the BLE connection takes less than 1 second, then the -**established** connection will **not** be cancelled (and `Gatt` will not be closed), and `result` -will be `ConnectGattResult.Success`. +**established** connection will **not** be cancelled and `result` will be +`ConnectGattResult.Success`. ### `Gatt` Coroutine Scope @@ -204,7 +202,7 @@ dependencies { # License ``` -Copyright 2018 JUUL Labs +Copyright 2020 JUUL Labs Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/build.gradle b/build.gradle index c9f3565..f86f0e9 100644 --- a/build.gradle +++ b/build.gradle @@ -7,7 +7,7 @@ buildscript { } plugins { - id 'org.jetbrains.kotlin.android' version '1.3.60' apply false + id 'org.jetbrains.kotlin.android' version '1.3.70' apply false id 'org.jmailen.kotlinter' version '2.2.0' apply false id 'com.android.library' version '3.6.2' apply false id 'com.vanniktech.maven.publish' version '0.11.1' apply false @@ -28,8 +28,7 @@ subprojects { subprojects { tasks.withType(Test) { testLogging { - // For more verbosity add: "standardOut", "standardError" - events "passed", "skipped", "failed" + events "passed", "skipped", "failed", "standardOut", "standardError" exceptionFormat "full" showExceptions true showStackTraces true @@ -44,7 +43,6 @@ gradle.taskGraph.whenReady { taskGraph -> taskGraph.getAllTasks().findAll { task -> task.name.startsWith('installArchives') || task.name.startsWith('publishArchives') }.forEach { task -> - logger.error("VERSION_NAME='" + project.findProperty("VERSION_NAME") + "'") task.doFirst { if (!project.hasProperty("VERSION_NAME") || project.findProperty("VERSION_NAME").startsWith("unspecified")) { logger.error("VERSION_NAME=" + project.findProperty("VERSION_NAME")) diff --git a/codecov.yml b/codecov.yml index f7ece7d..41f9bac 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,5 +1,4 @@ fixes: - - "com/juul/able/experimental::" - - "com/juul/able/experimental/processor::" - - "com/juul/able/experimental/throwable::" - - "com/juul/able/experimental/logger/timber::" + - "com/juul/able/logger/timber::" + - "com/juul/able/processor::" + - "com/juul/able/throwable::" diff --git a/core/build.gradle b/core/build.gradle index 00afa67..5639344 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -1,6 +1,7 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' + id 'org.jmailen.kotlinter' id 'com.hiya.jacoco-android' id 'com.vanniktech.maven.publish' } @@ -21,7 +22,7 @@ android { dependencies { api deps.kotlin.coroutines - api deps.kotlin.junit + testImplementation deps.kotlin.junit testImplementation deps.mockk testImplementation deps.equalsverifier } diff --git a/core/src/main/AndroidManifest.xml b/core/src/main/AndroidManifest.xml index 0c42424..a71d332 100644 --- a/core/src/main/AndroidManifest.xml +++ b/core/src/main/AndroidManifest.xml @@ -1,5 +1,5 @@ - + diff --git a/core/src/main/java/Able.kt b/core/src/main/java/Able.kt index fdddfb4..544bf76 100644 --- a/core/src/main/java/Able.kt +++ b/core/src/main/java/Able.kt @@ -1,25 +1,14 @@ /* - * Copyright 2018 JUUL Labs, Inc. + * Copyright 2020 JUUL Labs, Inc. */ -package com.juul.able.experimental +package com.juul.able -import android.util.Log - -interface Logger { - fun isLoggable(priority: Int): Boolean - fun log(priority: Int, throwable: Throwable? = null, message: String) -} +import com.juul.able.logger.AndroidLogger object Able { - const val VERBOSE = 2 - const val DEBUG = 3 - const val INFO = 4 - const val WARN = 5 - const val ERROR = 6 - const val ASSERT = 7 - + @Volatile var logger: Logger = AndroidLogger() inline fun assert(throwable: Throwable? = null, message: () -> String) { @@ -48,18 +37,7 @@ object Able { inline fun log(priority: Int, throwable: Throwable? = null, message: () -> String) { if (logger.isLoggable(priority)) { - logger.log(priority, throwable, message()) + logger.log(priority, throwable, message.invoke()) } } } - -class AndroidLogger : Logger { - - private val tag = "Able" - - override fun isLoggable(priority: Int): Boolean = Log.isLoggable(tag, priority) - - override fun log(priority: Int, throwable: Throwable?, message: String) { - Log.println(priority, tag, message) - } -} diff --git a/core/src/main/java/ConnectionStateMonitor.kt b/core/src/main/java/ConnectionStateMonitor.kt deleted file mode 100644 index 85adc32..0000000 --- a/core/src/main/java/ConnectionStateMonitor.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright 2018 JUUL Labs, Inc. - */ - -@file:Suppress("RedundantUnitReturnType") - -package com.juul.able.experimental - -import android.bluetooth.BluetoothGatt -import android.bluetooth.BluetoothProfile -import com.juul.able.experimental.messenger.OnConnectionStateChange -import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.channels.consumeEach -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import java.io.Closeable - -class ConnectionStateMonitor(private val gatt: Gatt) : Closeable { - - private val connectionStateMutex = Mutex() - private var connectionStateSubscription: ReceiveChannel? = null - - suspend fun suspendUntilConnectionState(state: GattState): Boolean { - Able.debug { "Suspending until ${state.asGattStateString()}" } - - gatt.onConnectionStateChange.openSubscription().also { subscription -> - if (state == BluetoothProfile.STATE_DISCONNECTED && subscription.isEmpty) { - // When disconnecting, the channel may be empty if we've never connected before, so - // we shouldn't wait. - Able.info { "suspendUntilConnectionState → subscription.isEmpty, aborting" } - subscription.cancel() - return true - } else { - connectionStateMutex.withLock { - // If another `suspendUntilConnectionState` is in progress then we abort it. - connectionStateSubscription?.cancel() - connectionStateSubscription = subscription - } - - subscription.consumeEach { (status, newState) -> - Able.verbose { - val statusString = status.asGattConnectionStatusString() - val stateString = newState.asGattStateString() - "status = $statusString, newState = $stateString" - } - - if (status != BluetoothGatt.GATT_SUCCESS) { - Able.info { "Received ${status.asGattConnectionStatusString()}, giving up." } - return false - } - - if (state == newState) { - Able.info { "Reached ${state.asGattStateString()}" } - return true - } - } - } - } - - Able.debug { "suspendUntilConnectionState → Aborted." } - return false - } - - override fun close(): Unit { - connectionStateSubscription?.cancel() - } -} diff --git a/core/src/main/java/CoroutinesDevice.kt b/core/src/main/java/CoroutinesDevice.kt deleted file mode 100644 index 718114d..0000000 --- a/core/src/main/java/CoroutinesDevice.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright 2018 JUUL Labs, Inc. - */ - -package com.juul.able.experimental - -import android.bluetooth.BluetoothDevice -import android.bluetooth.BluetoothGatt -import android.bluetooth.BluetoothGatt.STATE_CONNECTED -import android.content.Context -import android.os.RemoteException -import com.juul.able.experimental.ConnectGattResult.Canceled -import com.juul.able.experimental.ConnectGattResult.Failure -import com.juul.able.experimental.ConnectGattResult.Success -import com.juul.able.experimental.messenger.GattCallback -import com.juul.able.experimental.messenger.GattCallbackConfig -import com.juul.able.experimental.messenger.Messenger -import kotlinx.coroutines.CancellationException - -class CoroutinesDevice( - private val device: BluetoothDevice, - private val callbackConfig: GattCallbackConfig = GattCallbackConfig() -) : Device { - - /** - * Requests that a connection to the [device] be established. - * - * A dedicated thread is spun up to handle interacting with the newly retrieved [BluetoothGatt] - * and must be closed when the connection is no longer needed by invoking [Gatt.close]. - */ - private fun requestConnectGatt(context: Context, autoConnect: Boolean): CoroutinesGatt? { - val callback = GattCallback(callbackConfig) - val bluetoothGatt = device.connectGatt(context, autoConnect, callback) ?: return null - val messenger = Messenger(bluetoothGatt, callback) - return CoroutinesGatt(bluetoothGatt, messenger) - } - - /** - * Establishes a connection to the [BluetoothDevice], suspending until connection is successful - * or error occurs. - * - * To cancel an in-flight connection attempt, the Coroutine from which this method was called - * can be canceled: - * - * ``` - * fun connect(androidContext: Context, device: BluetoothDevice) { - * connectJob = async { - * device.connectGattOrNull(androidContext, autoConnect = false) - * } - * } - * - * fun cancelConnection() { - * connectJob?.cancel() // cancels the above `connectGatt` - * } - * ``` - * - * A dedicated thread is spun up to handle interacting with the underlying [BluetoothGatt], and - * can be stopped by invoking [Gatt.close] on the returned [Gatt] object. - * - * If an error occurs during connection process, then [Gatt.close] is automatically called - * (which stops the dedicated thread). - */ - override suspend fun connectGatt(context: Context, autoConnect: Boolean): ConnectGattResult { - val gatt = requestConnectGatt(context, autoConnect) - ?: return Failure( - RemoteException("`BluetoothDevice.connectGatt` returned `null`.") - ) - val connectionStateMonitor = ConnectionStateMonitor(gatt) - - val didConnect = try { - connectionStateMonitor.suspendUntilConnectionState(STATE_CONNECTED) - } catch (e: CancellationException) { - Able.info { "connectGatt() canceled." } - gatt.close() - return Canceled(e) - } finally { - connectionStateMonitor.close() - } - - return if (didConnect) { - Success(gatt) - } else { - Able.warn { "connectGatt() failed." } - gatt.close() - return Failure( - IllegalStateException("Failed to connect to ${device.address}.") - ) - } - } -} diff --git a/core/src/main/java/CoroutinesGatt.kt b/core/src/main/java/CoroutinesGatt.kt deleted file mode 100644 index 896a7e2..0000000 --- a/core/src/main/java/CoroutinesGatt.kt +++ /dev/null @@ -1,267 +0,0 @@ -/* - * Copyright 2018 JUUL Labs, Inc. - */ - -@file:Suppress("RedundantUnitReturnType") - -package com.juul.able.experimental - -import android.bluetooth.BluetoothGatt -import android.bluetooth.BluetoothGattCharacteristic -import android.bluetooth.BluetoothGattDescriptor -import android.bluetooth.BluetoothGattService -import android.bluetooth.BluetoothProfile.STATE_CONNECTED -import android.bluetooth.BluetoothProfile.STATE_DISCONNECTED -import android.bluetooth.BluetoothProfile.STATE_DISCONNECTING -import android.os.RemoteException -import com.juul.able.experimental.messenger.Message.DiscoverServices -import com.juul.able.experimental.messenger.Message.ReadCharacteristic -import com.juul.able.experimental.messenger.Message.RequestMtu -import com.juul.able.experimental.messenger.Message.WriteCharacteristic -import com.juul.able.experimental.messenger.Message.WriteDescriptor -import com.juul.able.experimental.messenger.Messenger -import com.juul.able.experimental.messenger.OnCharacteristicChanged -import com.juul.able.experimental.messenger.OnCharacteristicRead -import com.juul.able.experimental.messenger.OnCharacteristicWrite -import com.juul.able.experimental.messenger.OnConnectionStateChange -import com.juul.able.experimental.messenger.OnDescriptorWrite -import com.juul.able.experimental.messenger.OnMtuChanged -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Job -import kotlinx.coroutines.channels.BroadcastChannel -import kotlinx.coroutines.channels.ClosedReceiveChannelException -import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.channels.filter -import kotlinx.coroutines.selects.select -import java.util.UUID -import kotlin.coroutines.CoroutineContext - -class GattClosed(message: String, cause: Throwable) : IllegalStateException(message, cause) -class GattConnectionLost : Exception() - -class CoroutinesGatt( - private val bluetoothGatt: BluetoothGatt, - private val messenger: Messenger -) : Gatt { - - private val job = Job() - override val coroutineContext: CoroutineContext - get() = job - - private val connectionStateMonitor by lazy { ConnectionStateMonitor(this) } - - override val onConnectionStateChange: BroadcastChannel - get() = messenger.callback.onConnectionStateChange - - override val onCharacteristicChanged: BroadcastChannel - get() = messenger.callback.onCharacteristicChanged - - override fun requestConnect(): Boolean = bluetoothGatt.connect() - override fun requestDisconnect(): Unit = bluetoothGatt.disconnect() - - override suspend fun connect(): Boolean { - return if (requestConnect()) { - connectionStateMonitor.suspendUntilConnectionState(STATE_CONNECTED) - } else { - Able.error { "connect → BluetoothGatt.requestConnect() returned false " } - false - } - } - - override suspend fun disconnect(): Unit { - requestDisconnect() - connectionStateMonitor.suspendUntilConnectionState(STATE_DISCONNECTED) - } - - override fun close() { - Able.verbose { "close → Begin" } - job.cancel() - connectionStateMonitor.close() - messenger.close() - bluetoothGatt.close() - Able.verbose { "close → End" } - } - - override val services: List get() = bluetoothGatt.services - override fun getService(uuid: UUID): BluetoothGattService? = bluetoothGatt.getService(uuid) - - /** - * @throws [RemoteException] if underlying [BluetoothGatt.discoverServices] returns `false`. - * @throws [GattClosed] if [Gatt] is closed while method is executing. - */ - override suspend fun discoverServices(): GattStatus { - Able.debug { "discoverServices → send(DiscoverServices)" } - - val response = CompletableDeferred() - messenger.send(DiscoverServices(response)) - - val call = "BluetoothGatt.discoverServices()" - Able.verbose { "discoverServices → Waiting for $call" } - if (!response.await()) { - throw RemoteException("$call returned false.") - } - - Able.verbose { "discoverServices → Waiting for BluetoothGattCallback" } - return try { - messenger.callback.onServicesDiscovered.receiveRequiringConnection().also { status -> - Able.info { "discoverServices, status=${status.asGattStatusString()}" } - } - } catch (e: ClosedReceiveChannelException) { - throw GattClosed("Gatt closed during discoverServices", e) - } - } - - /** - * @throws [RemoteException] if underlying [BluetoothGatt.readCharacteristic] returns `false`. - * @throws [GattClosed] if [Gatt] is closed while method is executing. - */ - override suspend fun readCharacteristic( - characteristic: BluetoothGattCharacteristic - ): OnCharacteristicRead { - val uuid = characteristic.uuid - Able.debug { "readCharacteristic → send(ReadCharacteristic[uuid=$uuid])" } - - val response = CompletableDeferred() - messenger.send(ReadCharacteristic(characteristic, response)) - - val call = "BluetoothGatt.readCharacteristic(BluetoothGattCharacteristic[uuid=$uuid])" - Able.verbose { "readCharacteristic → Waiting for $call" } - if (!response.await()) { - throw RemoteException("Failed to read characteristic with UUID $uuid.") - } - - Able.verbose { "readCharacteristic → Waiting for BluetoothGattCallback" } - return try { - messenger.callback.onCharacteristicRead.receiveRequiringConnection() - .also { (_, value, status) -> - Able.info { - val bytesString = value.size.bytesString - val statusString = status.asGattStatusString() - "← readCharacteristic $uuid ($bytesString), status=$statusString" - } - } - } catch (e: ClosedReceiveChannelException) { - throw GattClosed("Gatt closed during readCharacteristic[uuid=$uuid]", e) - } - } - - /** - * @param value applied to [characteristic] when characteristic is written. - * @param writeType applied to [characteristic] when characteristic is written. - * @throws [RemoteException] if underlying [BluetoothGatt.writeCharacteristic] returns `false`. - * @throws [GattClosed] if [Gatt] is closed while method is executing. - */ - override suspend fun writeCharacteristic( - characteristic: BluetoothGattCharacteristic, - value: ByteArray, - writeType: WriteType - ): OnCharacteristicWrite { - val uuid = characteristic.uuid - Able.debug { "writeCharacteristic → send(WriteCharacteristic[uuid=$uuid])" } - - val response = CompletableDeferred() - messenger.send(WriteCharacteristic(characteristic, value, writeType, response)) - - val call = "BluetoothGatt.writeCharacteristic(BluetoothGattCharacteristic[uuid=$uuid])" - Able.verbose { "writeCharacteristic → Waiting for $call" } - if (!response.await()) { - throw RemoteException("$call returned false.") - } - - Able.verbose { "writeCharacteristic → Waiting for BluetoothGattCallback" } - return try { - messenger.callback.onCharacteristicWrite.receiveRequiringConnection() - .also { (_, status) -> - Able.info { - val bytesString = value.size.bytesString - val typeString = writeType.asWriteTypeString() - val statusString = status.asGattStatusString() - "→ writeCharacteristic $uuid ($bytesString), type=$typeString, status=$statusString" - } - } - } catch (e: ClosedReceiveChannelException) { - throw GattClosed("Gatt closed during writeCharacteristic[uuid=$uuid]", e) - } - } - - /** - * @param value applied to [descriptor] when descriptor is written. - * @throws [RemoteException] if underlying [BluetoothGatt.writeDescriptor] returns `false`. - * @throws [GattClosed] if [Gatt] is closed while method is executing. - */ - override suspend fun writeDescriptor( - descriptor: BluetoothGattDescriptor, value: ByteArray - ): OnDescriptorWrite { - val uuid = descriptor.uuid - Able.debug { "writeDescriptor → send(WriteDescriptor[uuid=$uuid])" } - - val response = CompletableDeferred() - messenger.send(WriteDescriptor(descriptor, value, response)) - - val call = "BluetoothGatt.writeDescriptor(BluetoothGattDescriptor[uuid=$uuid])" - Able.verbose { "writeDescriptor → Waiting for $call" } - if (!response.await()) { - throw RemoteException("$call returned false.") - } - - Able.verbose { "writeDescriptor → Waiting for BluetoothGattCallback" } - return try { - messenger.callback.onDescriptorWrite.receiveRequiringConnection().also { (_, status) -> - Able.info { - val bytesString = value.size.bytesString - val statusString = status.asGattStatusString() - "→ writeDescriptor $uuid ($bytesString), status=$statusString" - } - } - } catch (e: ClosedReceiveChannelException) { - throw GattClosed("Gatt closed during writeDescriptor[uuid=$uuid]", e) - } - } - - /** - * @throws [RemoteException] if underlying [BluetoothGatt.requestMtu] returns `false`. - * @throws [GattClosed] if [Gatt] is closed while method is executing. - */ - override suspend fun requestMtu(mtu: Int): OnMtuChanged { - Able.debug { "requestMtu → send(RequestMtu[mtu=$mtu])" } - - val response = CompletableDeferred() - messenger.send(RequestMtu(mtu, response)) - - val call = "BluetoothGatt.requestMtu($mtu)" - Able.verbose { "requestMtu → Waiting for $call" } - if (!response.await()) { - throw RemoteException("$call returned false.") - } - - Able.verbose { "requestMtu → Waiting for BluetoothGattCallback" } - return try { - messenger.callback.onMtuChanged.receiveRequiringConnection().also { (mtu, status) -> - Able.info { "requestMtu $mtu, status=${status.asGattStatusString()}" } - } - } catch (e: ClosedReceiveChannelException) { - throw GattClosed("Gatt closed during requestMtu[mtu=$mtu]", e) - } - } - - override fun setCharacteristicNotification( - characteristic: BluetoothGattCharacteristic, - enable: Boolean - ): Boolean { - Able.info { "setCharacteristicNotification ${characteristic.uuid} enable=$enable" } - return bluetoothGatt.setCharacteristicNotification(characteristic, enable) - } - - private suspend fun ReceiveChannel.receiveRequiringConnection(): T = select { - onReceive { it } - - onConnectionStateChange - .openSubscription() // fixme: Find solution w/o subscription object creation cost. - .filter { (_, newState) -> - newState == STATE_DISCONNECTING || newState == STATE_DISCONNECTED - } - .onReceive { throw GattConnectionLost() } - } -} - -private val Int.bytesString get() = if (this == 1) "$this byte" else "$this bytes" diff --git a/core/src/main/java/Device.kt b/core/src/main/java/Device.kt deleted file mode 100644 index a90caaf..0000000 --- a/core/src/main/java/Device.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2018 JUUL Labs, Inc. - */ - -package com.juul.able.experimental - -import android.content.Context -import com.juul.able.experimental.ConnectGattResult.Success -import kotlinx.coroutines.CancellationException - -interface ConnectGattError { - val cause: Throwable -} - -sealed class ConnectGattResult { - data class Success(val gatt: Gatt) : ConnectGattResult() - - data class Canceled( - override val cause: CancellationException - ) : ConnectGattResult(), ConnectGattError - - data class Failure( - override val cause: Throwable - ) : ConnectGattResult(), ConnectGattError -} - -interface Device { - suspend fun connectGatt(context: Context, autoConnect: Boolean): ConnectGattResult -} - -suspend fun Device.connectGattOrNull(context: Context, autoConnect: Boolean): Gatt? { - val result = connectGatt(context, autoConnect) - - @Suppress("IfThenToSafeAccess") // Easier to read as `if` statement. - return if (result is Success) result.gatt else null -} diff --git a/core/src/main/java/Logger.kt b/core/src/main/java/Logger.kt new file mode 100644 index 0000000..f2f02ed --- /dev/null +++ b/core/src/main/java/Logger.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2020 JUUL Labs, Inc. + */ + +package com.juul.able + +/* + * Log values chosen to match Android's: + * https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/util/Log.java;l=71-99?q=Log.java&ss=android + */ +const val VERBOSE = 2 +const val DEBUG = 3 +const val INFO = 4 +const val WARN = 5 +const val ERROR = 6 +const val ASSERT = 7 + +interface Logger { + fun isLoggable(priority: Int): Boolean + fun log(priority: Int, throwable: Throwable? = null, message: String) +} diff --git a/core/src/main/java/Uuid.kt b/core/src/main/java/Uuid.kt deleted file mode 100644 index c2a7b65..0000000 --- a/core/src/main/java/Uuid.kt +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright 2018 JUUL Labs, Inc. - */ - -package com.juul.able.experimental - -import java.util.UUID - -/** - * @throw [IllegalArgumentException] if receiver does not conform to the string representation as described in [UUID.toString]. - */ -fun String.toUuid(): UUID = UUID.fromString(this) diff --git a/core/src/main/java/android/BluetoothDevice.kt b/core/src/main/java/android/BluetoothDevice.kt index 744d31e..d7af848 100644 --- a/core/src/main/java/android/BluetoothDevice.kt +++ b/core/src/main/java/android/BluetoothDevice.kt @@ -1,31 +1,16 @@ /* - * Copyright 2018 JUUL Labs, Inc. + * Copyright 2020 JUUL Labs, Inc. */ -@file:JvmName("BluetoothDeviceCoreKt") - -package com.juul.able.experimental.android +package com.juul.able.android import android.bluetooth.BluetoothDevice import android.content.Context -import com.juul.able.experimental.ConnectGattResult -import com.juul.able.experimental.CoroutinesDevice -import com.juul.able.experimental.Gatt -import com.juul.able.experimental.connectGattOrNull -import com.juul.able.experimental.messenger.GattCallbackConfig - -fun BluetoothDevice.asCoroutinesDevice( - callbackConfig: GattCallbackConfig = GattCallbackConfig() -): CoroutinesDevice = CoroutinesDevice(this, callbackConfig) +import com.juul.able.device.ConnectGattResult +import com.juul.able.device.CoroutinesDevice suspend fun BluetoothDevice.connectGatt( - context: Context, - autoConnect: Boolean, - callbackConfig: GattCallbackConfig = GattCallbackConfig() -): ConnectGattResult = asCoroutinesDevice(callbackConfig).connectGatt(context, autoConnect) + context: Context +): ConnectGattResult = asCoroutinesDevice().connectGatt(context) -suspend fun BluetoothDevice.connectGattOrNull( - context: Context, - autoConnect: Boolean, - callbackConfig: GattCallbackConfig = GattCallbackConfig() -): Gatt? = asCoroutinesDevice(callbackConfig).connectGattOrNull(context, autoConnect) +private fun BluetoothDevice.asCoroutinesDevice() = CoroutinesDevice(this) diff --git a/core/src/main/java/device/CoroutinesDevice.kt b/core/src/main/java/device/CoroutinesDevice.kt new file mode 100644 index 0000000..824b332 --- /dev/null +++ b/core/src/main/java/device/CoroutinesDevice.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2020 JUUL Labs, Inc. + */ + +package com.juul.able.device + +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt.STATE_CONNECTED +import android.content.Context +import android.os.RemoteException +import com.juul.able.Able +import com.juul.able.device.ConnectGattResult.Failure +import com.juul.able.device.ConnectGattResult.Success +import com.juul.able.gatt.CoroutinesGatt +import com.juul.able.gatt.GattCallback +import com.juul.able.gatt.suspendUntilConnectionState +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.newSingleThreadContext + +private const val DISPATCHER_NAME = "Gatt" + +internal class CoroutinesDevice( + private val device: BluetoothDevice +) : Device { + + /** + * Establishes a connection to the [BluetoothDevice], suspending until connection is successful + * or error occurs. + * + * To cancel an in-flight connection attempt, the Coroutine from which this method was called + * can be canceled: + * + * ``` + * fun connect(context: Context, device: BluetoothDevice) { + * connectJob = async { + * device.connectGatt(context) + * } + * } + * + * fun cancelConnection() { + * connectJob?.cancel() // cancels the above `connectGatt` + * } + * ``` + */ + override suspend fun connectGatt(context: Context): ConnectGattResult { + val dispatcher = newSingleThreadContext("$DISPATCHER_NAME@$device") + val callback = GattCallback(dispatcher) + val bluetoothGatt = device.connectGatt(context, false, callback) + ?: return Failure( + RemoteException("`BluetoothDevice.connectGatt` returned `null` for device $device") + ) + + return try { + val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback) + gatt.suspendUntilConnectionState(STATE_CONNECTED) + Success(gatt) + } catch (cancellation: CancellationException) { + Able.info { "connectGatt() canceled for $this" } + bluetoothGatt.close() + dispatcher.close() + throw cancellation + } catch (failure: Exception) { + Able.warn { "connectGatt() failed for $this" } + bluetoothGatt.close() + dispatcher.close() + Failure(ConnectionFailed("Failed to connect to device $device", failure)) + } + } + + override fun toString(): String = "CoroutinesDevice(device=$device)" +} diff --git a/core/src/main/java/device/Device.kt b/core/src/main/java/device/Device.kt new file mode 100644 index 0000000..48196eb --- /dev/null +++ b/core/src/main/java/device/Device.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2020 JUUL Labs, Inc. + */ + +package com.juul.able.device + +import android.content.Context +import com.juul.able.gatt.Gatt + +class ConnectionFailed(message: String, cause: Throwable) : IllegalStateException(message, cause) + +sealed class ConnectGattResult { + data class Success( + val gatt: Gatt + ) : ConnectGattResult() + + data class Failure( + val cause: Exception + ) : ConnectGattResult() +} + +interface Device { + suspend fun connectGatt(context: Context): ConnectGattResult +} diff --git a/core/src/main/java/gatt/CoroutinesGatt.kt b/core/src/main/java/gatt/CoroutinesGatt.kt new file mode 100644 index 0000000..33d8b80 --- /dev/null +++ b/core/src/main/java/gatt/CoroutinesGatt.kt @@ -0,0 +1,143 @@ +/* + * Copyright 2020 JUUL Labs, Inc. + */ + +@file:Suppress("RedundantUnitReturnType") + +package com.juul.able.gatt + +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothGattService +import android.bluetooth.BluetoothProfile.STATE_DISCONNECTED +import android.os.RemoteException +import com.juul.able.Able +import java.util.UUID +import kotlinx.coroutines.ExecutorCoroutineDispatcher +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +class OutOfOrderGattCallback(message: String) : IllegalStateException(message) + +class CoroutinesGatt internal constructor( + private val bluetoothGatt: BluetoothGatt, + private val dispatcher: ExecutorCoroutineDispatcher, + private val callback: GattCallback +) : Gatt { + + @FlowPreview + override val onConnectionStateChange = callback.onConnectionStateChange.asFlow() + + @FlowPreview + override val onCharacteristicChanged = callback.onCharacteristicChanged.asFlow() + + override val services: List get() = bluetoothGatt.services + override fun getService(uuid: UUID): BluetoothGattService? = bluetoothGatt.getService(uuid) + + override suspend fun disconnect() { + try { + bluetoothGatt.disconnect() + suspendUntilConnectionState(STATE_DISCONNECTED) + } finally { + callback.close(bluetoothGatt) + } + } + + /** + * @throws [RemoteException] if underlying [BluetoothGatt.discoverServices] returns `false`. + * @throws [ConnectionLost] if [Gatt] disconnects while method is executing. + */ + override suspend fun discoverServices(): GattStatus = + performBluetoothAction("discoverServices") { + bluetoothGatt.discoverServices() + } + + /** + * @throws [RemoteException] if underlying [BluetoothGatt.readCharacteristic] returns `false`. + * @throws [ConnectionLost] if [Gatt] disconnects while method is executing. + */ + override suspend fun readCharacteristic( + characteristic: BluetoothGattCharacteristic + ): OnCharacteristicRead = + performBluetoothAction("readCharacteristic") { + bluetoothGatt.readCharacteristic(characteristic) + } + + /** + * @param value applied to [characteristic] when characteristic is written. + * @param writeType applied to [characteristic] when characteristic is written. + * @throws [RemoteException] if underlying [BluetoothGatt.writeCharacteristic] returns `false`. + * @throws [ConnectionLost] if [Gatt] disconnects while method is executing. + */ + override suspend fun writeCharacteristic( + characteristic: BluetoothGattCharacteristic, + value: ByteArray, + writeType: WriteType + ): OnCharacteristicWrite = + performBluetoothAction("writeCharacteristic") { + characteristic.value = value + characteristic.writeType = writeType + bluetoothGatt.writeCharacteristic(characteristic) + } + + /** + * @param value applied to [descriptor] when descriptor is written. + * @throws [RemoteException] if underlying [BluetoothGatt.writeDescriptor] returns `false`. + * @throws [ConnectionLost] if [Gatt] disconnects while method is executing. + */ + override suspend fun writeDescriptor( + descriptor: BluetoothGattDescriptor, + value: ByteArray + ): OnDescriptorWrite = + performBluetoothAction("writeDescriptor") { + descriptor.value = value + bluetoothGatt.writeDescriptor(descriptor) + } + + /** + * @throws [RemoteException] if underlying [BluetoothGatt.requestMtu] returns `false`. + * @throws [ConnectionLost] if [Gatt] disconnects while method is executing. + */ + override suspend fun requestMtu(mtu: Int): OnMtuChanged = + performBluetoothAction("requestMtu") { + bluetoothGatt.requestMtu(mtu) + } + + override fun setCharacteristicNotification( + characteristic: BluetoothGattCharacteristic, + enable: Boolean + ): Boolean { + Able.info { "setCharacteristicNotification → uuid=${characteristic.uuid}, enable=$enable" } + return bluetoothGatt.setCharacteristicNotification(characteristic, enable) + } + + private val lock = Mutex() + + private suspend inline fun performBluetoothAction( + methodName: String, + crossinline action: () -> Boolean + ): T = lock.withLock { + Able.verbose { "$methodName → withContext $dispatcher" } + withContext(dispatcher) { + action.invoke() || throw RemoteException("BluetoothGatt.$methodName returned false") + } + + Able.verbose { "$methodName ← Waiting for BluetoothGattCallback" } + val response = callback.onResponse.receive() + Able.info { "$methodName ← $response" } + + // `lock` should always enforce a 1:1 matching of request to response, but if an Android + // `BluetoothGattCallback` method gets called out of order then we'll cast to the wrong + // response type. + response as? T + ?: throw OutOfOrderGattCallback( + "Unexpected response type ${response.javaClass.simpleName} received for $methodName" + ) + } + + override fun toString(): String = "CoroutinesGatt(device=${bluetoothGatt.device})" +} diff --git a/core/src/main/java/Debug.kt b/core/src/main/java/gatt/Debug.kt similarity index 90% rename from core/src/main/java/Debug.kt rename to core/src/main/java/gatt/Debug.kt index 05da19a..a29e78c 100644 --- a/core/src/main/java/Debug.kt +++ b/core/src/main/java/gatt/Debug.kt @@ -1,8 +1,8 @@ /* - * Copyright 2018 JUUL Labs, Inc. + * Copyright 2020 JUUL Labs, Inc. */ -package com.juul.able.experimental +package com.juul.able.gatt import android.bluetooth.BluetoothGatt.GATT_CONNECTION_CONGESTED import android.bluetooth.BluetoothGatt.GATT_FAILURE @@ -39,13 +39,13 @@ private const val L2CAP_CONN_CANCEL = 256 /** * https://android.googlesource.com/platform/external/bluetooth/bluedroid/+/lollipop-release/stack/include/gatt_api.h#106 */ -private const val GATT_CONN_L2C_FAILURE = 1 -private const val GATT_CONN_TIMEOUT = HCI_ERR_CONNECTION_TOUT -private const val GATT_CONN_TERMINATE_PEER_USER = HCI_ERR_PEER_USER -private const val GATT_CONN_TERMINATE_LOCAL_HOST = HCI_ERR_CONN_CAUSE_LOCAL_HOST -private const val GATT_CONN_FAIL_ESTABLISH = HCI_ERR_CONN_FAILED_ESTABLISHMENT -private const val GATT_CONN_LMP_TIMEOUT = HCI_ERR_LMP_RESPONSE_TIMEOUT -private const val GATT_CONN_CANCEL = L2CAP_CONN_CANCEL +internal const val GATT_CONN_L2C_FAILURE = 1 +internal const val GATT_CONN_TIMEOUT = HCI_ERR_CONNECTION_TOUT +internal const val GATT_CONN_TERMINATE_PEER_USER = HCI_ERR_PEER_USER +internal const val GATT_CONN_TERMINATE_LOCAL_HOST = HCI_ERR_CONN_CAUSE_LOCAL_HOST +internal const val GATT_CONN_FAIL_ESTABLISH = HCI_ERR_CONN_FAILED_ESTABLISHMENT +internal const val GATT_CONN_LMP_TIMEOUT = HCI_ERR_LMP_RESPONSE_TIMEOUT +internal const val GATT_CONN_CANCEL = L2CAP_CONN_CANCEL /** * 0xE0 ~ 0xFC reserved for future use @@ -89,7 +89,7 @@ internal fun GattState.asGattStateString() = when (this) { else -> "STATE_UNKNOWN" }.let { name -> "$name($this)" } -internal fun GattStatus.asGattConnectionStatusString() = when (this) { +internal fun GattConnectionStatus.asGattConnectionStatusString() = when (this) { GATT_SUCCESS -> "GATT_SUCCESS" GATT_CONN_L2C_FAILURE -> "GATT_CONN_L2C_FAILURE" GATT_CONN_TIMEOUT -> "GATT_CONN_TIMEOUT" diff --git a/core/src/main/java/gatt/Events.kt b/core/src/main/java/gatt/Events.kt new file mode 100644 index 0000000..a171e43 --- /dev/null +++ b/core/src/main/java/gatt/Events.kt @@ -0,0 +1,127 @@ +/* + * Copyright 2020 JUUL Labs, Inc. + */ + +package com.juul.able.gatt + +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor + +data class OnConnectionStateChange( + val status: GattStatus, + val newState: GattState +) { + override fun toString(): String { + val connectionStatus = status.asGattConnectionStatusString() + val gattState = newState.asGattStateString() + return "OnConnectionStateChange(status=$connectionStatus, newState=$gattState)" + } +} + +data class OnCharacteristicRead( + val characteristic: BluetoothGattCharacteristic, + val value: ByteArray, + val status: GattStatus +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as OnCharacteristicRead + + return status == other.status && + characteristic.uuid == other.characteristic.uuid && + characteristic.instanceId == other.characteristic.instanceId && + value.contentEquals(other.value) + } + + override fun hashCode(): Int { + var result = characteristic.uuid.hashCode() + result = 31 * result + characteristic.instanceId + result = 31 * result + value.contentHashCode() + result = 31 * result + status + return result + } + + override fun toString(): String { + val uuid = characteristic.uuid + val instanceId = characteristic.instanceId + val size = value.size + val gattStatus = status.asGattStatusString() + return "OnCharacteristicRead(uuid=$uuid, instanceId=$instanceId, value=$value(size=$size), status=$gattStatus)" + } +} + +data class OnCharacteristicWrite( + val characteristic: BluetoothGattCharacteristic, + val status: GattStatus +) { + override fun toString(): String = + "OnCharacteristicWrite(uuid=${characteristic.uuid}, status=${status.asGattStatusString()})" +} + +data class OnCharacteristicChanged( + val characteristic: BluetoothGattCharacteristic, + val value: ByteArray +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as OnCharacteristicChanged + + return characteristic.uuid == other.characteristic.uuid && + characteristic.instanceId == other.characteristic.instanceId && + value.contentEquals(other.value) + } + + override fun hashCode(): Int { + var result = characteristic.uuid.hashCode() + result = 31 * result + characteristic.instanceId + result = 31 * result + value.contentHashCode() + return result + } + + override fun toString(): String = + "OnCharacteristicChanged(uuid=${characteristic.uuid}, value=$value(size=${value.size}))" +} + +data class OnDescriptorRead( + val descriptor: BluetoothGattDescriptor, + val value: ByteArray, + val status: GattStatus +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as OnDescriptorRead + + return status == other.status && + descriptor.uuid == other.descriptor.uuid && + value.contentEquals(other.value) + } + + override fun hashCode(): Int { + var result = descriptor.uuid.hashCode() + result = 31 * result + value.contentHashCode() + result = 31 * result + status + return result + } + + override fun toString(): String { + val uuid = descriptor.uuid + val gattStatus = status.asGattStatusString() + return "OnDescriptorRead(uuid=$uuid, value=$value(size=${value.size}), status=$gattStatus)" + } +} + +data class OnDescriptorWrite( + val descriptor: BluetoothGattDescriptor, + val status: GattStatus +) { + override fun toString(): String = + "OnDescriptorWrite(uuid=${descriptor.uuid}, status=${status.asGattStatusString()})" +} + +data class OnMtuChanged(val mtu: Int, val status: GattStatus) { + override fun toString(): String = + "OnMtuChanged(mtu=$mtu, status=${status.asGattStatusString()})" +} diff --git a/core/src/main/java/Gatt.kt b/core/src/main/java/gatt/Gatt.kt similarity index 54% rename from core/src/main/java/Gatt.kt rename to core/src/main/java/gatt/Gatt.kt index 1714713..f49b540 100644 --- a/core/src/main/java/Gatt.kt +++ b/core/src/main/java/gatt/Gatt.kt @@ -1,27 +1,39 @@ /* - * Copyright 2018 JUUL Labs, Inc. + * Copyright 2020 JUUL Labs, Inc. */ @file:Suppress("RedundantUnitReturnType") -package com.juul.able.experimental +package com.juul.able.gatt import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGatt.GATT_SUCCESS import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT import android.bluetooth.BluetoothGattDescriptor import android.bluetooth.BluetoothGattService import android.bluetooth.BluetoothProfile -import com.juul.able.experimental.messenger.OnCharacteristicChanged -import com.juul.able.experimental.messenger.OnCharacteristicRead -import com.juul.able.experimental.messenger.OnCharacteristicWrite -import com.juul.able.experimental.messenger.OnConnectionStateChange -import com.juul.able.experimental.messenger.OnDescriptorWrite -import com.juul.able.experimental.messenger.OnMtuChanged -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.BroadcastChannel -import java.io.Closeable +import android.bluetooth.BluetoothProfile.STATE_DISCONNECTED +import com.juul.able.Able import java.util.UUID +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.onEach + +/** + * Represents the possible GATT connection statuses as defined in the Android source code. + * + * - [GATT_SUCCESS] + * - [GATT_CONN_L2C_FAILURE] + * - [GATT_CONN_L2C_FAILURE] + * - [GATT_CONN_TIMEOUT] + * - [GATT_CONN_TERMINATE_PEER_USER] + * - [GATT_CONN_TERMINATE_LOCAL_HOST] + * - [GATT_CONN_FAIL_ESTABLISH] + * - [GATT_CONN_LMP_TIMEOUT] + * - [GATT_CONN_CANCEL] + */ +typealias GattConnectionStatus = Int /** * Represents the possible GATT statuses as defined in [BluetoothGatt]: @@ -58,21 +70,26 @@ typealias GattState = Int */ typealias WriteType = Int -interface Gatt : Closeable, CoroutineScope { +class ConnectionLost : Exception() - val onConnectionStateChange: BroadcastChannel - val onCharacteristicChanged: BroadcastChannel +class FailedToDeliverEvent(message: String) : IllegalStateException(message) - val services: List +class GattStatusFailure( + val event: OnConnectionStateChange +) : IllegalStateException("Received $event") - fun requestConnect(): Boolean - fun requestDisconnect(): Unit - fun getService(uuid: UUID): BluetoothGattService? +interface Gatt { + + val onConnectionStateChange: Flow + val onCharacteristicChanged: Flow - suspend fun connect(): Boolean - suspend fun disconnect(): Unit suspend fun discoverServices(): GattStatus + val services: List + fun getService(uuid: UUID): BluetoothGattService? + + suspend fun requestMtu(mtu: Int): OnMtuChanged + suspend fun readCharacteristic( characteristic: BluetoothGattCharacteristic ): OnCharacteristicRead @@ -88,14 +105,37 @@ interface Gatt : Closeable, CoroutineScope { value: ByteArray ): OnDescriptorWrite - suspend fun requestMtu(mtu: Int): OnMtuChanged - fun setCharacteristicNotification( characteristic: BluetoothGattCharacteristic, enable: Boolean ): Boolean + + suspend fun disconnect(): Unit } suspend fun Gatt.writeCharacteristic( - characteristic: BluetoothGattCharacteristic, value: ByteArray + characteristic: BluetoothGattCharacteristic, + value: ByteArray ): OnCharacteristicWrite = writeCharacteristic(characteristic, value, WRITE_TYPE_DEFAULT) + +internal suspend fun Gatt.suspendUntilConnectionState(state: GattState) { + Able.debug { "Suspending until ${state.asGattStateString()}" } + onConnectionStateChange + .onEach { event -> + Able.verbose { "← Received $event while waiting for ${state.asGattStateString()}" } + if (event.status != GATT_SUCCESS) throw GattStatusFailure(event) + } + .firstOrNull { (_, newState) -> newState == state } + .also { + if (it == null) { // Upstream Channel closed due to STATE_DISCONNECTED. + if (state == STATE_DISCONNECTED) { + Able.info { "Reached (implicit) STATE_DISCONNECTED" } + } else { + throw ConnectionLost() + } + } + } + ?.also { (_, newState) -> + Able.info { "Reached ${newState.asGattStateString()}" } + } +} diff --git a/core/src/main/java/gatt/GattCallback.kt b/core/src/main/java/gatt/GattCallback.kt new file mode 100644 index 0000000..ce6b92a --- /dev/null +++ b/core/src/main/java/gatt/GattCallback.kt @@ -0,0 +1,127 @@ +/* + * Copyright 2020 JUUL Labs, Inc. + */ + +package com.juul.able.gatt + +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothProfile.STATE_DISCONNECTED +import android.bluetooth.BluetoothProfile.STATE_DISCONNECTING +import com.juul.able.Able +import kotlinx.coroutines.ExecutorCoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BroadcastChannel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.BUFFERED +import kotlinx.coroutines.channels.Channel.Factory.CONFLATED +import kotlinx.coroutines.channels.sendBlocking + +internal class GattCallback( + private val dispatcher: ExecutorCoroutineDispatcher +) : BluetoothGattCallback() { + + @ExperimentalCoroutinesApi + val onConnectionStateChange = BroadcastChannel(CONFLATED) + + @ExperimentalCoroutinesApi + val onCharacteristicChanged = BroadcastChannel(BUFFERED) + + val onResponse = Channel(CONFLATED) + + private fun onDisconnecting() { + onCharacteristicChanged.close() + onResponse.close(ConnectionLost()) + } + + @ExperimentalCoroutinesApi + fun close(gatt: BluetoothGatt) { + Able.verbose { "Closing GattCallback belonging to device ${gatt.device}" } + onDisconnecting() // Duplicate call in case Android skips STATE_DISCONNECTING. + onConnectionStateChange.close() + gatt.close() + + // todo: Remove once https://github.com/Kotlin/kotlinx.coroutines/issues/261 is fixed. + dispatcher.close() + } + + override fun onConnectionStateChange( + gatt: BluetoothGatt, + status: GattConnectionStatus, + newState: GattState + ) { + val event = OnConnectionStateChange(status, newState) + Able.debug { "← $event" } + onConnectionStateChange.offer(event) + + when (newState) { + STATE_DISCONNECTING -> onDisconnecting() + STATE_DISCONNECTED -> close(gatt) + } + } + + override fun onCharacteristicChanged( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic + ) { + val value = characteristic.value + val event = OnCharacteristicChanged(characteristic, value) + Able.verbose { "← $event" } + onCharacteristicChanged.sendBlocking(event) + } + + override fun onServicesDiscovered(gatt: BluetoothGatt, status: GattStatus) { + Able.verbose { "← OnServicesDiscovered(status=${status.asGattStatusString()})" } + onResponse.offer(status) || + throw FailedToDeliverEvent("OnServicesDiscovered(status=${status.asGattStatusString()})") + } + + override fun onCharacteristicRead( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + status: GattStatus + ) { + val value = characteristic.value + emitEvent(OnCharacteristicRead(characteristic, value, status)) + } + + override fun onCharacteristicWrite( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + status: GattStatus + ) { + emitEvent(OnCharacteristicWrite(characteristic, status)) + } + + override fun onDescriptorRead( + gatt: BluetoothGatt, + descriptor: BluetoothGattDescriptor, + status: GattStatus + ) { + val value = descriptor.value + emitEvent(OnDescriptorRead(descriptor, value, status)) + } + + override fun onDescriptorWrite( + gatt: BluetoothGatt, + descriptor: BluetoothGattDescriptor, + status: GattStatus + ) { + emitEvent(OnDescriptorWrite(descriptor, status)) + } + + override fun onMtuChanged( + gatt: BluetoothGatt, + mtu: Int, + status: Int + ) { + emitEvent(OnMtuChanged(mtu, status)) + } + + private fun emitEvent(event: Any) { + Able.verbose { "← $event" } + onResponse.offer(event) || throw FailedToDeliverEvent(event.toString()) + } +} diff --git a/core/src/main/java/logger/AndroidLogger.kt b/core/src/main/java/logger/AndroidLogger.kt new file mode 100644 index 0000000..5df0428 --- /dev/null +++ b/core/src/main/java/logger/AndroidLogger.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2020 JUUL Labs, Inc. + */ + +package com.juul.able.logger + +import android.util.Log +import com.juul.able.Logger + +private const val TAG = "Able" + +class AndroidLogger : Logger { + + override fun isLoggable( + priority: Int + ): Boolean = Log.isLoggable(TAG, priority) + + override fun log( + priority: Int, + throwable: Throwable?, + message: String + ) { + Log.println(priority, TAG, message) + } +} diff --git a/core/src/main/java/messenger/GattCallback.kt b/core/src/main/java/messenger/GattCallback.kt deleted file mode 100644 index 68b0adc..0000000 --- a/core/src/main/java/messenger/GattCallback.kt +++ /dev/null @@ -1,188 +0,0 @@ -/* - * Copyright 2018 JUUL Labs, Inc. - */ - -package com.juul.able.experimental.messenger - -import android.bluetooth.BluetoothGatt -import android.bluetooth.BluetoothGattCallback -import android.bluetooth.BluetoothGattCharacteristic -import android.bluetooth.BluetoothGattDescriptor -import com.juul.able.experimental.Able -import com.juul.able.experimental.GattState -import com.juul.able.experimental.GattStatus -import com.juul.able.experimental.asGattConnectionStatusString -import com.juul.able.experimental.asGattStateString -import kotlinx.coroutines.channels.BroadcastChannel -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.sync.Mutex - -data class GattCallbackConfig( - val onCharacteristicChangedCapacity: Int = Channel.CONFLATED, - val onServicesDiscoveredCapacity: Int = Channel.CONFLATED, - val onCharacteristicReadCapacity: Int = Channel.CONFLATED, - val onCharacteristicWriteCapacity: Int = Channel.CONFLATED, - val onDescriptorReadCapacity: Int = Channel.CONFLATED, - val onDescriptorWriteCapacity: Int = Channel.CONFLATED, - val onReliableWriteCompletedCapacity: Int = Channel.CONFLATED, - val onMtuChangedCapacity: Int = Channel.CONFLATED -) { - constructor(capacity: Int = Channel.CONFLATED) : this( - onCharacteristicChangedCapacity = capacity, - onServicesDiscoveredCapacity = capacity, - onCharacteristicReadCapacity = capacity, - onCharacteristicWriteCapacity = capacity, - onDescriptorReadCapacity = capacity, - onDescriptorWriteCapacity = capacity, - onReliableWriteCompletedCapacity = capacity, - onMtuChangedCapacity = capacity - ) -} - -internal class GattCallback(config: GattCallbackConfig) : BluetoothGattCallback() { - - internal val onConnectionStateChange = - BroadcastChannel(Channel.CONFLATED) - internal val onCharacteristicChanged = - BroadcastChannel(config.onCharacteristicChangedCapacity) - - internal val onServicesDiscovered = - Channel(config.onServicesDiscoveredCapacity) - internal val onCharacteristicRead = - Channel(config.onCharacteristicReadCapacity) - internal val onCharacteristicWrite = - Channel(config.onCharacteristicWriteCapacity) - internal val onDescriptorRead = Channel(config.onDescriptorReadCapacity) - internal val onDescriptorWrite = Channel(config.onDescriptorWriteCapacity) - internal val onReliableWriteCompleted = - Channel(config.onReliableWriteCompletedCapacity) - internal val onMtuChanged = Channel(config.onMtuChangedCapacity) - - private val gattLock = Mutex() - internal suspend fun waitForGattReady() = gattLock.lock() - internal fun notifyGattReady() { - if (gattLock.isLocked) { - gattLock.unlock() - } - } - - fun close() { - Able.verbose { "close → Begin" } - - onConnectionStateChange.close() - onCharacteristicChanged.close() - - onServicesDiscovered.close() - onCharacteristicRead.close() - onCharacteristicWrite.close() - onDescriptorRead.close() - onDescriptorWrite.close() - onReliableWriteCompleted.close() - - Able.verbose { "close → End" } - } - - override fun onConnectionStateChange( - gatt: BluetoothGatt, - status: GattStatus, - newState: GattState - ) { - Able.debug { - val statusString = status.asGattConnectionStatusString() - val stateString = newState.asGattStateString() - "onConnectionStateChange → status = $statusString, newState = $stateString" - } - - if (!onConnectionStateChange.offer(OnConnectionStateChange(status, newState))) { - Able.warn { "onConnectionStateChange → dropped" } - } - } - - override fun onServicesDiscovered(gatt: BluetoothGatt, status: GattStatus) { - Able.verbose { "onServicesDiscovered → status = $status" } - if (!onServicesDiscovered.offer(status)) { - Able.warn { "onServicesDiscovered → dropped" } - } - notifyGattReady() - } - - override fun onCharacteristicRead( - gatt: BluetoothGatt, - characteristic: BluetoothGattCharacteristic, - status: GattStatus - ) { - Able.verbose { "onCharacteristicRead → uuid = ${characteristic.uuid}" } - val event = OnCharacteristicRead(characteristic, characteristic.value, status) - if (!onCharacteristicRead.offer(event)) { - Able.warn { "onCharacteristicRead → dropped" } - } - notifyGattReady() - } - - override fun onCharacteristicWrite( - gatt: BluetoothGatt, - characteristic: BluetoothGattCharacteristic, - status: GattStatus - ) { - Able.verbose { "onCharacteristicWrite → uuid = ${characteristic.uuid}" } - if (!onCharacteristicWrite.offer(OnCharacteristicWrite(characteristic, status))) { - Able.warn { "onCharacteristicWrite → dropped" } - } - notifyGattReady() - } - - override fun onCharacteristicChanged( - gatt: BluetoothGatt, - characteristic: BluetoothGattCharacteristic - ) { - Able.verbose { "onCharacteristicChanged → uuid = ${characteristic.uuid}" } - val event = OnCharacteristicChanged(characteristic, characteristic.value) - if (!onCharacteristicChanged.offer(event)) { - Able.warn { "OnCharacteristicChanged → dropped" } - } - - // We don't call `notifyGattReady` because `onCharacteristicChanged` is called whenever a - // characteristic changes (after notification(s) have been enabled) so is not directly tied - // to a specific call (or lock). - } - - override fun onDescriptorRead( - gatt: BluetoothGatt, - descriptor: BluetoothGattDescriptor, - status: GattStatus - ) { - Able.verbose { "onDescriptorRead → uuid = ${descriptor.uuid}" } - if (!onDescriptorRead.offer(OnDescriptorRead(descriptor, descriptor.value, status))) { - Able.warn { "onDescriptorRead → dropped" } - } - notifyGattReady() - } - - override fun onDescriptorWrite( - gatt: BluetoothGatt, - descriptor: BluetoothGattDescriptor, - status: GattStatus - ) { - Able.verbose { "onDescriptorWrite → uuid = ${descriptor.uuid}" } - if (!onDescriptorWrite.offer(OnDescriptorWrite(descriptor, status))) { - Able.warn { "onDescriptorWrite → dropped" } - } - notifyGattReady() - } - - override fun onReliableWriteCompleted(gatt: BluetoothGatt, status: Int) { - Able.verbose { "onReliableWriteCompleted → status = $status" } - if (!onReliableWriteCompleted.offer(OnReliableWriteCompleted(status))) { - Able.warn { "onReliableWriteCompleted → dropped" } - } - notifyGattReady() - } - - override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { - Able.verbose { "onMtuChanged → status = $status" } - if (!onMtuChanged.offer(OnMtuChanged(mtu, status))) { - Able.warn { "onMtuChanged → dropped" } - } - notifyGattReady() - } -} diff --git a/core/src/main/java/messenger/Messages.kt b/core/src/main/java/messenger/Messages.kt deleted file mode 100644 index 894b02f..0000000 --- a/core/src/main/java/messenger/Messages.kt +++ /dev/null @@ -1,137 +0,0 @@ -/* - * Copyright 2018 JUUL Labs, Inc. - */ - -package com.juul.able.experimental.messenger - -import android.bluetooth.BluetoothGattCharacteristic -import android.bluetooth.BluetoothGattDescriptor -import com.juul.able.experimental.GattState -import com.juul.able.experimental.GattStatus -import kotlinx.coroutines.CompletableDeferred -import java.util.Arrays - -internal sealed class Message { - - abstract val response: CompletableDeferred - - internal data class DiscoverServices( - override val response: CompletableDeferred - ) : Message() - - internal data class ReadCharacteristic( - val characteristic: BluetoothGattCharacteristic, - override val response: CompletableDeferred - ) : Message() - - internal data class WriteCharacteristic( - val characteristic: BluetoothGattCharacteristic, - val value: ByteArray, - val writeType: Int, - override val response: CompletableDeferred - ) : Message() - - internal data class RequestMtu( - val mtu: Int, - override val response: CompletableDeferred - ) : Message() - - internal data class WriteDescriptor( - val descriptor: BluetoothGattDescriptor, - val value: ByteArray, - override val response: CompletableDeferred - ) : Message() -} - -data class OnConnectionStateChange( - val status: GattStatus, - val newState: GattState -) - -data class OnCharacteristicRead( - val characteristic: BluetoothGattCharacteristic, - val value: ByteArray, - val status: GattStatus -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - @Suppress("UnsafeCast") // Safe per Java class comparison above. - other as OnCharacteristicRead - - return status == other.status - && characteristic.uuid == other.characteristic.uuid - && Arrays.equals(value, other.value) - } - - @Suppress("MagicNumber") - override fun hashCode(): Int { - var result = characteristic.uuid.hashCode() - result = 31 * result + Arrays.hashCode(value) - result = 31 * result + status - return result - } -} - -data class OnCharacteristicWrite( - val characteristic: BluetoothGattCharacteristic, - val status: GattStatus -) - -data class OnCharacteristicChanged( - val characteristic: BluetoothGattCharacteristic, - val value: ByteArray -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - @Suppress("UnsafeCast") // Safe per Java class comparison above. - other as OnCharacteristicChanged - - return characteristic.uuid == other.characteristic.uuid && Arrays.equals(value, other.value) - } - - @Suppress("MagicNumber") - override fun hashCode(): Int { - var result = characteristic.uuid.hashCode() - result = 31 * result + Arrays.hashCode(value) - return result - } -} - -data class OnDescriptorRead( - val descriptor: BluetoothGattDescriptor, - val value: ByteArray, - val status: GattStatus -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - @Suppress("UnsafeCast") // Safe per Java class comparison above. - other as OnDescriptorRead - - return status == other.status - && descriptor.uuid == other.descriptor.uuid - && Arrays.equals(value, other.value) - } - - @Suppress("MagicNumber") - override fun hashCode(): Int { - var result = descriptor.uuid.hashCode() - result = 31 * result + Arrays.hashCode(value) - result = 31 * result + status - return result - } -} - -data class OnDescriptorWrite( - val descriptor: BluetoothGattDescriptor, - val status: GattStatus -) - -data class OnReliableWriteCompleted(val status: GattStatus) - -data class OnMtuChanged(val mtu: Int, val status: GattStatus) diff --git a/core/src/main/java/messenger/Messenger.kt b/core/src/main/java/messenger/Messenger.kt deleted file mode 100644 index b5b890d..0000000 --- a/core/src/main/java/messenger/Messenger.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright 2018 JUUL Labs, Inc. - */ - -package com.juul.able.experimental.messenger - -import android.bluetooth.BluetoothGatt -import com.juul.able.experimental.Able -import com.juul.able.experimental.messenger.Message.DiscoverServices -import com.juul.able.experimental.messenger.Message.ReadCharacteristic -import com.juul.able.experimental.messenger.Message.RequestMtu -import com.juul.able.experimental.messenger.Message.WriteCharacteristic -import com.juul.able.experimental.messenger.Message.WriteDescriptor -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.actor -import kotlinx.coroutines.channels.consumeEach -import kotlinx.coroutines.newSingleThreadContext - -class Messenger internal constructor( - private val bluetoothGatt: BluetoothGatt, - internal val callback: GattCallback -) { - - internal suspend fun send(message: Message) = actor.send(message) - - private val context = newSingleThreadContext("Gatt") - private val actor = GlobalScope.actor(context) { - Able.verbose { "Begin" } - consumeEach { message -> - Able.debug { "Waiting for Gatt" } - callback.waitForGattReady() - - Able.debug { "Processing ${message.javaClass.simpleName}" } - val result: Boolean = when (message) { - is DiscoverServices -> bluetoothGatt.discoverServices() - is ReadCharacteristic -> bluetoothGatt.readCharacteristic(message.characteristic) - is RequestMtu -> bluetoothGatt.requestMtu(message.mtu) - is WriteCharacteristic -> { - message.characteristic.value = message.value - message.characteristic.writeType = message.writeType - bluetoothGatt.writeCharacteristic(message.characteristic) - } - is WriteDescriptor -> { - message.descriptor.value = message.value - bluetoothGatt.writeDescriptor(message.descriptor) - } - } - - Able.debug { "Processed ${message.javaClass.simpleName}, result=$result" } - message.response.complete(result) - - if (!result) { - callback.notifyGattReady() - } - } - Able.verbose { "End" } - } - - fun close() { - Able.verbose { "close → Begin" } - callback.close() - actor.close() - closeContext() - Able.verbose { "close → End" } - } - - /** - * Explicitly close context (this is only needed until #261 is fixed). - * - * [Kotlin Coroutines Issue #261](https://github.com/Kotlin/kotlinx.coroutines/issues/261) - * [Coroutines actor test Gist](https://gist.github.com/twyatt/c51f81d763a6ee39657233fa725f5435) - */ - private fun closeContext(): Unit = context.close() -} diff --git a/core/src/test/java/CoroutinesGattTest.kt b/core/src/test/java/CoroutinesGattTest.kt deleted file mode 100644 index b444c89..0000000 --- a/core/src/test/java/CoroutinesGattTest.kt +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright 2018 JUUL Labs, Inc. - */ - -package com.juul.able.experimental - -import android.bluetooth.BluetoothGatt -import android.bluetooth.BluetoothGatt.GATT_SUCCESS -import android.bluetooth.BluetoothGattCharacteristic -import android.bluetooth.BluetoothProfile.STATE_CONNECTED -import android.os.RemoteException -import com.juul.able.experimental.messenger.GattCallback -import com.juul.able.experimental.messenger.GattCallbackConfig -import com.juul.able.experimental.messenger.Messenger -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.channels.consumeEach -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout -import org.junit.BeforeClass -import org.junit.Test -import java.nio.ByteBuffer -import java.util.UUID -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith - -class CoroutinesGattTest { - - companion object { - @BeforeClass - @JvmStatic - fun setUp() { - Able.logger = NoOpLogger() - } - } - - /** - * Verifies that [BluetoothGattCharacteristic]s that are received by the [GattCallback] remain - * in-order when consumed from the [Gatt.onCharacteristicChanged] channel. - */ - @Test - fun correctOrderOfOnCharacteristicChanged() { - val numberOfFakeCharacteristicNotifications = 10_000L - val numberOfFakeBinderThreads = 10 - val onCharacteristicChangedCapacity = numberOfFakeCharacteristicNotifications.toInt() - - val bluetoothGatt = mockk() - val callback = GattCallback(GattCallbackConfig(onCharacteristicChangedCapacity)).apply { - onConnectionStateChange(bluetoothGatt, GATT_SUCCESS, STATE_CONNECTED) - } - val messenger = Messenger(bluetoothGatt, callback) - val gatt = CoroutinesGatt(bluetoothGatt, messenger) - - val binderThreads = FakeBinderThreadHandler(numberOfFakeBinderThreads) - for (i in 0..numberOfFakeCharacteristicNotifications) { - binderThreads.enqueue { - val characteristic = mockCharacteristic(data = i.asByteArray()) - callback.onCharacteristicChanged(bluetoothGatt, characteristic) - } - } - - runBlocking { - var i = 0L - gatt.onCharacteristicChanged.openSubscription().also { subscription -> - binderThreads.start() - - subscription.consumeEach { (_, value) -> - assertEquals(i++, value.longValue) - - if (i == numberOfFakeCharacteristicNotifications) { - subscription.cancel() - } - } - } - assertEquals(numberOfFakeCharacteristicNotifications, i) - - binderThreads.stop() - } - } - - @Test - fun readCharacteristic_bluetoothGattReturnsFalse_doesNotDeadlock() { - val bluetoothGatt = mockk { - every { readCharacteristic(any()) } returns false - } - val callback = GattCallback(GattCallbackConfig()).apply { - onConnectionStateChange(bluetoothGatt, GATT_SUCCESS, STATE_CONNECTED) - } - val messenger = Messenger(bluetoothGatt, callback) - val gatt = CoroutinesGatt(bluetoothGatt, messenger) - - assertFailsWith(RemoteException::class, "First invocation") { - runBlocking { - withTimeout(5_000L) { - gatt.readCharacteristic(mockCharacteristic()) - } - } - } - - // Perform another read to verify that the previous failure did not deadlock `Messenger`. - assertFailsWith(RemoteException::class, "Second invocation") { - runBlocking { - withTimeout(5_000L) { - gatt.readCharacteristic(mockCharacteristic()) - } - } - } - } -} - -private fun mockCharacteristic( - uuid: UUID = UUID.randomUUID(), - data: ByteArray? = null -): BluetoothGattCharacteristic = mockk { - every { getUuid() } returns uuid - every { value } returns data -} - -private fun Long.asByteArray(): ByteArray = ByteBuffer.allocate(8).putLong(this).array() - -private val ByteArray.longValue: Long - get() = ByteBuffer.wrap(this).long diff --git a/core/src/test/java/EventsTest.kt b/core/src/test/java/EventsTest.kt new file mode 100644 index 0000000..a196d8c --- /dev/null +++ b/core/src/test/java/EventsTest.kt @@ -0,0 +1,139 @@ +/* + * Copyright 2020 JUUL Labs, Inc. + */ + +package com.juul.able + +import android.bluetooth.BluetoothGatt.GATT_SUCCESS +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import com.juul.able.gatt.FakeBluetoothGattCharacteristic as FakeCharacteristic +import com.juul.able.gatt.FakeBluetoothGattDescriptor as FakeDescriptor +import com.juul.able.gatt.OnCharacteristicChanged +import com.juul.able.gatt.OnCharacteristicRead +import com.juul.able.gatt.OnDescriptorRead +import com.juul.able.gatt.OnMtuChanged +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import nl.jqno.equalsverifier.EqualsVerifier + +private val testUuid = "01234567-89ab-cdef-0123-456789abcdef".toUuid() + +class EventsTest { + + @Test + fun `Verify equals of OnCharacteristicRead`() { + verifyEquals() + } + + @Test + fun `Verify toString of OnCharacteristicRead`() { + val value = createByteArray(size = 256) + val event = OnCharacteristicRead( + characteristic = FakeCharacteristic(testUuid), + value = value, + status = GATT_SUCCESS + ) + + assertEquals( + expected = "OnCharacteristicRead(" + + "uuid=$testUuid, " + + "instanceId=0, " + + "value=[B@${value.hashCode().hex()}(size=256), " + + "status=GATT_SUCCESS(0)" + + ")", + actual = event.toString() + ) + } + + @Test + fun `Verify equals of OnCharacteristicChanged`() { + verifyEquals() + } + + @Test + fun `Verify toString of OnCharacteristicChanged`() { + val value = createByteArray(size = 256) + val event = OnCharacteristicChanged( + characteristic = FakeCharacteristic(testUuid), + value = value + ) + + assertEquals( + expected = "OnCharacteristicChanged(" + + "uuid=$testUuid, " + + "value=[B@${value.hashCode().hex()}(size=256)" + + ")", + actual = event.toString() + ) + } + + @Test + fun `Verify equals of OnDescriptorRead`() { + verifyEquals() + } + + @Test + fun `Verify toString of OnDescriptorRead`() { + val value = createByteArray(size = 256) + val event = OnDescriptorRead( + descriptor = FakeDescriptor(testUuid), + value = value, + status = GATT_SUCCESS + ) + + assertEquals( + expected = "OnDescriptorRead(" + + "uuid=$testUuid, " + + "value=[B@${value.hashCode().hex()}(size=256), " + + "status=GATT_SUCCESS(0)" + + ")", + actual = event.toString() + ) + } + + @Test + fun `Verify equals of OnMtuChanged`() { + verifyEquals() + } + + @Test + fun `Verify toString of OnMtuChanged`() { + val event = OnMtuChanged(status = GATT_SUCCESS, mtu = 512) + + assertEquals( + expected = "OnMtuChanged(mtu=512, status=GATT_SUCCESS(0))", + actual = event.toString() + ) + } +} + +private val redCharacteristic = FakeCharacteristic("63057836-0b22-4341-969a-8fee3a8be2b3".toUuid()) +private val blackCharacteristic = FakeCharacteristic("2a5346f9-1aec-4752-acec-5d269aa96e7d".toUuid()) + +private val redDescriptor = FakeDescriptor("bce47f52-6c2a-43e9-a382-2c460fcc6f6c".toUuid()) +private val blackDescriptor = FakeDescriptor("de923c26-b18a-474a-84e0-7837300fc666".toUuid()) + +/** + * Preconfigures [EqualsVerifier] for validating proper implementation of `data class`es in the + * `Messages.kt` file that have custom `equals` and `hashCode` implementations: + * + * > `EqualsVerifier` can be used in unit tests to verify whether the contract for the `equals` and + * > `hashCode` methods in a class is met. + */ +private inline fun verifyEquals() { + EqualsVerifier + .forClass(T::class.java) + .withPrefabValues(BluetoothGattDescriptor::class.java, redDescriptor, blackDescriptor) + .withPrefabValues( + BluetoothGattCharacteristic::class.java, + redCharacteristic, + blackCharacteristic + ) + .verify() +} + +private fun createByteArray(size: Int) = ByteArray(size) { it.toByte() } +private fun Int.hex() = Integer.toHexString(this) +private fun String.toUuid(): UUID = UUID.fromString(this) diff --git a/core/src/test/java/FakeBinderThreadHandler.kt b/core/src/test/java/FakeBinderThreadHandler.kt index a3c2ada..62dce9e 100644 --- a/core/src/test/java/FakeBinderThreadHandler.kt +++ b/core/src/test/java/FakeBinderThreadHandler.kt @@ -1,8 +1,8 @@ /* - * Copyright 2018 JUUL Labs, Inc. + * Copyright 2020 JUUL Labs, Inc. */ -package com.juul.able.experimental +package com.juul.able import java.util.ArrayDeque import kotlin.concurrent.thread @@ -78,7 +78,7 @@ class FakeBinderThreadHandler(private val binderThreadCount: Int) { operationQueue.poll()?.invoke() } } - } catch (e: InterruptedException) { + } catch (exception: InterruptedException) { // We've been asked to shutdown, no need to log exception. } } diff --git a/core/src/test/java/MessagesTest.kt b/core/src/test/java/MessagesTest.kt deleted file mode 100644 index c70e546..0000000 --- a/core/src/test/java/MessagesTest.kt +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2018 JUUL Labs, Inc. - */ - -package com.juul.able.experimental - -import android.bluetooth.BluetoothGattCharacteristic -import android.bluetooth.BluetoothGattDescriptor -import com.juul.able.experimental.messenger.OnCharacteristicChanged -import com.juul.able.experimental.messenger.OnCharacteristicRead -import com.juul.able.experimental.messenger.OnDescriptorRead -import io.mockk.every -import io.mockk.mockk -import nl.jqno.equalsverifier.EqualsVerifier -import org.junit.Test -import java.util.UUID - -private val redDescriptor = mockDescriptor("bce47f52-6c2a-43e9-a382-2c460fcc6f6c") -private val blackDescriptor = mockDescriptor("de923c26-b18a-474a-84e0-7837300fc666") -private val redCharacteristic = mockCharacteristic("63057836-0b22-4341-969a-8fee3a8be2b3") -private val blackCharacteristic = mockCharacteristic("2a5346f9-1aec-4752-acec-5d269aa96e7d") - -class MessagesTest { - - @Test - fun onCharacteristicReadEquals() { - verifyEquals() - } - - @Test - fun onCharacteristicChangedEquals() { - verifyEquals() - } - - @Test - fun onDescriptorReadEquals() { - verifyEquals() - } -} - -private fun mockDescriptor(uuidString: String): BluetoothGattDescriptor { - val uuid = UUID.fromString(uuidString) - return mockk { - every { getUuid() } returns uuid - } -} - -private fun mockCharacteristic(uuidString: String): BluetoothGattCharacteristic { - val uuid = UUID.fromString(uuidString) - return mockk { - every { getUuid() } returns uuid - } -} - -/** - * Preconfigures [EqualsVerifier] for validating proper implementation of `data class`es in the - * `Messages.kt` file that have custom `equals` and `hashCode` implementations: - * - * > `EqualsVerifier` can be used in unit tests to verify whether the contract for the `equals` and - * > `hashCode` methods in a class is met. - */ -private inline fun verifyEquals() { - EqualsVerifier - .forClass(T::class.java) - .withPrefabValues(BluetoothGattDescriptor::class.java, redDescriptor, blackDescriptor) - .withPrefabValues( - BluetoothGattCharacteristic::class.java, - redCharacteristic, - blackCharacteristic - ) - .verify() -} diff --git a/core/src/test/java/device/CoroutinesDeviceTest.kt b/core/src/test/java/device/CoroutinesDeviceTest.kt new file mode 100644 index 0000000..66cbf2c --- /dev/null +++ b/core/src/test/java/device/CoroutinesDeviceTest.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2020 JUUL Labs, Inc. + */ + +package com.juul.able.device + +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGatt.GATT_SUCCESS +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothProfile.STATE_CONNECTED +import android.bluetooth.BluetoothProfile.STATE_CONNECTING +import android.bluetooth.BluetoothProfile.STATE_DISCONNECTED +import com.juul.able.device.ConnectGattResult.Failure +import com.juul.able.device.ConnectGattResult.Success +import com.juul.able.gatt.ConnectionLost +import com.juul.able.gatt.GATT_CONN_CANCEL +import com.juul.able.gatt.GattCallback +import com.juul.able.gatt.GattStatusFailure +import com.juul.able.gatt.OnConnectionStateChange +import com.juul.able.logger.ConsoleLoggerTestRule +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import org.junit.Rule + +class CoroutinesDeviceTest { + + @get:Rule + val loggerRule = ConsoleLoggerTestRule() + + @Test + fun `Non-success GATT status during connectGatt returns Failure`() = runBlocking { + val callbackSlot = slot() + lateinit var bluetoothGatt: BluetoothGatt + val bluetoothDevice = mockk { + bluetoothGatt = createBluetoothGatt(this@mockk) + every { connectGatt(any(), false, capture(callbackSlot)) } returns bluetoothGatt + every { this@mockk.toString() } returns "00:11:22:33:FF:EE" + } + val device = CoroutinesDevice(bluetoothDevice) + + launch { + // Wait for `CoroutinesGatt` to spin up and provide us with the `GattCallback`. + while (!callbackSlot.isCaptured) yield() + val callback = callbackSlot.captured as GattCallback + + callback.onConnectionStateChange(bluetoothGatt, GATT_SUCCESS, STATE_CONNECTING) + callback.onConnectionStateChange(bluetoothGatt, GATT_CONN_CANCEL, STATE_CONNECTED) + } + + val failure = device.connectGatt(mockk()) as Failure + + assertEquals>( + expected = ConnectionFailed::class.java, + actual = failure.cause.javaClass + ) + assertEquals( + expected = OnConnectionStateChange(GATT_CONN_CANCEL, STATE_CONNECTED), + actual = (failure.cause.cause as GattStatusFailure).event + ) + verify { bluetoothGatt.close() } + } + + @Test + fun `Success GATT status during connectGatt returns Success`() = runBlocking { + val callbackSlot = slot() + lateinit var bluetoothGatt: BluetoothGatt + val bluetoothDevice = mockk { + bluetoothGatt = createBluetoothGatt(this@mockk) + every { connectGatt(any(), false, capture(callbackSlot)) } returns bluetoothGatt + every { this@mockk.toString() } returns "00:11:22:33:FF:EE" + } + val device = CoroutinesDevice(bluetoothDevice) + + launch { + // Wait for `CoroutinesGatt` to spin up and provide us with the `GattCallback`. + while (!callbackSlot.isCaptured) yield() + val callback = callbackSlot.captured as GattCallback + + callback.onConnectionStateChange(bluetoothGatt, GATT_SUCCESS, STATE_CONNECTING) + callback.onConnectionStateChange(bluetoothGatt, GATT_SUCCESS, STATE_CONNECTED) + } + + val result = device.connectGatt(mockk()) + + assertEquals>( + expected = Success::class.java, + actual = result.javaClass + ) + verify(exactly = 0) { bluetoothGatt.close() } + } + + @Test + fun `Receive STATE_DISCONNECTED during connectGatt returns Failure`() = runBlocking { + val callbackSlot = slot() + lateinit var bluetoothGatt: BluetoothGatt + val bluetoothDevice = mockk { + bluetoothGatt = createBluetoothGatt(this@mockk) + every { connectGatt(any(), false, capture(callbackSlot)) } returns bluetoothGatt + every { this@mockk.toString() } returns "00:11:22:33:FF:EE" + } + val device = CoroutinesDevice(bluetoothDevice) + + launch { + // Wait for `CoroutinesGatt` to spin up and provide us with the `GattCallback`. + while (!callbackSlot.isCaptured) yield() + val callback = callbackSlot.captured as GattCallback + callback.onConnectionStateChange(bluetoothGatt, GATT_SUCCESS, STATE_DISCONNECTED) + } + + val failure = device.connectGatt(mockk()) as Failure + + assertEquals>( + expected = ConnectionFailed::class.java, + actual = failure.cause.javaClass + ) + assertEquals>( + expected = ConnectionLost::class.java, + actual = failure.cause.cause!!.javaClass + ) + verify { bluetoothGatt.close() } + } + + @Test + fun `Cancelling during connectGatt closes underlying BluetoothGatt`() = runBlocking { + val callbackSlot = slot() + lateinit var bluetoothGatt: BluetoothGatt + val bluetoothDevice = mockk { + bluetoothGatt = createBluetoothGatt(this@mockk) + every { connectGatt(any(), false, capture(callbackSlot)) } returns bluetoothGatt + every { this@mockk.toString() } returns "00:11:22:33:FF:EE" + } + val device = CoroutinesDevice(bluetoothDevice) + + val job = launch { + device.connectGatt(mockk()) + } + + // Wait for `CoroutinesGatt` to spin up and provide us with the `GattCallback`. + while (!callbackSlot.isCaptured) yield() + job.cancelAndJoin() + + verify { bluetoothGatt.close() } + } +} + +private fun createBluetoothGatt( + bluetoothDevice: BluetoothDevice +): BluetoothGatt = mockk { + every { device } returns bluetoothDevice + every { close() } returns Unit +} diff --git a/core/src/test/java/gatt/CoroutinesGattTest.kt b/core/src/test/java/gatt/CoroutinesGattTest.kt new file mode 100644 index 0000000..851ac4d --- /dev/null +++ b/core/src/test/java/gatt/CoroutinesGattTest.kt @@ -0,0 +1,451 @@ +/* + * Copyright 2020 JUUL Labs, Inc. + */ + +package com.juul.able.gatt + +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGatt.GATT_SUCCESS +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothProfile.STATE_CONNECTED +import android.bluetooth.BluetoothProfile.STATE_DISCONNECTED +import android.bluetooth.BluetoothProfile.STATE_DISCONNECTING +import android.os.RemoteException +import com.juul.able.gatt.FakeBluetoothGattCharacteristic as FakeCharacteristic +import com.juul.able.gatt.FakeBluetoothGattDescriptor as FakeDescriptor +import com.juul.able.logger.ConsoleLoggerTestRule +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import java.util.UUID +import java.util.concurrent.TimeUnit.SECONDS +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Rule + +private val testUuid = UUID.fromString("01234567-89ab-cdef-0123-456789abcdef") + +class CoroutinesGattTest { + + @get:Rule + val loggerRule = ConsoleLoggerTestRule() + + @Test + fun `discoverServices propagates result`() { + createDispatcher().use { dispatcher -> + val callback = GattCallback(dispatcher) + val bluetoothGatt = mockk { + every { discoverServices() } answers { + callback.onServicesDiscovered(this@mockk, GATT_SUCCESS) + true + } + } + + val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback) + val response = runBlocking { + gatt.discoverServices() + } + + assertEquals( + expected = GATT_SUCCESS, + actual = response + ) + } + } + + @Test + fun `discoverServices throws RemoteException when BluetoothGatt returns false`() { + createDispatcher().use { dispatcher -> + val callback = GattCallback(dispatcher) + val bluetoothGatt = mockk { + every { discoverServices() } returns false + } + + val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback) + assertFailsWith { + runBlocking { + gatt.discoverServices() + } + } + } + } + + @Test + fun `readCharacteristic propagates result`() { + val characteristic = FakeCharacteristic(testUuid) + createDispatcher().use { dispatcher -> + val callback = GattCallback(dispatcher) + val value = ByteArray(256) { it.toByte() } + val bluetoothGatt = mockk { + every { readCharacteristic(characteristic) } answers { + characteristic.value = value + callback.onCharacteristicRead(this@mockk, characteristic, GATT_SUCCESS) + true + } + } + + val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback) + val response = runBlocking { + gatt.readCharacteristic(characteristic) + } + + assertEquals( + expected = OnCharacteristicRead(characteristic, value, GATT_SUCCESS), + actual = response + ) + } + } + + @Test + fun `readCharacteristic throws RemoteException when BluetoothGatt returns false`() { + createDispatcher().use { dispatcher -> + val callback = GattCallback(dispatcher) + val bluetoothGatt = mockk { + every { readCharacteristic(any()) } returns false + } + + val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback) + + assertFailsWith { + runBlocking { + gatt.readCharacteristic(createCharacteristic()) + } + } + } + } + + @Test + fun `writeCharacteristic propagates result`() { + val characteristic = FakeCharacteristic(testUuid) + val slot = slot() + createDispatcher().use { dispatcher -> + val callback = GattCallback(dispatcher) + val bluetoothGatt = mockk { + every { writeCharacteristic(capture(slot)) } answers { + callback.onCharacteristicWrite(this@mockk, characteristic, GATT_SUCCESS) + true + } + } + + val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback) + val response = runBlocking { + gatt.writeCharacteristic(characteristic, byteArrayOf(), WRITE_TYPE_DEFAULT) + } + + assertEquals( + expected = OnCharacteristicWrite(characteristic, GATT_SUCCESS), + actual = response + ) + } + } + + @Test + fun `ByteArray passed as parameter of writeCharacteristic is applied to BluetoothGattCharacteristic`() { + val characteristic = FakeCharacteristic(testUuid) + val slot = slot() + createDispatcher().use { dispatcher -> + val callback = GattCallback(dispatcher) + val bluetoothGatt = mockk { + every { writeCharacteristic(capture(slot)) } answers { + callback.onCharacteristicWrite(this@mockk, characteristic, GATT_SUCCESS) + true + } + } + + val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback) + runBlocking { + gatt.writeCharacteristic(characteristic, byteArrayOf(0xF, 0x0, 0x0, 0xD)) + } + + // Convert to `List` so equality verification is done on the contents. + assertEquals( + expected = byteArrayOf(0xF, 0x0, 0x0, 0xD).toList(), + actual = slot.captured.value.toList() + ) + } + } + + @Test + fun `writeCharacteristic throws RemoteException when BluetoothGatt returns false`() { + createDispatcher().use { dispatcher -> + val callback = GattCallback(dispatcher) + val bluetoothGatt = mockk { + every { writeCharacteristic(any()) } returns false + } + + val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback) + + assertFailsWith { + runBlocking { + gatt.writeCharacteristic(createCharacteristic(), byteArrayOf()) + } + } + } + } + + @Test + fun `writeDescriptor propagates result`() { + val descriptor = FakeDescriptor(testUuid) + val slot = slot() + createDispatcher().use { dispatcher -> + val callback = GattCallback(dispatcher) + val bluetoothGatt = mockk { + every { writeDescriptor(capture(slot)) } answers { + callback.onDescriptorWrite(this@mockk, descriptor, GATT_SUCCESS) + true + } + } + + val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback) + val response = runBlocking { + gatt.writeDescriptor(descriptor, byteArrayOf()) + } + + assertEquals( + expected = OnDescriptorWrite(descriptor, GATT_SUCCESS), + actual = response + ) + } + } + + @Test + fun `ByteArray passed as parameter of writeDescriptor is applied to BluetoothGattDescriptor`() { + val descriptor = FakeDescriptor(testUuid) + val slot = slot() + createDispatcher().use { dispatcher -> + val callback = GattCallback(dispatcher) + val bluetoothGatt = mockk { + every { writeDescriptor(capture(slot)) } answers { + callback.onDescriptorWrite(this@mockk, descriptor, GATT_SUCCESS) + true + } + } + + val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback) + runBlocking { + gatt.writeDescriptor(descriptor, byteArrayOf(0xF, 0x0, 0x0, 0xD)) + } + + // Convert to `List` so equality verification is done on the contents. + assertEquals( + expected = byteArrayOf(0xF, 0x0, 0x0, 0xD).toList(), + actual = slot.captured.value.toList() + ) + } + } + + @Test + fun `writeDescriptor throws RemoteException when BluetoothGatt returns false`() { + createDispatcher().use { dispatcher -> + val callback = GattCallback(dispatcher) + val bluetoothGatt = mockk { + every { writeDescriptor(any()) } returns false + } + + val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback) + + assertFailsWith { + runBlocking { + gatt.writeDescriptor(createDescriptor(), byteArrayOf()) + } + } + } + } + + @Test + fun `readCharacteristic returning false does not cause a deadlock`() = runBlocking { + createDispatcher().use { dispatcher -> + val bluetoothGatt = mockk { + every { readCharacteristic(any()) } returns false + every { device } returns mockk { + every { this@mockk.toString() } returns "00:11:22:33:FF:EE" + } + } + val callback = GattCallback(dispatcher) + val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback) + + callback.onConnectionStateChange(bluetoothGatt, GATT_SUCCESS, STATE_CONNECTED) + + withTimeout(SECONDS.toMillis(10L)) { + assertFailsWith("First invocation") { + gatt.readCharacteristic(createCharacteristic()) + } + + // Perform another read to verify that the previous failure didn't deadlock. + assertFailsWith("Second invocation") { + gatt.readCharacteristic(createCharacteristic()) + } + } + } + } + + @Test + fun `Out-of-order BluetoothGattCallback call throws OutOfOrderGattCallback`() { + createDispatcher().use { dispatcher -> + val callback = GattCallback(dispatcher) + val bluetoothGatt = mockk { + every { readCharacteristic(any()) } answers { + callback.onDescriptorWrite(this@mockk, createDescriptor(), GATT_SUCCESS) + true + } + } + + val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback) + runBlocking { + assertFailsWith { + gatt.readCharacteristic(createCharacteristic()) + } + } + } + } + + @Test + fun `BluetoothGatt is closed on disconnect`() { + createDispatcher().use { dispatcher -> + val callback = GattCallback(dispatcher) + val bluetoothGatt = mockk { + every { device } returns mockk { + every { close() } returns Unit + every { this@mockk.toString() } returns "00:11:22:33:FF:EE" + } + every { disconnect() } answers { + callback.onConnectionStateChange(this@mockk, GATT_SUCCESS, STATE_DISCONNECTING) + callback.onConnectionStateChange(this@mockk, GATT_SUCCESS, STATE_DISCONNECTED) + } + } + + val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback) + runBlocking { + gatt.disconnect() + } + + verify { bluetoothGatt.close() } + } + } + + @Test + fun `BluetoothGatt is closed on timeout during disconnect`() { + createDispatcher().use { dispatcher -> + val callback = GattCallback(dispatcher) + val bluetoothGatt = mockk { + every { device } returns mockk { + every { close() } returns Unit + every { this@mockk.toString() } returns "00:11:22:33:FF:EE" + } + every { disconnect() } answers { + callback.onConnectionStateChange(this@mockk, GATT_SUCCESS, STATE_DISCONNECTING) + } + } + + val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback) + runBlocking { + assertFailsWith { + withTimeout(200L) { + gatt.disconnect() + } + } + } + + verify { bluetoothGatt.close() } + } + } + + @Test + fun `On disconnect, onCharacteristicChanged subscriptions complete normally`() { + createDispatcher().use { dispatcher -> + val callback = GattCallback(dispatcher) + val bluetoothGatt = mockk { + every { device } returns mockk { + every { close() } returns Unit + every { this@mockk.toString() } returns "00:11:22:33:FF:EE" + } + } + val characteristic = FakeCharacteristic(testUuid, value = byteArrayOf(0xF, 0x0, 0x0, 0xD)) + val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback) + + val events = runBlocking { + launch { + callback.onCharacteristicChanged(bluetoothGatt, characteristic) + delay(500L) + callback.onConnectionStateChange( + bluetoothGatt, + GATT_SUCCESS, + STATE_DISCONNECTED + ) + } + + gatt.onCharacteristicChanged.toList() + } + + assertEquals( + expected = listOf( + OnCharacteristicChanged(characteristic, byteArrayOf(0xF, 0x0, 0x0, 0xD)) + ), + actual = events + ) + + verify { bluetoothGatt.close() } + } + } + + @Test + fun `When already disconnected, onCharacteristicChanged subscription completes normally`() { + createDispatcher().use { dispatcher -> + val callback = GattCallback(dispatcher) + val bluetoothGatt = mockk { + every { device } returns mockk { + every { close() } returns Unit + every { this@mockk.toString() } returns "00:11:22:33:FF:EE" + } + } + val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback) + + val events = runBlocking { + callback.onConnectionStateChange(bluetoothGatt, GATT_SUCCESS, STATE_DISCONNECTED) + verify { bluetoothGatt.close() } + gatt.onCharacteristicChanged.toList() + } + + assertEquals( + expected = emptyList(), + actual = events + ) + } + } +} + +private val dispatcherNumber = AtomicInteger() +private fun createDispatcher() = + newSingleThreadContext("MockGatt${dispatcherNumber.incrementAndGet()}") + +private fun createCharacteristic( + uuid: UUID = testUuid, + data: ByteArray = byteArrayOf() +): BluetoothGattCharacteristic = mockk { + every { getUuid() } returns uuid + every { setValue(data) } returns true + every { writeType = any() } returns Unit + every { value } returns data +} + +private fun createDescriptor( + uuid: UUID = testUuid, + data: ByteArray = byteArrayOf() +): BluetoothGattDescriptor = mockk { + every { getUuid() } returns uuid + every { setValue(data) } returns true + every { value } returns data +} diff --git a/core/src/test/java/gatt/FakeBluetoothGattCharacteristic.kt b/core/src/test/java/gatt/FakeBluetoothGattCharacteristic.kt new file mode 100644 index 0000000..3d04970 --- /dev/null +++ b/core/src/test/java/gatt/FakeBluetoothGattCharacteristic.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2020 JUUL Labs, Inc. + */ + +package com.juul.able.gatt + +import android.bluetooth.BluetoothGattCharacteristic +import java.util.UUID + +class FakeBluetoothGattCharacteristic( + uuid: UUID, + instanceId: Int = 0, + value: ByteArray = byteArrayOf() +) : BluetoothGattCharacteristic(uuid, 0, 0) { + + private val fakeUuid: UUID = uuid + private val fakeInstanceId: Int = instanceId + private var fakeValue: ByteArray = value + private var fakeWriteType: WriteType = WRITE_TYPE_DEFAULT + + override fun getUuid(): UUID = fakeUuid + + override fun getInstanceId(): Int = fakeInstanceId + + override fun setValue(value: ByteArray): Boolean { + fakeValue = value + return true + } + override fun getValue(): ByteArray = fakeValue + + override fun setWriteType(writeType: WriteType) { + fakeWriteType = writeType + } + override fun getWriteType(): WriteType = fakeWriteType +} diff --git a/core/src/test/java/gatt/FakeBluetoothGattDescriptor.kt b/core/src/test/java/gatt/FakeBluetoothGattDescriptor.kt new file mode 100644 index 0000000..501e4e7 --- /dev/null +++ b/core/src/test/java/gatt/FakeBluetoothGattDescriptor.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2020 JUUL Labs, Inc. + */ + +package com.juul.able.gatt + +import android.bluetooth.BluetoothGattDescriptor +import java.util.UUID + +class FakeBluetoothGattDescriptor( + uuid: UUID, + value: ByteArray = byteArrayOf() +) : BluetoothGattDescriptor(uuid, 0) { + + private val fakeUuid: UUID = uuid + private var fakeValue: ByteArray = value + + override fun getUuid(): UUID = fakeUuid + override fun setValue(value: ByteArray): Boolean { + fakeValue = value + return true + } + override fun getValue(): ByteArray = fakeValue +} diff --git a/core/src/test/java/logger/ConsoleLogger.kt b/core/src/test/java/logger/ConsoleLogger.kt new file mode 100644 index 0000000..8860b52 --- /dev/null +++ b/core/src/test/java/logger/ConsoleLogger.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2020 JUUL Labs, Inc. + */ + +package com.juul.able.logger + +import com.juul.able.ASSERT +import com.juul.able.ERROR +import com.juul.able.INFO +import com.juul.able.Logger +import com.juul.able.VERBOSE +import com.juul.able.WARN + +private const val TAG = "Able" + +class ConsoleLogger( + private val logLevel: Int = VERBOSE, + private inline val logHandler: (String) -> Unit = { println(it) } +) : Logger { + + private val labels = charArrayOf('V', 'D', 'I', 'W', 'E', 'A') + + /** + * Check if the [priority] of the message to be logged is at least the [logLevel] of this + * [Logger]. In other words, all logs of lower [priority] than this [Logger]'s [logLevel] will + * not be logged. + * + * For example, if the [ConsoleLogger]'s [logLevel] is set to [INFO] (`4`), only logs of + * [priority] [INFO] (or higher: [WARN], [ERROR], [ASSERT]) will be logged. + */ + override fun isLoggable(priority: Int) = priority >= logLevel + + override fun log(priority: Int, throwable: Throwable?, message: String) { + // labels[0] = 'V', labels[1] = 'D', labels[2] = 'I', ... + // VERBOSE = 2 , DEBUG = 3 , INFO = 4 , ... + // + // We coerce requested priority within `priority` range (VERBOSE to ASSERT) then offset down + // to `labels` who's index starts at `0`. + val index = priority.coerceIn(VERBOSE, ASSERT) - VERBOSE + + val label = labels[index] + val error = if (throwable != null) "${throwable.message}" else "" + logHandler.invoke("$label/$TAG: $error$message") + } +} diff --git a/core/src/test/java/logger/ConsoleLoggerTestRule.kt b/core/src/test/java/logger/ConsoleLoggerTestRule.kt new file mode 100644 index 0000000..ddd0d8d --- /dev/null +++ b/core/src/test/java/logger/ConsoleLoggerTestRule.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2020 JUUL Labs, Inc. + */ + +package com.juul.able.logger + +import com.juul.able.Able +import com.juul.able.Logger +import java.util.Collections +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +/** JUnit test rule that only prints logs on test failure. */ +class ConsoleLoggerTestRule : TestRule { + + override fun apply( + base: Statement, + description: Description + ): Statement = ConsoleLoggerStatement(base) +} + +private class ConsoleLoggerStatement( + private val base: Statement +) : Statement() { + + private val bufferedLogger = BufferedConsoleLogger() + + override fun evaluate() { + val previousLogger = Able.logger + Able.logger = bufferedLogger + + try { + base.evaluate() + } catch (throwable: Throwable) { + bufferedLogger.flush() + throw throwable + } finally { + Able.logger = previousLogger + } + } +} + +private class BufferedConsoleLogger private constructor( + private val logs: MutableList, + private val logger: Logger = ConsoleLogger { logs += it } +) : Logger by logger { + + constructor() : this(Collections.synchronizedList(mutableListOf())) + + fun flush() { + logs.forEach(::println) + } +} diff --git a/core/src/test/java/NoOpLogger.kt b/core/src/test/java/logger/NoOpLogger.kt similarity index 71% rename from core/src/test/java/NoOpLogger.kt rename to core/src/test/java/logger/NoOpLogger.kt index bf9f1bc..c6a63ca 100644 --- a/core/src/test/java/NoOpLogger.kt +++ b/core/src/test/java/logger/NoOpLogger.kt @@ -1,8 +1,10 @@ /* - * Copyright 2018 JUUL Labs, Inc. + * Copyright 2020 JUUL Labs, Inc. */ -package com.juul.able.experimental +package com.juul.able.logger + +import com.juul.able.Logger class NoOpLogger : Logger { override fun isLoggable(priority: Int): Boolean = false diff --git a/documentation/RECIPES.md b/documentation/RECIPES.md index 402d7f5..b806d8a 100644 --- a/documentation/RECIPES.md +++ b/documentation/RECIPES.md @@ -10,7 +10,7 @@ val serviceUuid = "F000AA80-0451-4000-B000-000000000000".toUuid() val characteristicUuid = "F000AA83-0451-4000-B000-000000000000".toUuid() fun connect(context: Context, device: BluetoothDevice) = launch { - val gatt = device.connectGatt(context, autoConnect = false).let { result -> + val gatt = device.connectGatt(context).let { result -> when (result) { is Success -> result.gatt is Canceled -> throw IllegalStateException("Connection canceled.", result.cause) @@ -18,21 +18,18 @@ fun connect(context: Context, device: BluetoothDevice) = launch { } } - if (gatt.discoverServices() != BluetoothGatt.GATT_SUCCESS) { - // discover services failed - } - - val characteristic = gatt.getService(serviceUuid).getCharacteristic(characteristicUuid) + try { + val characteristic = gatt.getService(serviceUuid).getCharacteristic(characteristicUuid) - val result = gatt.readCharacteristic(characteristic) - if (result.status == BluetoothGatt.GATT_SUCCESS) { - println("result.value = ${result.value}") - } else { - // read characteristic failed + val result = gatt.readCharacteristic(characteristic) + if (result.status == BluetoothGatt.GATT_SUCCESS) { + println("result.value = ${result.value}") + } else { + // read characteristic failed + } + } finally { + gatt.disconnect() } - - gatt.disconnect() - gatt.close() } ``` @@ -65,7 +62,7 @@ class ExampleActivity : AppCompatActivity(), CoroutineScope { launch { // If Activity is destroyed during connection attempt, then `result` will contain // `ConnectGattResult.Canceled`. - val result = bluetoothDevice.connectGatt(this@ExampleActivity, autoConnect = false) + val result = bluetoothDevice.connectGatt(this@ExampleActivity) // ... } @@ -87,25 +84,17 @@ Component's [`ViewModel`] can be scoped (via `CoroutineScope` interface) allowin attempts to be tied to the `ViewModel`'s lifecycle: ```kotlin -class ExampleViewModel(application: Application) : AndroidViewModel(application), CoroutineScope { - - private val job = Job() - override val coroutineContext: CoroutineContext - get() = job + Dispatchers.Main +class ExampleViewModel(application: Application) : AndroidViewModel(application) { fun connect(bluetoothDevice: BluetoothDevice) { - launch { + viewModelScope.launch { // If ViewModel is destroyed during connection attempt, then `result` will contain // `ConnectGattResult.Canceled`. - val result = bluetoothDevice.connectGatt(getApplication(), autoConnect = false) + val result = bluetoothDevice.connectGatt(getApplication()) // ... } } - - override fun onCleared() { - job.cancel() - } } ``` @@ -115,44 +104,26 @@ Similar to the connection process, after a connection has been established, if a cancelled then any `Gatt` operation executing within the Coroutine will be cancelled. However, unlike the `connectGatt` cancellation handling, an established `Gatt` connection will -**not** automatically disconnect or close when the Coroutine executing a `Gatt` operation is -canceled. Special care must be taken to ensure that Bluetooth Low Energy connections are properly -closed when no longer needed, for example: +**not** automatically disconnect when the Coroutine executing a `Gatt` operation is canceled. Be +sure and `disconnect` your `Gatt` connection when no longer needed: ```kotlin -val gatt: Gatt = TODO("Acquire Gatt via `BluetoothDevice.connectGatt` extension function.") - launch { - try { - gatt.discoverServices() - // todo: Assign desired characteristic to `characteristic` variable. - val value = gatt.readCharacteristic(characteristic).value - gatt.disconnect() + val gatt: Gatt = TODO("Acquire Gatt via `BluetoothDevice.connectGatt` extension function.") + // todo: Assign desired characteristic to `characteristic` variable. + val value = try { + gatt.readCharacteristic(characteristic).value } finally { - gatt.close() - } -} -``` - -The `Gatt` interface adheres to [`Closeable`] which can simplify the above example by using [`use`]: - -```kotlin -val gatt: Gatt = TODO("Acquire Gatt via `BluetoothDevice.connectGatt` extension function.") - -launch { - gatt.use { // Will close `gatt` if any failures or cancellation occurs. - gatt.discoverServices() - // todo: Assign desired characteristic to `characteristic` variable. - val value = gatt.readCharacteristic(characteristic).value - gatt.disconnect() + withContext(NonCancellable) { + gatt.disconnect() + } } } ``` -It may be desirable to manage Bluetooth Low Energy connections entirely manually. In which case, -Coroutine [`GlobalScope`] can be used for the connection process. In which case, the returned `Gatt` -object (after successful connection) can be stored and later used to disconnect **and** close the -underlying `BluetoothGatt`. +It may be desirable to manage Bluetooth Low Energy connections manually. In which case, +[`GlobalScope`] can be used for the connection process. The returned `Gatt` object (after successful +connection) can be stored and later used to disconnect. [`BluetoothDevice`]: https://developer.android.com/reference/android/bluetooth/BluetoothDevice.html diff --git a/gradle/dependencies.gradle b/gradle/dependencies.gradle index 45d7f8c..f6993e1 100644 --- a/gradle/dependencies.gradle +++ b/gradle/dependencies.gradle @@ -6,9 +6,9 @@ ext.versions = [ ext.deps = [ kotlin: [ - stdlib: "org.jetbrains.kotlin:kotlin-stdlib:1.3.60", - coroutines: "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.0.0", - junit: "org.jetbrains.kotlin:kotlin-test-junit:1.3.60", + stdlib: "org.jetbrains.kotlin:kotlin-stdlib", + coroutines: "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.5", + junit: "org.jetbrains.kotlin:kotlin-test-junit", ], // [Timber](https://github.com/JakeWharton/timber) diff --git a/gradle/jacoco-android.gradle b/gradle/jacoco-android.gradle index 4a72106..51f6f42 100644 --- a/gradle/jacoco-android.gradle +++ b/gradle/jacoco-android.gradle @@ -6,4 +6,6 @@ jacocoAndroidUnitTestReport { csv.enabled false html.enabled true xml.enabled true + + excludes += ['**/Debug*.*'] } diff --git a/gradle/jacoco-java.gradle b/gradle/jacoco-java.gradle new file mode 100644 index 0000000..e6ccd00 --- /dev/null +++ b/gradle/jacoco-java.gradle @@ -0,0 +1,12 @@ +jacoco { + toolVersion = "0.8.5" +} + +jacocoTestReport { + reports { + csv.enabled false + html.enabled true + xml.enabled true + } + dependsOn test +} diff --git a/processor/build.gradle b/processor/build.gradle index 0906c54..96db0e3 100644 --- a/processor/build.gradle +++ b/processor/build.gradle @@ -1,6 +1,7 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' + id 'org.jmailen.kotlinter' id 'com.hiya.jacoco-android' id 'com.vanniktech.maven.publish' } @@ -22,4 +23,5 @@ android { dependencies { api project(':core') testImplementation deps.kotlin.junit + testImplementation deps.mockk } diff --git a/processor/src/main/AndroidManifest.xml b/processor/src/main/AndroidManifest.xml index 29ab21e..11541bf 100644 --- a/processor/src/main/AndroidManifest.xml +++ b/processor/src/main/AndroidManifest.xml @@ -1,5 +1,5 @@ - + diff --git a/processor/src/main/java/GattProcessor.kt b/processor/src/main/java/GattProcessor.kt index f900d9f..0ef2f38 100644 --- a/processor/src/main/java/GattProcessor.kt +++ b/processor/src/main/java/GattProcessor.kt @@ -1,16 +1,16 @@ /* - * Copyright 2018 JUUL Labs, Inc. + * Copyright 2020 JUUL Labs, Inc. */ -package com.juul.able.experimental.processor +package com.juul.able.processor import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattDescriptor -import com.juul.able.experimental.Gatt -import com.juul.able.experimental.WriteType -import com.juul.able.experimental.messenger.OnCharacteristicRead -import com.juul.able.experimental.messenger.OnCharacteristicWrite -import com.juul.able.experimental.messenger.OnDescriptorWrite +import com.juul.able.gatt.Gatt +import com.juul.able.gatt.OnCharacteristicRead +import com.juul.able.gatt.OnCharacteristicWrite +import com.juul.able.gatt.OnDescriptorWrite +import com.juul.able.gatt.WriteType fun Gatt.withProcessors(vararg processors: Processor) = GattProcessor(this, processors) diff --git a/processor/src/main/java/Processor.kt b/processor/src/main/java/Processor.kt index 0872ab6..a80c2b8 100644 --- a/processor/src/main/java/Processor.kt +++ b/processor/src/main/java/Processor.kt @@ -1,12 +1,12 @@ /* - * Copyright 2018 JUUL Labs, Inc. + * Copyright 2020 JUUL Labs, Inc. */ -package com.juul.able.experimental.processor +package com.juul.able.processor import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattDescriptor -import com.juul.able.experimental.WriteType +import com.juul.able.gatt.WriteType /** * Processors add the ability to process (and optionally modify) GATT data pre-write or post-read. diff --git a/processor/src/test/java/ExampleUnitTest.kt b/processor/src/test/java/ExampleUnitTest.kt deleted file mode 100644 index 00614a0..0000000 --- a/processor/src/test/java/ExampleUnitTest.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright 2020 JUUL Labs, Inc. - */ - -package com.juul.able.experimental.processor.test - -import kotlin.test.Test -import kotlin.test.assertEquals - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} diff --git a/processor/src/test/java/GattProcessorTest.kt b/processor/src/test/java/GattProcessorTest.kt new file mode 100644 index 0000000..77e11f9 --- /dev/null +++ b/processor/src/test/java/GattProcessorTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2020 JUUL Labs, Inc. + */ + +package com.juul.able.processor.test + +import android.bluetooth.BluetoothGatt.GATT_SUCCESS +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import com.juul.able.gatt.Gatt +import com.juul.able.gatt.OnCharacteristicRead +import com.juul.able.gatt.WriteType +import com.juul.able.processor.Processor +import com.juul.able.processor.withProcessors +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.coroutines.runBlocking + +private val testUuid = UUID.fromString("01234567-89ab-cdef-0123-456789abcdef") + +class GattProcessorTest { + + private val reverseBytes = object : Processor { + override fun readCharacteristic( + characteristic: BluetoothGattCharacteristic, + value: ByteArray + ): ByteArray = value.reversedArray() + + override fun writeCharacteristic( + characteristic: BluetoothGattCharacteristic, + value: ByteArray, + writeType: WriteType + ): ByteArray = value.reversedArray() + + override fun writeDescriptor( + descriptor: BluetoothGattDescriptor, + value: ByteArray + ): ByteArray = value.reversedArray() + } + + @Test + fun `Processor modifies readCharacteristic data`() { + val characteristic = mockk { + every { uuid } returns testUuid + every { instanceId } returns 0 + } + val gatt = mockk { + coEvery { readCharacteristic(characteristic) } returns OnCharacteristicRead( + characteristic = characteristic, + value = byteArrayOf(0xF, 0x0, 0x0, 0xD), + status = GATT_SUCCESS + ) + } + val withProcessors = gatt.withProcessors(reverseBytes) + + val result = runBlocking { + withProcessors.readCharacteristic(characteristic) + } + + val expected = OnCharacteristicRead( + characteristic = characteristic, + value = byteArrayOf(0xD, 0x0, 0x0, 0xF), + status = GATT_SUCCESS + ) + assertEquals( + expected = expected.value.toList(), + actual = result.value.toList() + ) + assertEquals( + expected = expected, + actual = result + ) + } +} diff --git a/throw/build.gradle b/throw/build.gradle index 0906c54..96db0e3 100644 --- a/throw/build.gradle +++ b/throw/build.gradle @@ -1,6 +1,7 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' + id 'org.jmailen.kotlinter' id 'com.hiya.jacoco-android' id 'com.vanniktech.maven.publish' } @@ -22,4 +23,5 @@ android { dependencies { api project(':core') testImplementation deps.kotlin.junit + testImplementation deps.mockk } diff --git a/throw/src/main/AndroidManifest.xml b/throw/src/main/AndroidManifest.xml index 86e42d1..bdaba34 100644 --- a/throw/src/main/AndroidManifest.xml +++ b/throw/src/main/AndroidManifest.xml @@ -1,5 +1,5 @@ - + diff --git a/throw/src/main/java/Device.kt b/throw/src/main/java/Device.kt deleted file mode 100644 index 2331023..0000000 --- a/throw/src/main/java/Device.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2018 JUUL Labs, Inc. - */ - -package com.juul.able.experimental.throwable - -import android.bluetooth.BluetoothDevice -import android.bluetooth.BluetoothGatt.GATT_SUCCESS -import android.content.Context -import com.juul.able.experimental.ConnectGattResult -import com.juul.able.experimental.Device -import com.juul.able.experimental.Gatt -import kotlinx.coroutines.CancellationException - -class ConnectionCanceledException(cause: CancellationException) : Exception(cause) -class ConnectionFailedException(cause: Throwable) : Exception(cause) - -/** - * @throws ConnectionCanceledException if a [CancellationException] occurs during the connection process. - * @throws ConnectionFailedException if underlying [BluetoothDevice.connectGatt] returns `null`. - * @throws ConnectionFailedException if an error (non-[GATT_SUCCESS] status) occurs during connection process. - */ -suspend fun Device.connectGattOrThrow(context: Context, autoConnect: Boolean): Gatt { - val result = connectGatt(context, autoConnect) - return when (result) { - is ConnectGattResult.Success -> result.gatt - is ConnectGattResult.Canceled -> throw ConnectionCanceledException(result.cause) - is ConnectGattResult.Failure -> throw ConnectionFailedException(result.cause) - } -} diff --git a/throw/src/main/java/Gatt.kt b/throw/src/main/java/Gatt.kt index 0e6b58a..a52a0ce 100644 --- a/throw/src/main/java/Gatt.kt +++ b/throw/src/main/java/Gatt.kt @@ -1,31 +1,25 @@ /* - * Copyright 2018 JUUL Labs, Inc. + * Copyright 2020 JUUL Labs, Inc. */ +@file:JvmName("GattThrowKt") @file:Suppress("RedundantUnitReturnType") -package com.juul.able.experimental.throwable +package com.juul.able.throwable import android.bluetooth.BluetoothGatt.GATT_SUCCESS import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattDescriptor -import com.juul.able.experimental.Gatt -import com.juul.able.experimental.WriteType - -/** - * @throws [IllegalStateException] if [Gatt.connect] call returns `false`. - */ -suspend fun Gatt.connectOrThrow(): Unit { - connect() || error("connect() returned `false`.") -} +import com.juul.able.gatt.Gatt +import com.juul.able.gatt.WriteType /** * @throws [IllegalStateException] if [Gatt.discoverServices] call does not return [GATT_SUCCESS]. */ -suspend fun Gatt.discoverServicesOrThrow(): Unit { +suspend fun Gatt.discoverServicesOrThrow() { discoverServices() .also { status -> - check(status == android.bluetooth.BluetoothGatt.GATT_SUCCESS) { + check(status == GATT_SUCCESS) { "Service discovery failed with gatt status $status." } } @@ -36,29 +30,28 @@ suspend fun Gatt.discoverServicesOrThrow(): Unit { */ suspend fun Gatt.readCharacteristicOrThrow( characteristic: BluetoothGattCharacteristic -): BluetoothGattCharacteristic { +): ByteArray { return readCharacteristic(characteristic) .also { (_, _, status) -> - check(status == android.bluetooth.BluetoothGatt.GATT_SUCCESS) { + check(status == GATT_SUCCESS) { "Reading characteristic ${characteristic.uuid} failed with status $status." } - }.characteristic + }.value } /** * @throws [IllegalStateException] if [Gatt.setCharacteristicNotification] call returns `false`. */ -suspend fun Gatt.setCharacteristicNotificationOrThrow( +fun Gatt.setCharacteristicNotificationOrThrow( characteristic: BluetoothGattCharacteristic, enable: Boolean -): Unit { +) { setCharacteristicNotification(characteristic, enable) .also { check(it) { "Setting characteristic notifications to $enable failed." } } } - /** * @throws [IllegalStateException] if [Gatt.writeCharacteristic] call does not return [GATT_SUCCESS]. */ @@ -66,13 +59,13 @@ suspend fun Gatt.writeCharacteristicOrThrow( characteristic: BluetoothGattCharacteristic, value: ByteArray, writeType: WriteType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT -): BluetoothGattCharacteristic { - return writeCharacteristic(characteristic, value, writeType) +) { + writeCharacteristic(characteristic, value, writeType) .also { (_, status) -> check(status == GATT_SUCCESS) { "Writing characteristic ${characteristic.uuid} failed with status $status." } - }.characteristic + } } /** @@ -81,9 +74,9 @@ suspend fun Gatt.writeCharacteristicOrThrow( suspend fun Gatt.writeDescriptorOrThrow( descriptor: BluetoothGattDescriptor, value: ByteArray -): BluetoothGattDescriptor { - return writeDescriptor(descriptor, value) +) { + writeDescriptor(descriptor, value) .also { (_, status) -> - check(status == GATT_SUCCESS) { "Descriptor write failed with gatt status $status." } - }.descriptor + check(status == GATT_SUCCESS) { "Descriptor write failed with status $status." } + } } diff --git a/throw/src/main/java/android/BluetoothDevice.kt b/throw/src/main/java/android/BluetoothDevice.kt index 2bfdb4d..73f9f28 100644 --- a/throw/src/main/java/android/BluetoothDevice.kt +++ b/throw/src/main/java/android/BluetoothDevice.kt @@ -1,27 +1,26 @@ /* - * Copyright 2018 JUUL Labs, Inc. + * Copyright 2020 JUUL Labs, Inc. */ -package com.juul.able.experimental.throwable.android +@file:JvmName("BluetoothDeviceThrowKt") + +package com.juul.able.throwable.android import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothGatt.GATT_SUCCESS import android.content.Context -import com.juul.able.experimental.Gatt -import com.juul.able.experimental.android.asCoroutinesDevice -import com.juul.able.experimental.messenger.GattCallbackConfig -import com.juul.able.experimental.throwable.connectGattOrThrow -import com.juul.able.experimental.throwable.ConnectionCanceledException -import com.juul.able.experimental.throwable.ConnectionFailedException -import kotlinx.coroutines.CancellationException +import com.juul.able.android.connectGatt +import com.juul.able.device.ConnectGattResult.Failure +import com.juul.able.device.ConnectGattResult.Success +import com.juul.able.device.ConnectionFailed +import com.juul.able.gatt.Gatt /** - * @throws ConnectionCanceledException if a [CancellationException] occurs during the connection process. - * @throws ConnectionFailedException if underlying [BluetoothDevice.connectGatt] returns `null`. - * @throws ConnectionFailedException if an error (non-[GATT_SUCCESS] status) occurs during connection process. + * @throws ConnectionFailed if underlying [BluetoothDevice.connectGatt] returns `null` or an error (non-[GATT_SUCCESS] status) occurs during connection process. */ suspend fun BluetoothDevice.connectGattOrThrow( - context: Context, - autoConnect: Boolean, - callbackConfig: GattCallbackConfig = GattCallbackConfig() -): Gatt = asCoroutinesDevice(callbackConfig).connectGattOrThrow(context, autoConnect) + context: Context +): Gatt = when (val result = connectGatt(context)) { + is Success -> result.gatt + is Failure -> throw result.cause +} diff --git a/throw/src/test/java/ExampleUnitTest.kt b/throw/src/test/java/ExampleUnitTest.kt deleted file mode 100644 index e3e7238..0000000 --- a/throw/src/test/java/ExampleUnitTest.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright 2020 JUUL Labs, Inc. - */ - -package com.juul.able.experimental.throwable.test - -import kotlin.test.Test -import kotlin.test.assertEquals - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} diff --git a/throw/src/test/java/GattTest.kt b/throw/src/test/java/GattTest.kt new file mode 100644 index 0000000..2bd5dd3 --- /dev/null +++ b/throw/src/test/java/GattTest.kt @@ -0,0 +1,143 @@ +/* + * Copyright 2020 JUUL Labs, Inc. + */ + +package com.juul.able.throwable.test + +import android.bluetooth.BluetoothGatt.GATT_FAILURE +import android.bluetooth.BluetoothGatt.GATT_READ_NOT_PERMITTED +import android.bluetooth.BluetoothGatt.GATT_SUCCESS +import android.bluetooth.BluetoothGatt.GATT_WRITE_NOT_PERMITTED +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import com.juul.able.gatt.Gatt +import com.juul.able.gatt.OnCharacteristicRead +import com.juul.able.gatt.OnCharacteristicWrite +import com.juul.able.gatt.OnDescriptorWrite +import com.juul.able.throwable.discoverServicesOrThrow +import com.juul.able.throwable.readCharacteristicOrThrow +import com.juul.able.throwable.setCharacteristicNotificationOrThrow +import com.juul.able.throwable.writeCharacteristicOrThrow +import com.juul.able.throwable.writeDescriptorOrThrow +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlinx.coroutines.runBlocking + +private val testUuid = UUID.fromString("01234567-89ab-cdef-0123-456789abcdef") + +class GattTest { + + @Test + fun `discoverServicesOrThrow throws IllegalStateException for non-GATT_SUCCESS response`() { + val gatt = mockk { + coEvery { discoverServices() } returns GATT_FAILURE + } + + assertFailsWith { + runBlocking { + gatt.discoverServicesOrThrow() + } + } + } + + @Test + fun `readCharacteristicOrThrow throws IllegalStateException for non-GATT_SUCCESS response`() { + val characteristic = mockk { + every { uuid } returns testUuid + } + val gatt = mockk { + coEvery { readCharacteristic(characteristic) } returns OnCharacteristicRead( + characteristic = characteristic, + value = byteArrayOf(), + status = GATT_READ_NOT_PERMITTED + ) + } + + assertFailsWith { + runBlocking { + gatt.readCharacteristicOrThrow(characteristic) + } + } + } + + @Test + fun `readCharacteristicOrThrow returns ByteArray for GATT_SUCCESS response`() { + val characteristic = mockk { + every { uuid } returns testUuid + } + val gatt = mockk { + coEvery { readCharacteristic(characteristic) } returns OnCharacteristicRead( + characteristic = characteristic, + value = byteArrayOf(0xF, 0x0, 0x0, 0xD), + status = GATT_SUCCESS + ) + } + + val result = runBlocking { + gatt.readCharacteristicOrThrow(characteristic) + } + + // Convert to `List` so equality verification is done on the contents. + assertEquals( + expected = byteArrayOf(0xF, 0x0, 0x0, 0xD).toList(), + actual = result.toList() + ) + } + + @Test + fun `setCharacteristicNotification throws IllegalStateException for false return`() { + val gatt = mockk { + coEvery { setCharacteristicNotification(any(), any()) } returns false + } + + assertFailsWith { + val characteristic = mockk { + every { uuid } returns testUuid + } + gatt.setCharacteristicNotificationOrThrow(characteristic, true) + } + } + + @Test + fun `writeCharacteristicOrThrow throws IllegalStateException for non-GATT_SUCCESS response`() { + val characteristic = mockk { + every { uuid } returns testUuid + } + val gatt = mockk { + coEvery { writeCharacteristic(characteristic, any(), any()) } returns OnCharacteristicWrite( + characteristic = characteristic, + status = GATT_WRITE_NOT_PERMITTED + ) + } + + assertFailsWith { + runBlocking { + gatt.writeCharacteristicOrThrow(characteristic, byteArrayOf()) + } + } + } + + @Test + fun `writeDescriptorOrThrow throws IllegalStateException for non-GATT_SUCCESS response`() { + val descriptor = mockk { + every { uuid } returns testUuid + } + val gatt = mockk { + coEvery { writeDescriptor(descriptor, any()) } returns OnDescriptorWrite( + descriptor = descriptor, + status = GATT_WRITE_NOT_PERMITTED + ) + } + + assertFailsWith { + runBlocking { + gatt.writeDescriptorOrThrow(descriptor, byteArrayOf()) + } + } + } +} diff --git a/throw/src/test/java/android/BluetoothDeviceTest.kt b/throw/src/test/java/android/BluetoothDeviceTest.kt new file mode 100644 index 0000000..059ccf4 --- /dev/null +++ b/throw/src/test/java/android/BluetoothDeviceTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2020 JUUL Labs, Inc. + */ + +package com.juul.able.throwable.test.android + +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt.GATT_FAILURE +import android.bluetooth.BluetoothProfile.STATE_DISCONNECTED +import android.content.Context +import com.juul.able.android.connectGatt +import com.juul.able.device.ConnectGattResult +import com.juul.able.device.ConnectGattResult.Success +import com.juul.able.device.ConnectionFailed +import com.juul.able.gatt.Gatt +import com.juul.able.gatt.GattStatusFailure +import com.juul.able.gatt.OnConnectionStateChange +import com.juul.able.throwable.android.connectGattOrThrow +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlinx.coroutines.runBlocking + +class BluetoothDeviceTest { + + @Test + fun `connectGattOrThrow returns result Gatt on success`() { + val context = mockk() + val gatt = mockk() + val bluetoothDevice = mockk() + + mockkStatic("com.juul.able.android.BluetoothDeviceKt") + try { + coEvery { bluetoothDevice.connectGatt(any()) } returns Success(gatt) + + val result = runBlocking { + bluetoothDevice.connectGattOrThrow(context) + } + + assertEquals( + expected = gatt, + actual = result + ) + } finally { + unmockkStatic("com.juul.able.android.BluetoothDeviceKt") + } + } + + @Test + fun `connectGattOrThrow throws result cause on failure`() { + val context = mockk() + val bluetoothDevice = mockk() + val event = OnConnectionStateChange(GATT_FAILURE, STATE_DISCONNECTED) + val cause = GattStatusFailure(event) + val failure = ConnectionFailed("Failed to connect to device 00:11:22:33:FF:EE", cause) + mockkStatic("com.juul.able.android.BluetoothDeviceKt") + try { + coEvery { bluetoothDevice.connectGatt(any()) } returns ConnectGattResult.Failure(failure) + + val capturedCause = assertFailsWith { + runBlocking { + bluetoothDevice.connectGattOrThrow(context) + } + }.cause!! + + assertEquals( + expected = cause, + actual = capturedCause + ) + } finally { + unmockkStatic("com.juul.able.android.BluetoothDeviceKt") + } + } +} diff --git a/timber-logger/build.gradle b/timber-logger/build.gradle index 43367d2..4fad649 100644 --- a/timber-logger/build.gradle +++ b/timber-logger/build.gradle @@ -1,6 +1,7 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' + id 'org.jmailen.kotlinter' id 'com.hiya.jacoco-android' id 'com.vanniktech.maven.publish' } diff --git a/timber-logger/src/main/AndroidManifest.xml b/timber-logger/src/main/AndroidManifest.xml index f476f89..7754d4e 100644 --- a/timber-logger/src/main/AndroidManifest.xml +++ b/timber-logger/src/main/AndroidManifest.xml @@ -1,5 +1,5 @@ - + diff --git a/timber-logger/src/main/java/AndroidTagGenerator.kt b/timber-logger/src/main/java/AndroidTagGenerator.kt index 8f4a85d..c2a1aaf 100644 --- a/timber-logger/src/main/java/AndroidTagGenerator.kt +++ b/timber-logger/src/main/java/AndroidTagGenerator.kt @@ -1,8 +1,8 @@ /* - * Copyright 2018 JUUL Labs, Inc. + * Copyright 2020 JUUL Labs, Inc. */ -package com.juul.able.experimental.logger.timber +package com.juul.able.logger.timber private const val CALL_STACK_INDEX = 2 private val ANONYMOUS_CLASS_REGEX = "(\\$\\d+)+$".toRegex() diff --git a/timber-logger/src/main/java/TimberLogger.kt b/timber-logger/src/main/java/TimberLogger.kt index 069b4bf..75be97f 100644 --- a/timber-logger/src/main/java/TimberLogger.kt +++ b/timber-logger/src/main/java/TimberLogger.kt @@ -1,11 +1,11 @@ /* - * Copyright 2018 JUUL Labs, Inc. + * Copyright 2020 JUUL Labs, Inc. */ -package com.juul.able.experimental.logger.timber +package com.juul.able.logger.timber import android.os.Build -import com.juul.able.experimental.Logger +import com.juul.able.Logger import timber.log.Timber private const val MAX_TAG_LENGTH = 23 diff --git a/timber-logger/src/test/java/AndroidTagGeneratorTest.kt b/timber-logger/src/test/java/AndroidTagGeneratorTest.kt index 2130d53..e49f374 100644 --- a/timber-logger/src/test/java/AndroidTagGeneratorTest.kt +++ b/timber-logger/src/test/java/AndroidTagGeneratorTest.kt @@ -1,14 +1,15 @@ /* - * Copyright 2018 JUUL Labs, Inc. + * Copyright 2020 JUUL Labs, Inc. */ -package com.juul.able.experimental.logger.timber +package com.juul.able.logger.timber.test -import com.juul.able.experimental.Able -import com.juul.able.experimental.Logger -import org.junit.BeforeClass -import org.junit.Test +import com.juul.able.Able +import com.juul.able.Logger +import com.juul.able.logger.timber.AndroidTagGenerator +import kotlin.test.Test import kotlin.test.assertEquals +import org.junit.BeforeClass class AndroidTagGeneratorTest { @@ -26,14 +27,14 @@ class AndroidTagGeneratorTest { } @Test - fun tagMatchesClassname() { + fun `Tag matches classname`() { val dog = Dog() dog.bark() assertEquals(dog.javaClass.simpleName, logger.lastTag) } @Test - fun tagFromAnonymousMethod() { + fun `Tag is captured from anonymous method`() { val anonymous = object { fun go() { Able.info { "hello world" } @@ -46,7 +47,7 @@ class AndroidTagGeneratorTest { } @Test - fun tagFromAnonymousMethodWithinRunnable() { + fun `Tag is captured from anonymous method within Runnable`() { Runnable { object { fun go() {