Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit bfd5c9bb authored by Srinivas Patibandla's avatar Srinivas Patibandla Committed by Gerrit Code Review
Browse files

Merge "DckTest: Implemented LE scan with address and IRK tests" into main

parents 5881f6eb e4b29181
Loading
Loading
Loading
Loading
+2 −1
Original line number Diff line number Diff line
@@ -50,8 +50,9 @@ import pandora.HostProto
import pandora.HostProto.AdvertiseRequest
import pandora.HostProto.OwnAddressType

/** DCK GATT Tests */
@RunWith(Parameterized::class)
public class DckTest(private val connected: Boolean) {
public class DckGattTest(private val connected: Boolean) {

    private val context: Context = ApplicationProvider.getApplicationContext()
    private val bluetoothManager = context.getSystemService(BluetoothManager::class.java)!!
+119 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 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 android.bluetooth

import android.bluetooth.DckTestRule.LeScanResult
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanSettings
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.android.compatibility.common.util.AdoptShellPermissionsRule
import com.google.common.collect.Sets
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import org.junit.runners.Parameterized.Parameters

/** DCK LE Scan Tests */
@RunWith(Parameterized::class)
class DckScanTest(
    isRemoteAdvertisingWithUuid: Boolean,
    isBluetoothToggled: Boolean,
    isGattConnected: Boolean,
) {
    private val context: Context = ApplicationProvider.getApplicationContext()

    // Gives shell permissions during the test.
    @Rule(order = 0) @JvmField val shellPermissionRule = AdoptShellPermissionsRule()

    // Setup a Bumble Pandora device for the duration of the test.
    // Acting as a Pandora client, it can be interacted with through the Pandora APIs.
    @Rule(order = 1) @JvmField val bumble = PandoraDevice()

    // Test rule for common DCK test setup and teardown procedures, along with utility APIs.
    @Rule(order = 2)
    @JvmField
    val dck =
        DckTestRule(
            context,
            bumble,
            isBluetoothToggled = isBluetoothToggled,
            isRemoteAdvertisingWithUuid = isRemoteAdvertisingWithUuid,
            isGattConnected = isGattConnected
        )

    @Test
    fun scanForIrkAndIdentityAddress_remoteFound() {
        // TODO(316001793): Retrieve identity address from Bumble
        val scanFilter =
            ScanFilter.Builder()
                .setDeviceAddress(
                    TEST_ADDRESS_RANDOM_STATIC,
                    BluetoothDevice.ADDRESS_TYPE_RANDOM,
                    Utils.BUMBLE_IRK
                )
                .build()
        val scanSettings =
            ScanSettings.Builder()
                .setScanMode(ScanSettings.SCAN_MODE_AMBIENT_DISCOVERY)
                .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
                .setMatchMode(ScanSettings.MATCH_MODE_STICKY)
                .build()

        val result: LeScanResult = runBlocking {
            withTimeout(TIMEOUT_MS) { dck.scanWithPendingIntent(scanFilter, scanSettings).first() }
        }

        assertThat(result).isInstanceOf(LeScanResult.Success::class.java)
        assertThat((result as LeScanResult.Success).callbackType)
            .isEqualTo(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
        assertThat((result as LeScanResult.Success).scanResult.device.address)
            .isEqualTo(TEST_ADDRESS_RANDOM_STATIC)
    }

    companion object {
        private const val TIMEOUT_MS = 3000L
        private const val TEST_ADDRESS_RANDOM_STATIC = "F0:43:A8:23:10:11"

        // TODO(315852141): Include variations for LE only vs. Dual mode Bumble when supported
        // TODO(315852141): Include variations for two advertisements at the same time
        // TODO(303502437): Include variations for other callback types when supported in rootcanal
        @Parameters(
            name =
                "{index}: isRemoteAdvertisingWithUuid = {0}, " +
                    "isBluetoothToggled = {1}, isGattConnected = {2}"
        )
        @JvmStatic
        fun parameters(): Iterable<Array<Any>> {
            val booleanVariations = setOf(true, false)

            return Sets.cartesianProduct(
                    listOf(
                        /* isRemoteAdvertisingWithUuid */ booleanVariations,
                        /* isBluetoothToggled */ booleanVariations,
                        /* isGattConnected */ booleanVariations
                    )
                )
                .map { it.toTypedArray() }
        }
    }
}
+335 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 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 android.bluetooth

import android.app.PendingIntent
import android.bluetooth.le.BluetoothLeScanner
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import com.google.protobuf.Empty
import io.grpc.Deadline
import java.util.UUID
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
import pandora.HostProto
import pandora.HostProto.AdvertiseRequest
import pandora.HostProto.OwnAddressType

/** Test rule for DCK specific device and Bumble setup and teardown procedures */
class DckTestRule(
    private val context: Context,
    private val bumble: PandoraDevice,
    private val isBluetoothToggled: Boolean = false,
    private val isRemoteAdvertisingWithUuid: Boolean = false,
    private val isGattConnected: Boolean = false,
) : TestRule {
    private val bluetoothManager = context.getSystemService(BluetoothManager::class.java)!!
    private val bluetoothAdapter = bluetoothManager.adapter
    private val leScanner = bluetoothAdapter.bluetoothLeScanner

    private val scope = CoroutineScope(Dispatchers.Default)
    private val ioScope = CoroutineScope(Dispatchers.IO)

    // Internal Types

    /** Wrapper for [ScanResult] */
    sealed class LeScanResult {

        /** Represents a [ScanResult] with the associated [callbackType] */
        data class Success(val scanResult: ScanResult, val callbackType: Int) : LeScanResult()

        /** Represents a scan failure with an [errorCode] */
        data class Failure(val errorCode: Int) : LeScanResult()
    }

    /** Wrapper for [BluetoothGatt] along with its [state] and [status] */
    data class GattState(val gatt: BluetoothGatt, val status: Int, val state: Int)

    // Public Methods

    /**
     * Starts an LE scan with the given [scanFilter] and [scanSettings], using [ScanCallback] within
     * the given [coroutine].
     *
     * The caller can stop the scan at any time by cancelling the coroutine they used to start the
     * scan. If no coroutine was provided, a default coroutine is used and the scan will be stopped
     * at the end of the test.
     *
     * @return SharedFlow of [LeScanResult] with a buffer of size 1
     */
    fun scanWithCallback(
        scanFilter: ScanFilter,
        scanSettings: ScanSettings,
        coroutine: CoroutineScope = scope
    ) =
        callbackFlow {
                val callback =
                    object : ScanCallback() {
                        override fun onScanResult(callbackType: Int, result: ScanResult) {
                            trySend(LeScanResult.Success(result, callbackType))
                        }

                        override fun onScanFailed(errorCode: Int) {
                            trySend(LeScanResult.Failure(errorCode))
                            channel.close()
                        }
                    }

                leScanner.startScan(listOf(scanFilter), scanSettings, callback)

                awaitClose { leScanner.stopScan(callback) }
            }
            .conflate()
            .shareIn(coroutine, SharingStarted.Lazily)

    /**
     * Starts an LE scan with the given [scanFilter] and [scanSettings], using [PendingIntent]
     * within the given [coroutine].
     *
     * The caller can stop the scan at any time by cancelling the coroutine they used to start the
     * scan. If no coroutine was provided, a default coroutine is used and the scan will be stopped
     * at the end of the test.
     *
     * @return SharedFlow of [LeScanResult] with a buffer of size 1
     */
    fun scanWithPendingIntent(
        scanFilter: ScanFilter,
        scanSettings: ScanSettings,
        coroutine: CoroutineScope = scope
    ) =
        callbackFlow {
                val intentFilter = IntentFilter(ACTION_DYNAMIC_RECEIVER_SCAN_RESULT)
                val broadcastReceiver =
                    object : BroadcastReceiver() {
                        override fun onReceive(context: Context, intent: Intent) {
                            if (ACTION_DYNAMIC_RECEIVER_SCAN_RESULT == intent.action) {
                                val results =
                                    intent.getParcelableArrayListExtra<ScanResult>(
                                        BluetoothLeScanner.EXTRA_LIST_SCAN_RESULT
                                    )
                                        ?: return

                                val callbackType =
                                    intent.getIntExtra(BluetoothLeScanner.EXTRA_CALLBACK_TYPE, -1)

                                for (result in results) {
                                    trySend(LeScanResult.Success(result, callbackType))
                                }
                            }
                        }
                    }

                context.registerReceiver(broadcastReceiver, intentFilter)

                val scanIntent = Intent(ACTION_DYNAMIC_RECEIVER_SCAN_RESULT)
                val pendingIntent =
                    PendingIntent.getBroadcast(
                        context,
                        0,
                        scanIntent,
                        PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
                    )

                leScanner.startScan(listOf(scanFilter), scanSettings, pendingIntent)

                awaitClose {
                    context.unregisterReceiver(broadcastReceiver)
                    leScanner.stopScan(pendingIntent)
                }
            }
            .conflate()
            .shareIn(coroutine, SharingStarted.Lazily)

    /**
     * Requests a GATT connection to the given [device] within the given [coroutine].
     *
     * Cancelling the coroutine will close the GATT client. If no coroutine was provided, a default
     * coroutine is used and the GATT client will be closed at the end of the test.
     *
     * @return SharedFlow of [GattState] with a buffer of size 1
     */
    fun connectGatt(device: BluetoothDevice, coroutine: CoroutineScope = ioScope) =
        callbackFlow {
                val callback =
                    object : BluetoothGattCallback() {
                        override fun onConnectionStateChange(
                            gatt: BluetoothGatt,
                            status: Int,
                            newState: Int
                        ) {
                            trySend(GattState(gatt, status, newState))
                        }
                    }

                val gatt = device.connectGatt(context, false, callback)

                awaitClose { gatt.close() }
            }
            .conflate()
            .shareIn(coroutine, SharingStarted.Lazily)

    // TestRule Overrides

    override fun apply(base: Statement, description: Description): Statement {
        return object : Statement() {
            override fun evaluate() {
                setup(base)
            }
        }
    }

    // Private Methods

    private fun setup(base: Statement) {
        // Register Bumble's DCK (Digital Car Key) service
        registerDckService()
        // Start LE advertisement on Bumble
        advertiseWithBumble()

        try {
            if (isBluetoothToggled) {
                toggleBluetooth()
            }

            if (isGattConnected) {
                connectGatt()
            }

            base.evaluate()
        } finally {
            reset()
        }
    }

    private fun registerDckService() {
        bumble
            .dckBlocking()
            .withDeadline(Deadline.after(TIMEOUT_MS, TimeUnit.MILLISECONDS))
            .register(Empty.getDefaultInstance())
    }

    private fun advertiseWithBumble() {
        val requestBuilder =
            AdvertiseRequest.newBuilder()
                .setLegacy(true) // Bumble currently only supports legacy advertising.
                .setOwnAddressType(OwnAddressType.RANDOM)
                .setConnectable(true)

        if (isRemoteAdvertisingWithUuid) {
            val advertisementData =
                HostProto.DataTypes.newBuilder()
                    .addCompleteServiceClassUuids128(CCC_DK_UUID.toString())
                    .build()
            requestBuilder.setData(advertisementData)
        }

        bumble.hostBlocking().advertise(requestBuilder.build())
    }

    private fun toggleBluetooth() = runBlocking {
        val scope = CoroutineScope(Dispatchers.Default)
        val bluetoothStateFlow = getBluetoothStateFlow(scope)

        try {
            withTimeout(TIMEOUT_MS * 2) { // Combined timeout for enabling and disabling BT
                if (bluetoothAdapter.isEnabled()) {
                    // Disable Bluetooth
                    bluetoothAdapter.disable()
                    // Wait for the BT state change to STATE_OFF
                    bluetoothStateFlow.first { it == BluetoothAdapter.STATE_OFF }
                }

                // Enable Bluetooth
                bluetoothAdapter.enable()
                // Wait for the BT state change to STATE_ON
                bluetoothStateFlow.first { it == BluetoothAdapter.STATE_ON }
            }
        } finally {
            // Close the BT state change flow
            scope.cancel("Done")
        }
    }

    private fun getBluetoothStateFlow(coroutine: CoroutineScope) =
        callbackFlow {
                val bluetoothStateFilter = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)
                val bluetoothStateReceiver =
                    object : BroadcastReceiver() {
                        override fun onReceive(context: Context, intent: Intent) {
                            if (BluetoothAdapter.ACTION_STATE_CHANGED == intent.action) {
                                trySend(
                                    intent.getIntExtra(
                                        BluetoothAdapter.EXTRA_STATE,
                                        BluetoothAdapter.ERROR
                                    )
                                )
                            }
                        }
                    }

                context.registerReceiver(bluetoothStateReceiver, bluetoothStateFilter)

                awaitClose { context.unregisterReceiver(bluetoothStateReceiver) }
            }
            .conflate()
            .shareIn(coroutine, SharingStarted.Lazily)

    private fun connectGatt() = runBlocking {
        // TODO(315852141): Use supported Bumble for the given type (LE Only vs. Dual Mode)
        val bumbleDevice =
            bluetoothAdapter.getRemoteLeDevice(
                Utils.BUMBLE_RANDOM_ADDRESS,
                BluetoothDevice.ADDRESS_TYPE_RANDOM
            )

        withTimeout(TIMEOUT_MS) {
            connectGatt(bumbleDevice).first { it.state == BluetoothProfile.STATE_CONNECTED }
        }
    }

    private fun reset() {
        scope.cancel("Test Completed")
        ioScope.cancel("Test Completed")
    }

    companion object {
        private const val TIMEOUT_MS = 3000L
        private const val ACTION_DYNAMIC_RECEIVER_SCAN_RESULT =
            "android.bluetooth.test.ACTION_DYNAMIC_RECEIVER_SCAN_RESULT"
        // CCC DK Specification R3 1.2.0 r14 section 19.2.1.2 Bluetooth Le Pairing
        private val CCC_DK_UUID = UUID.fromString("0000FFF5-0000-1000-8000-00805f9b34fb")
    }
}