Add tests for GattServer
Test coverage for GattServer is 76% with this change.
Bug: 294322777
Test: ./gradlew bluetooth:bluetooth:check \
bluetooth:bluetooth:connectedCheck \
bluetooth:bluetooth-testing:check
Change-Id: I983806202ed27d22c1f4f6506fc5bc0812086ef9
diff --git a/bluetooth/bluetooth-testing/src/test/kotlin/androidx/bluetooth/testing/RobolectricGattServerTest.kt b/bluetooth/bluetooth-testing/src/test/kotlin/androidx/bluetooth/testing/RobolectricGattServerTest.kt
new file mode 100644
index 0000000..685a859
--- /dev/null
+++ b/bluetooth/bluetooth-testing/src/test/kotlin/androidx/bluetooth/testing/RobolectricGattServerTest.kt
@@ -0,0 +1,332 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.bluetooth.testing
+
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothDevice as FwkDevice
+import android.bluetooth.BluetoothGattCharacteristic
+import android.bluetooth.BluetoothGattServer
+import android.bluetooth.BluetoothGattServerCallback
+import android.bluetooth.BluetoothGattService
+import android.bluetooth.BluetoothManager
+import android.content.Context
+import androidx.bluetooth.BluetoothLe
+import androidx.bluetooth.GattCharacteristic
+import androidx.bluetooth.GattCharacteristic.Companion.PERMISSION_READ
+import androidx.bluetooth.GattCharacteristic.Companion.PERMISSION_WRITE
+import androidx.bluetooth.GattCharacteristic.Companion.PROPERTY_NOTIFY
+import androidx.bluetooth.GattCharacteristic.Companion.PROPERTY_READ
+import androidx.bluetooth.GattCharacteristic.Companion.PROPERTY_WRITE
+import androidx.bluetooth.GattServer
+import androidx.bluetooth.GattServerRequest
+import androidx.bluetooth.GattService
+import java.nio.ByteBuffer
+import java.util.UUID
+import junit.framework.TestCase.fail
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.shadows.ShadowBluetoothGattServer
+
+@RunWith(RobolectricTestRunner::class)
+@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
+class RobolectricGattServerTest {
+ private val context: Context = RuntimeEnvironment.getApplication()
+ private val bluetoothManager: BluetoothManager =
+ context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
+ private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
+
+ private lateinit var bluetoothLe: BluetoothLe
+ private lateinit var serverAdapter: StubServerFrameworkAdapter
+
+ private companion object {
+ private val serviceUuid1 = UUID.fromString("00001111-0000-1000-8000-00805F9B34FB")
+ private val serviceUuid2 = UUID.fromString("00001112-0000-1000-8000-00805F9B34FB")
+
+ private val readCharUuid = UUID.fromString("00002221-0000-1000-8000-00805F9B34FB")
+ private val writeCharUuid = UUID.fromString("00002222-0000-1000-8000-00805F9B34FB")
+ private val notifyCharUuid = UUID.fromString("00002223-0000-1000-8000-00805F9B34FB")
+
+ private val cccdUuid = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
+
+ private val readCharacteristic = GattCharacteristic(
+ readCharUuid,
+ PROPERTY_READ, PERMISSION_READ
+ )
+ private val writeCharacteristic = GattCharacteristic(
+ writeCharUuid,
+ PROPERTY_READ or PROPERTY_WRITE, PERMISSION_READ or PERMISSION_WRITE
+ )
+ private val notifyCharacteristic = GattCharacteristic(
+ notifyCharUuid,
+ PROPERTY_READ or PROPERTY_NOTIFY, PERMISSION_READ
+ )
+
+ private val service1 = GattService(
+ serviceUuid1,
+ listOf(readCharacteristic, writeCharacteristic, notifyCharacteristic)
+ )
+ private val service2 = GattService(serviceUuid2, listOf())
+ }
+
+ @Before
+ fun setUp() {
+ bluetoothLe = BluetoothLe(context)
+ serverAdapter = StubServerFrameworkAdapter(bluetoothLe.server.fwkAdapter)
+ bluetoothLe.server.fwkAdapter = serverAdapter
+ }
+
+ @Test
+ fun openGattServer() = runTest {
+ val device = createDevice("00:11:22:33:44:55")
+ val opened = CompletableDeferred<Unit>()
+ val closed = CompletableDeferred<Unit>()
+
+ serverAdapter.>
+ StubServerFrameworkAdapter.OnOpenGattServerListener {
+ connectDevice(device) { opened.complete(Unit) }
+ }
+ serverAdapter.>
+ StubServerFrameworkAdapter.OnCloseGattServerListener {
+ closed.complete(Unit)
+ }
+
+ bluetoothLe.openGattServer(listOf()).first().accept {
+ Assert.assertEquals(device.address, this.device.address)
+ }
+
+ Assert.assertTrue(opened.isCompleted)
+ Assert.assertTrue(closed.isCompleted)
+ }
+
+ @Test
+ fun openGattServer_readCharacteristic() = runTest {
+ val services = listOf(service1, service2)
+ val device = createDevice("00:11:22:33:44:55")
+ val closed = CompletableDeferred<Unit>()
+ val valueToRead = 42
+
+ runAfterServicesAreAdded(services.size) {
+ connectDevice(device) {
+ serverAdapter.callback.onCharacteristicReadRequest(
+ device, /*requestId=*/1, /*offset=*/0, readCharacteristic.fwkCharacteristic)
+ }
+ }
+ serverAdapter.>
+ StubServerFrameworkAdapter.OnCloseGattServerListener {
+ closed.complete(Unit)
+ }
+
+ launch {
+ bluetoothLe.openGattServer(services).collect {
+ it.accept {
+ when (val request = requests.first()) {
+ is GattServerRequest.ReadCharacteristicRequest -> {
+ request.sendResponse(true, valueToRead.toByteArray())
+ }
+ else -> fail("unexpected request")
+ }
+ // Close the server
+ this@launch.cancel()
+ }
+ }
+ }.join()
+
+ // Ensure if the server is closed
+ Assert.assertTrue(closed.isCompleted)
+ Assert.assertEquals(1, serverAdapter.shadowGattServer.responses.size)
+ Assert.assertEquals(valueToRead, serverAdapter.shadowGattServer.responses[0].toInt())
+ }
+
+ @Test
+ fun openGattServer_rejectAndAccept_throwsException() = runTest {
+ val services = listOf(service1, service2)
+ val device = createDevice("00:11:22:33:44:55")
+ val closed = CompletableDeferred<Unit>()
+
+ runAfterServicesAreAdded(services.size) {
+ connectDevice(device) {
+ serverAdapter.callback.onCharacteristicReadRequest(
+ device, /*requestId=*/1, /*offset=*/0, readCharacteristic.fwkCharacteristic)
+ }
+ }
+ serverAdapter.>
+ StubServerFrameworkAdapter.OnCloseGattServerListener {
+ closed.complete(Unit)
+ }
+
+ launch {
+ bluetoothLe.openGattServer(services).collect {
+ it.reject()
+ Assert.assertThrows(
+ IllegalStateException::class.java
+ ) {
+ runBlocking {
+ it.accept {}
+ }
+ }
+ this@launch.cancel()
+ }
+ }.join()
+
+ Assert.assertTrue(closed.isCompleted)
+ Assert.assertEquals(0, serverAdapter.shadowGattServer.responses.size)
+ }
+
+ @Test
+ fun openGattServer_writeCharacteristic() = runTest {
+ val services = listOf(service1, service2)
+ val device = createDevice("00:11:22:33:44:55")
+ val closed = CompletableDeferred<Unit>()
+ val valueToWrite = 42
+
+ runAfterServicesAreAdded(services.size) {
+ connectDevice(device) {
+ serverAdapter.callback.onCharacteristicWriteRequest(
+ device, /*requestId=*/1, writeCharacteristic.fwkCharacteristic,
+ /*preparedWrite=*/false, /*responseNeeded=*/false,
+ /*offset=*/0, valueToWrite.toByteArray())
+ }
+ }
+ serverAdapter.>
+ StubServerFrameworkAdapter.OnCloseGattServerListener {
+ closed.complete(Unit)
+ }
+
+ launch {
+ bluetoothLe.openGattServer(services).collect {
+ it.accept {
+ when (val request = requests.first()) {
+ is GattServerRequest.WriteCharacteristicRequest -> {
+ Assert.assertEquals(valueToWrite, request.value?.toInt())
+ request.sendResponse(true)
+ }
+ else -> fail("unexpected request")
+ }
+ // Close the server
+ this@launch.cancel()
+ }
+ }
+ }.join()
+
+ Assert.assertTrue(closed.isCompleted)
+ Assert.assertEquals(1, serverAdapter.shadowGattServer.responses.size)
+ Assert.assertEquals(valueToWrite, serverAdapter.shadowGattServer.responses[0].toInt())
+ }
+
+ private fun<R> runAfterServicesAreAdded(countServices: Int, block: suspend () -> R) {
+ var waitCount = countServices
+ serverAdapter. {
+ if (--waitCount == 0) {
+ runBlocking {
+ block()
+ }
+ }
+ }
+ }
+
+ private fun<R> connectDevice(device: FwkDevice, block: () -> R): R {
+ serverAdapter.shadowGattServer.notifyConnection(device)
+ return block()
+ }
+
+ private fun createDevice(address: String): FwkDevice {
+ return bluetoothAdapter!!.getRemoteDevice(address)
+ }
+
+ class StubServerFrameworkAdapter(
+ private val baseAdapter: GattServer.FrameworkAdapter
+ ) : GattServer.FrameworkAdapter {
+ val shadowGattServer: ShadowBluetoothGattServer
+ get() = shadowOf(gattServer)
+ val callback: BluetoothGattServerCallback
+ get() = shadowGattServer.gattServerCallback
+ override var gattServer: BluetoothGattServer?
+ get() = baseAdapter.gattServer
+ set(value) { baseAdapter.gattServer = value }
+
+ var onOpenGattServerListener: OnOpenGattServerListener? = null
+ var onCloseGattServerListener: OnCloseGattServerListener? = null
+ var onAddServiceListener: OnAddServiceListener? = null
+
+ override fun openGattServer(context: Context, callback: BluetoothGattServerCallback) {
+ baseAdapter.openGattServer(context, callback)
+ onOpenGattServerListener?.onOpenGattServer()
+ }
+
+ override fun closeGattServer() {
+ baseAdapter.closeGattServer()
+ onCloseGattServerListener?.onCloseGattServer()
+ }
+
+ override fun clearServices() {
+ baseAdapter.clearServices()
+ }
+
+ override fun addService(service: BluetoothGattService) {
+ baseAdapter.addService(service)
+ onAddServiceListener?.onAddService(service)
+ }
+
+ override fun notifyCharacteristicChanged(
+ device: FwkDevice,
+ characteristic: BluetoothGattCharacteristic,
+ confirm: Boolean,
+ value: ByteArray
+ ) {
+ baseAdapter.notifyCharacteristicChanged(device, characteristic, confirm, value)
+ }
+
+ override fun sendResponse(
+ device: FwkDevice,
+ requestId: Int,
+ status: Int,
+ offset: Int,
+ value: ByteArray?
+ ) {
+ baseAdapter.sendResponse(device, requestId, status, offset, value)
+ }
+
+ fun interface OnOpenGattServerListener {
+ fun onOpenGattServer()
+ }
+ fun interface OnAddServiceListener {
+ fun onAddService(service: BluetoothGattService)
+ }
+ fun interface OnCloseGattServerListener {
+ fun onCloseGattServer()
+ }
+ }
+}
+
+private fun Int.toByteArray(): ByteArray {
+ return ByteBuffer.allocate(Int.SIZE_BYTES).putInt(this).array()
+}
+
+private fun ByteArray.toInt(): Int {
+ return ByteBuffer.wrap(this).int
+}
diff --git a/bluetooth/bluetooth/src/main/java/androidx/bluetooth/BluetoothLe.kt b/bluetooth/bluetooth/src/main/java/androidx/bluetooth/BluetoothLe.kt
index cc95ef8..abf6539 100644
--- a/bluetooth/bluetooth/src/main/java/androidx/bluetooth/BluetoothLe.kt
+++ b/bluetooth/bluetooth/src/main/java/androidx/bluetooth/BluetoothLe.kt
@@ -53,7 +53,9 @@
@VisibleForTesting
@get:RestrictTo(RestrictTo.Scope.LIBRARY)
val client = GattClient(context)
- private val server = GattServer(context)
+ @VisibleForTesting
+ @get:RestrictTo(RestrictTo.Scope.LIBRARY)
+ val server = GattServer(context)
/**
* Returns a _cold_ [Flow] to start Bluetooth LE Advertising. When the flow is successfully collected,
@@ -252,7 +254,7 @@
*
* @see GattServerScope
*/
- suspend fun accept(block: GattServerScope.() -> Unit) {
+ suspend fun accept(block: suspend GattServerScope.() -> Unit) {
return server.acceptConnection(this, block)
}
diff --git a/bluetooth/bluetooth/src/main/java/androidx/bluetooth/GattCharacteristic.kt b/bluetooth/bluetooth/src/main/java/androidx/bluetooth/GattCharacteristic.kt
index c198dc9..577d196 100644
--- a/bluetooth/bluetooth/src/main/java/androidx/bluetooth/GattCharacteristic.kt
+++ b/bluetooth/bluetooth/src/main/java/androidx/bluetooth/GattCharacteristic.kt
@@ -25,7 +25,8 @@
*/
@RestrictTo(RestrictTo.Scope.LIBRARY)
class GattCharacteristic internal constructor(
- internal var fwkCharacteristic: BluetoothGattCharacteristic
+ @get:RestrictTo(RestrictTo.Scope.LIBRARY)
+ var fwkCharacteristic: BluetoothGattCharacteristic
) {
companion object {
/**
diff --git a/bluetooth/bluetooth/src/main/java/androidx/bluetooth/GattServer.kt b/bluetooth/bluetooth/src/main/java/androidx/bluetooth/GattServer.kt
index 785983d..293c76b 100644
--- a/bluetooth/bluetooth/src/main/java/androidx/bluetooth/GattServer.kt
+++ b/bluetooth/bluetooth/src/main/java/androidx/bluetooth/GattServer.kt
@@ -29,6 +29,8 @@
import android.content.Context
import android.os.Build
import androidx.annotation.RequiresPermission
+import androidx.annotation.RestrictTo
+import androidx.annotation.VisibleForTesting
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.channels.Channel
@@ -41,8 +43,10 @@
/**
* Class for handling operations as a GATT server role
*/
-internal class GattServer(private val context: Context) {
- private interface GattServerImpl {
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+class GattServer(private val context: Context) {
+ interface FrameworkAdapter {
+ var gattServer: BluetoothGattServer?
fun openGattServer(context: Context, callback: BluetoothGattServerCallback)
fun closeGattServer()
fun clearServices()
@@ -82,9 +86,11 @@
private val attributeMap = AttributeMap()
@SuppressLint("ObsoleteSdkInt")
- private val impl: GattServerImpl =
- if (Build.VERSION.SDK_INT >= 33) GattServerImplApi33()
- else BaseGattServerImpl()
+ @VisibleForTesting
+ @RestrictTo(RestrictTo.Scope.LIBRARY)
+ var fwkAdapter: FrameworkAdapter =
+ if (Build.VERSION.SDK_INT >= 33) FrameworkAdapterApi33()
+ else FrameworkAdapterBase()
fun open(services: List<GattService>):
Flow<BluetoothLe.GattServerConnectionRequest> = callbackFlow {
@@ -155,22 +161,22 @@
}
}
}
- impl.openGattServer(context, callback)
- services.forEach { impl.addService(it.fwkService) }
+ fwkAdapter.openGattServer(context, callback)
+ services.forEach { fwkAdapter.addService(it.fwkService) }
awaitClose {
- impl.closeGattServer()
+ fwkAdapter.closeGattServer()
}
}
fun updateServices(services: List<GattService>) {
- impl.clearServices()
- services.forEach { impl.addService(it.fwkService) }
+ fwkAdapter.clearServices()
+ services.forEach { fwkAdapter.addService(it.fwkService) }
}
suspend fun<R> acceptConnection(
request: BluetoothLe.GattServerConnectionRequest,
- block: BluetoothLe.GattServerScope.() -> R
+ block: suspend BluetoothLe.GattServerScope.() -> R
) = coroutineScope {
val session = request.session
if (!session.state.compareAndSet(Session.State.CONNECTING, Session.State.CONNECTED)) {
@@ -185,7 +191,7 @@
characteristic: GattCharacteristic,
value: ByteArray
) {
- impl.notifyCharacteristicChanged(
+ fwkAdapter.notifyCharacteristicChanged(
request.device.fwkDevice, characteristic.fwkCharacteristic, false, value)
}
}
@@ -223,11 +229,11 @@
offset: Int,
value: ByteArray?
) {
- impl.sendResponse(device, requestId, status, offset, value)
+ fwkAdapter.sendResponse(device, requestId, status, offset, value)
}
- private open class BaseGattServerImpl : GattServerImpl {
- internal var gattServer: BluetoothGattServer? = null
+ private open class FrameworkAdapterBase : FrameworkAdapter {
+ override var gattServer: BluetoothGattServer? = null
private val isOpen = AtomicBoolean(false)
@RequiresPermission(BLUETOOTH_CONNECT)
override fun openGattServer(context: Context, callback: BluetoothGattServerCallback) {
@@ -278,7 +284,7 @@
}
}
- private open class GattServerImplApi33 : BaseGattServerImpl() {
+ private open class FrameworkAdapterApi33 : FrameworkAdapterBase() {
@RequiresPermission(BLUETOOTH_CONNECT)
override fun notifyCharacteristicChanged(
device: FwkBluetoothDevice,