Skip to content

Commit

Permalink
Add integration test for Kotlin Flow + Shadow Bluetooth APIs
Browse files Browse the repository at this point in the history
This came up in a discussion about controlling coroutines
in Robolectric tests.

PiperOrigin-RevId: 631456525
  • Loading branch information
hoisie authored and Copybara-Service committed May 7, 2024
1 parent ccf8c6f commit 6aecbb4
Show file tree
Hide file tree
Showing 3 changed files with 163 additions and 0 deletions.
1 change: 1 addition & 0 deletions integration_tests/kotlin/build.gradle
Expand Up @@ -18,6 +18,7 @@ dependencies {
testCompileOnly AndroidSdk.MAX_SDK.coordinates
testRuntimeOnly AndroidSdk.MAX_SDK.coordinates
testImplementation libs.kotlin.stdlib
testImplementation libs.kotlinx.coroutines.android
testImplementation libs.junit4
testImplementation libs.truth
testImplementation "androidx.test:core:$axtCoreVersion@aar"
Expand Down
@@ -0,0 +1,79 @@
package org.robolectric.integrationtests.kotlin.flow

import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattService
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.content.Context
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flow

/** A class that invokes Android Bluetooth LE APIs. */
class BluetoothProvisioner(applicationContext: Context) {

val context: Context

init {
context = applicationContext
}

fun startScan(): Flow<BluetoothDevice> = callbackFlow {
val scanCallback =
object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult?) {
if (result?.device != null) {
val unused = trySend(result.device)
}
}

override fun onScanFailed(errorCode: Int) {
cancel("BLE Scan Failed", null)
}
}
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val scanner = bluetoothManager.adapter.bluetoothLeScanner
scanner.startScan(scanCallback)
awaitClose { scanner.stopScan(scanCallback) }
}

fun connectToDevice(device: BluetoothDevice): Flow<BluetoothGatt> = callbackFlow {
val gattCallback =
object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
val unused = gatt!!.discoverServices()
} else {
cancel("Connect Failed", null)
}
}

override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) {
val unused = trySend(gatt!!)
} else {
cancel("Service discovery failed", null)
}
}
}

device.connectGatt(context, true, gattCallback)
awaitClose {}
}

fun scanAndConnect() =
flow<BluetoothGattService> {
val device = startScan().firstOrNull()
if (device != null) {
val gatt = connectToDevice(device).firstOrNull()
emit(gatt!!.services[0])
}
}
}
@@ -0,0 +1,83 @@
package org.robolectric.integrationtests.kotlin.flow

import android.bluetooth.BluetoothGattService
import android.bluetooth.BluetoothManager
import android.bluetooth.le.ScanResult
import android.content.Context
import android.os.Build.VERSION_CODES.S
import com.google.common.truth.Truth.assertThat
import java.util.UUID
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.android.util.concurrent.PausedExecutorService
import org.robolectric.annotation.Config
import org.robolectric.shadow.api.Shadow
import org.robolectric.shadows.ShadowBluetoothDevice
import org.robolectric.shadows.ShadowBluetoothGatt
import org.robolectric.shadows.ShadowBluetoothLeScanner

/**
* A test that uses a custom executor-backed coroutine dispatcher to control the execution of
* coroutines.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [S])
class BluetoothProvisionerTest {

val BLUETOOTH_MAC = "00:11:22:33:AA:BB"

val context = RuntimeEnvironment.getApplication()

val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager

fun newScanResult(): ScanResult {
val bluetoothDevice = bluetoothManager.adapter.getRemoteDevice(BLUETOOTH_MAC)
return ScanResult(bluetoothDevice, null, 0, 0)
}

@Test
fun testBluetoothProvisioner() {
val executor = PausedExecutorService()
val dispatcher = executor.asCoroutineDispatcher()
val scope = CoroutineScope(dispatcher)

scope.launch {
val gattService =
BluetoothProvisioner(RuntimeEnvironment.getApplication()).scanAndConnect().firstOrNull()
assertThat(gattService).isNotNull()
}

executor.runAll()

val scanner = bluetoothManager.adapter.bluetoothLeScanner
val shadowScanner = Shadow.extract<ShadowBluetoothLeScanner>(scanner)

val scanResult = newScanResult()
val bluetoothDevice = scanResult.device
shadowScanner.scanCallbacks.first().onScanResult(0, newScanResult())

executor.runAll()

val shadowDevice = Shadow.extract<ShadowBluetoothDevice>(bluetoothDevice)

val gatt = shadowDevice.bluetoothGatts.first()
val shadowGatt = Shadow.extract<ShadowBluetoothGatt>(gatt)

val service =
BluetoothGattService(
UUID.fromString("00000000-0000-0000-0000-0000000000A1"),
BluetoothGattService.SERVICE_TYPE_PRIMARY,
)

shadowGatt.addDiscoverableService(service)
shadowGatt.notifyConnection(BLUETOOTH_MAC)

executor.runAll()
}
}

0 comments on commit 6aecbb4

Please sign in to comment.