Add multi-device tests for CDM.

Bug: 252817312
Test: atest CompanionDeviceManagerMultiDevicesTestCases
Change-Id: I8e5092d764c7e04de47029dfde15dfc4a04fafc4
diff --git a/tests/CompanionDeviceMultiDeviceTests/client/Android.bp b/tests/CompanionDeviceMultiDeviceTests/client/Android.bp
new file mode 100644
index 0000000..1e68c9d
--- /dev/null
+++ b/tests/CompanionDeviceMultiDeviceTests/client/Android.bp
@@ -0,0 +1,50 @@
+// Copyright (C) 2022 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 {
+    // See: http://go/android-license-faq
+    // A large-scale-change added 'default_applicable_licenses' to import
+    // all of the 'license_kinds' from "frameworks_base_license"
+    // to get the below license kinds:
+    //   SPDX-license-identifier-Apache-2.0
+    default_applicable_licenses: ["frameworks_base_license"],
+}
+
+android_test {
+    name: "cdm_snippet",
+    srcs: ["src/**/*.kt"],
+    manifest: "AndroidManifest.xml",
+
+    platform_apis: true,
+    target_sdk_version: "current",
+
+    static_libs: [
+        "androidx.test.ext.junit",
+        "androidx.test.uiautomator_uiautomator",
+        "compatibility-device-util-axt",
+        "cts-companion-common",
+        "cts-companion-uicommon",
+        "kotlin-stdlib",
+        "mobly-snippet-lib",
+    ],
+    libs: [
+        "android.test.base",
+        "android.test.runner",
+    ],
+
+    optimize: {
+        proguard_compatibility: true,
+        proguard_flags_files: ["proguard.flags"],
+    },
+}
diff --git a/tests/CompanionDeviceMultiDeviceTests/client/AndroidManifest.xml b/tests/CompanionDeviceMultiDeviceTests/client/AndroidManifest.xml
new file mode 100644
index 0000000..11dc763
--- /dev/null
+++ b/tests/CompanionDeviceMultiDeviceTests/client/AndroidManifest.xml
@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2022 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.
+  -->
+
+<manifest
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    package="android.companion.multidevices">
+
+  <uses-permission android:name="android.permission.INTERNET" />
+  <uses-permission android:name="android.permission.REQUEST_COMPANION_SELF_MANAGED" />
+  <uses-permission android:name="android.permission.REQUEST_OBSERVE_COMPANION_DEVICE_PRESENCE" />
+  <uses-permission android:name="android.permission.REQUEST_COMPANION_PROFILE_WATCH" />
+  <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
+  <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
+  <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
+  <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
+  <uses-permission android:name="android.permission.DELIVER_COMPANION_MESSAGES" />
+
+  <uses-feature android:name="android.hardware.bluetooth" android:required="true"/>
+  <uses-feature android:name="android.software.companion_device_setup" />
+
+  <application>
+    <!-- Add any classes that implement the Snippet interface as meta-data, whose
+         value is a comma-separated string, each section being the package path
+         of a snippet class -->
+    <meta-data
+        android:name="mobly-snippets"
+        android:value="android.companion.multidevices.CompanionDeviceManagerSnippet" />
+  </application>
+
+  <!-- Add an instrumentation tag so that the app can be launched through an
+       instrument command. The runner `com.google.android.mobly.snippet.SnippetRunner`
+       is derived from `AndroidJUnitRunner`, and is required to use the
+       Mobly Snippet Lib. -->
+  <instrumentation
+      android:name="com.google.android.mobly.snippet.SnippetRunner"
+      android:targetPackage="android.companion.multidevices" />
+</manifest>
diff --git a/tests/CompanionDeviceMultiDeviceTests/client/proguard.flags b/tests/CompanionDeviceMultiDeviceTests/client/proguard.flags
new file mode 100644
index 0000000..1c70253a
--- /dev/null
+++ b/tests/CompanionDeviceMultiDeviceTests/client/proguard.flags
@@ -0,0 +1,24 @@
+# Keep all companion classes.
+-keep class android.companion.** {
+    *;
+}
+
+# Do not touch Mobly.
+-keep class com.google.android.mobly.** {
+    *;
+}
+
+# Keep names for easy debugging.
+-dontobfuscate
+
+# Necessary to allow debugging.
+-keepattributes *
+
+# By default, proguard leaves all classes in their original package, which
+# needlessly repeats com.google.android.apps.etc.
+-repackageclasses ""
+
+# Allows proguard to make private and protected methods and fields public as
+# part of optimization. This lets proguard inline trivial getter/setter
+# methods.
+-allowaccessmodification
\ No newline at end of file
diff --git a/tests/CompanionDeviceMultiDeviceTests/client/src/android/companion/multidevices/CallbackUtils.kt b/tests/CompanionDeviceMultiDeviceTests/client/src/android/companion/multidevices/CallbackUtils.kt
new file mode 100644
index 0000000..3e4944a
--- /dev/null
+++ b/tests/CompanionDeviceMultiDeviceTests/client/src/android/companion/multidevices/CallbackUtils.kt
@@ -0,0 +1,107 @@
+/*
+ * Copyright (C) 2022 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.companion.multidevices
+
+import android.companion.AssociationInfo
+import android.companion.CompanionDeviceManager
+import android.companion.CompanionException
+import android.content.IntentSender
+import android.os.OutcomeReceiver
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit.SECONDS
+import java.util.concurrent.TimeoutException
+
+/** Blocking callbacks for Wi-Fi Aware and Connectivity Manager. */
+object CallbackUtils {
+    private const val TAG = "CDM_CallbackUtils"
+    private const val CALLBACK_TIMEOUT_SEC = 30L
+    private const val MESSAGE_CALLBACK_TIMEOUT_SEC = 5L
+
+    class AssociationCallback : CompanionDeviceManager.Callback() {
+        private val pending = CountDownLatch(1)
+        private val created = CountDownLatch(1)
+
+        private var pendingIntent: IntentSender? = null
+        private var associationInfo: AssociationInfo? = null
+        private var error: String? = null
+
+        override fun onAssociationPending(intentSender: IntentSender) {
+            this.pendingIntent = intentSender
+            pending.countDown()
+        }
+
+        override fun onAssociationCreated(associationInfo: AssociationInfo) {
+            this.associationInfo = associationInfo
+            created.countDown()
+        }
+
+        override fun onFailure(error: CharSequence?) {
+            this.error = error?.toString() ?: "There was an unexpected failure."
+            pending.countDown()
+            created.countDown()
+        }
+
+        fun waitForPendingIntent(): IntentSender? {
+            if (!pending.await(CALLBACK_TIMEOUT_SEC, SECONDS)) {
+                throw TimeoutException("Pending association request timed out.")
+            }
+
+            error?.let {
+                throw CompanionException(it)
+            }
+
+            return pendingIntent
+        }
+
+        fun waitForAssociation(): AssociationInfo? {
+            if (!created.await(CALLBACK_TIMEOUT_SEC, SECONDS)) {
+                throw TimeoutException("Association request timed out.")
+            }
+
+            error?.let {
+                throw CompanionException(it)
+            }
+
+            return associationInfo
+        }
+    }
+
+    class SystemDataTransferCallback : OutcomeReceiver<Void, CompanionException> {
+        private val completed = CountDownLatch(1)
+
+        private var error: CompanionException? = null
+
+        override fun onResult(result: Void?) {
+            completed.countDown()
+        }
+
+        override fun onError(error: CompanionException) {
+            this.error = error
+            completed.countDown()
+        }
+
+        fun waitForCompletion() {
+            if (!completed.await(CALLBACK_TIMEOUT_SEC, SECONDS)) {
+                throw TimeoutException("System data transfer timed out.")
+            }
+
+            error?.let {
+                throw it
+            }
+        }
+    }
+}
diff --git a/tests/CompanionDeviceMultiDeviceTests/client/src/android/companion/multidevices/CompanionDeviceManagerSnippet.kt b/tests/CompanionDeviceMultiDeviceTests/client/src/android/companion/multidevices/CompanionDeviceManagerSnippet.kt
new file mode 100644
index 0000000..ee587f5
--- /dev/null
+++ b/tests/CompanionDeviceMultiDeviceTests/client/src/android/companion/multidevices/CompanionDeviceManagerSnippet.kt
@@ -0,0 +1,183 @@
+/*
+ * Copyright (C) 2022 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.companion.multidevices
+
+import android.app.Instrumentation
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothManager
+import android.companion.AssociationInfo
+import android.companion.AssociationRequest
+import android.companion.BluetoothDeviceFilter
+import android.companion.CompanionDeviceManager
+import android.companion.CompanionException
+import android.companion.cts.common.CompanionActivity
+import android.companion.multidevices.CallbackUtils.AssociationCallback
+import android.companion.multidevices.CallbackUtils.SystemDataTransferCallback
+import android.companion.multidevices.bluetooth.BluetoothConnector
+import android.companion.multidevices.bluetooth.BluetoothController
+import android.companion.cts.uicommon.CompanionDeviceManagerUi
+import android.content.Context
+import android.os.Handler
+import android.os.HandlerExecutor
+import android.os.HandlerThread
+import android.util.Log
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.uiautomator.UiDevice
+import com.google.android.mobly.snippet.Snippet
+import com.google.android.mobly.snippet.event.EventCache
+import com.google.android.mobly.snippet.rpc.Rpc
+import java.util.concurrent.Executor
+import java.util.regex.Pattern
+
+/**
+ * Snippet class that exposes Android APIs in CompanionDeviceManager.
+ */
+class CompanionDeviceManagerSnippet : Snippet {
+    private val instrumentation: Instrumentation = InstrumentationRegistry.getInstrumentation()!!
+    private val context: Context = instrumentation.targetContext
+
+    private val btAdapter: BluetoothAdapter by lazy {
+        (context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter
+    }
+    private val companionDeviceManager: CompanionDeviceManager by lazy {
+        context.getSystemService(Context.COMPANION_DEVICE_SERVICE) as CompanionDeviceManager
+    }
+    private val btConnector: BluetoothConnector by lazy {
+        BluetoothConnector(btAdapter, companionDeviceManager)
+    }
+
+    private val uiDevice by lazy { UiDevice.getInstance(instrumentation) }
+    private val confirmationUi by lazy { CompanionDeviceManagerUi(uiDevice) }
+    private val btController by lazy { BluetoothController(context, btAdapter, uiDevice) }
+
+    private val eventCache = EventCache.getInstance()
+    private val handlerThread = HandlerThread("Snippet-Aware")
+    private val handler: Handler
+    private val executor: Executor
+
+    init {
+        handlerThread.start()
+        handler = Handler(handlerThread.looper)
+        executor = HandlerExecutor(handler)
+    }
+
+    /**
+     * Make device discoverable to other devices via BLE and return device name.
+     */
+    @Rpc(description = "Start advertising device to be discoverable.")
+    fun becomeDiscoverable(): String {
+        btController.becomeDiscoverable()
+        return btAdapter.name
+    }
+
+    /**
+     * Associate with a nearby device with given name and return newly-created association ID.
+     */
+    @Rpc(description = "Start device association flow.")
+    @Throws(Exception::class)
+    fun associate(deviceName: String): Int {
+        val filter = BluetoothDeviceFilter.Builder()
+            .setNamePattern(Pattern.compile(deviceName))
+            .build()
+        val request = AssociationRequest.Builder()
+            .setSingleDevice(true)
+            .addDeviceFilter(filter)
+            .build()
+        val callback = AssociationCallback()
+        companionDeviceManager.associate(request, callback, handler)
+        val pendingConfirmation = callback.waitForPendingIntent()
+            ?: throw CompanionException("Association is pending but intent sender is null.")
+        CompanionActivity.launchAndWait(context)
+        CompanionActivity.startIntentSender(pendingConfirmation)
+        confirmationUi.waitUntilVisible()
+        confirmationUi.waitUntilPositiveButtonIsEnabledAndClick()
+        confirmationUi.waitUntilGone()
+
+        val (_, result) = CompanionActivity.waitForActivityResult()
+        if (result == null) {
+            throw CompanionException("Association result can't be null.")
+        }
+
+        val association = result.getParcelableExtra(
+            CompanionDeviceManager.EXTRA_ASSOCIATION,
+            AssociationInfo::class.java
+        )
+        val remoteDevice = association.associatedDevice?.getBluetoothDevice()!!
+
+        // Register associated device
+        btConnector.registerDevice(association.id, remoteDevice)
+
+        return association.id
+    }
+
+    /**
+     * Disassociate an association with given ID.
+     */
+    @Rpc(description = "Disassociate device.")
+    @Throws(Exception::class)
+    fun disassociate(associationId: Int) {
+        companionDeviceManager.disassociate(associationId)
+    }
+
+    /**
+     * Consent to system data transfer and carry it out using Bluetooth socket.
+     */
+    @Rpc(description = "Start permissions sync.")
+    fun startPermissionsSync(associationId: Int) {
+        val pendingIntent = companionDeviceManager
+            .buildPermissionTransferUserConsentIntent(associationId)
+        CompanionActivity.launchAndWait(context)
+        CompanionActivity.startIntentSender(pendingIntent)
+        confirmationUi.waitUntilSystemDataTransferConfirmationVisible()
+        confirmationUi.clickPositiveButton()
+        confirmationUi.waitUntilGone()
+
+        CompanionActivity.waitForActivityResult()
+
+        val callback = SystemDataTransferCallback()
+        companionDeviceManager.startSystemDataTransfer(associationId, executor, callback)
+        callback.waitForCompletion()
+    }
+
+    @Rpc(description = "Attach transport to the BT client socket.")
+    fun attachClientSocket(id: Int) {
+        btConnector.attachClientSocket(id)
+    }
+
+    @Rpc(description = "Attach transport to the BT server socket.")
+    fun attachServerSocket(id: Int) {
+        btConnector.attachServerSocket(id)
+    }
+
+    @Rpc(description = "Close all open sockets.")
+    fun closeAllSockets() {
+        // Close all open sockets
+        btConnector.closeAllSockets()
+    }
+
+    @Rpc(description = "Disassociate all associations.")
+    fun disassociateAll() {
+        companionDeviceManager.myAssociations.forEach {
+            Log.d(TAG, "Disassociating id=${it.id}.")
+            companionDeviceManager.disassociate(it.id)
+        }
+    }
+
+    companion object {
+        private const val TAG = "CDM_CompanionDeviceManagerSnippet"
+    }
+}
diff --git a/tests/CompanionDeviceMultiDeviceTests/client/src/android/companion/multidevices/bluetooth/BluetoothConnector.kt b/tests/CompanionDeviceMultiDeviceTests/client/src/android/companion/multidevices/bluetooth/BluetoothConnector.kt
new file mode 100644
index 0000000..c7312d2
--- /dev/null
+++ b/tests/CompanionDeviceMultiDeviceTests/client/src/android/companion/multidevices/bluetooth/BluetoothConnector.kt
@@ -0,0 +1,155 @@
+/*
+ * Copyright (C) 2022 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.companion.multidevices.bluetooth
+
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothServerSocket
+import android.bluetooth.BluetoothSocket
+import android.companion.CompanionDeviceManager
+import android.util.Log
+import java.io.IOException
+import java.util.UUID
+
+class BluetoothConnector(
+    private val adapter: BluetoothAdapter,
+    private val cdm: CompanionDeviceManager
+) {
+    companion object {
+        private const val TAG = "CDM_BluetoothServer"
+
+        private val SERVICE_NAME = "CDM_BluetoothChannel"
+        private val SERVICE_UUID = UUID.fromString("435fe1d9-56c5-455d-a516-d5e6b22c52f9")
+
+        // Registry of bluetooth server threads
+        private val serverThreads = mutableMapOf<Int, BluetoothServerThread>()
+
+        // Registry of remote bluetooth devices
+        private val remoteDevices = mutableMapOf<Int, BluetoothDevice>()
+
+        // Set of connected client sockets
+        private val clientSockets = mutableMapOf<Int, BluetoothSocket>()
+    }
+
+    fun attachClientSocket(associationId: Int) {
+        try {
+            val device = remoteDevices[associationId]!!
+            val socket = device.createRfcommSocketToServiceRecord(SERVICE_UUID)
+            if (clientSockets.containsKey(associationId)) {
+                detachClientSocket(associationId)
+                clientSockets[associationId] = socket
+            } else {
+                clientSockets += associationId to socket
+            }
+
+            socket.connect()
+            Log.d(TAG, "Attaching client socket $socket.")
+            cdm.attachSystemDataTransport(
+                    associationId,
+                    socket.inputStream,
+                    socket.outputStream
+            )
+        } catch (e: IOException) {
+            Log.e(TAG, "Failed to attach client socket.", e)
+            throw RuntimeException(e)
+        }
+    }
+
+    fun attachServerSocket(associationId: Int) {
+        val serverThread: BluetoothServerThread
+        if (serverThreads.containsKey(associationId)) {
+            serverThread = serverThreads[associationId]!!
+        } else {
+            serverThread = BluetoothServerThread(associationId)
+            serverThreads += associationId to serverThread
+        }
+
+        // Start thread
+        if (!serverThread.isOpen) {
+            serverThread.start()
+        }
+    }
+
+    fun closeAllSockets() {
+        val iter = clientSockets.keys.iterator()
+        while (iter.hasNext()) {
+            detachClientSocket(iter.next())
+        }
+        for (thread in serverThreads.values) {
+            thread.shutdown()
+        }
+        serverThreads.clear()
+    }
+
+    fun registerDevice(associationId: Int, remoteDevice: BluetoothDevice) {
+        remoteDevices[associationId] = remoteDevice
+    }
+
+    private fun detachClientSocket(associationId: Int) {
+        try {
+            Log.d(TAG, "Detaching client socket.")
+            cdm.detachSystemDataTransport(associationId)
+            clientSockets[associationId]?.close()
+        } catch (e: IOException) {
+            Log.e(TAG, "Failed to detach client socket.", e)
+            throw RuntimeException(e)
+        }
+    }
+
+    inner class BluetoothServerThread(
+        private val associationId: Int
+    ) : Thread() {
+        private lateinit var mServerSocket: BluetoothServerSocket
+
+        var isOpen = false
+
+        override fun run() {
+            try {
+                Log.d(TAG, "Listening for remote connections...")
+                mServerSocket = adapter.listenUsingRfcommWithServiceRecord(
+                        SERVICE_NAME,
+                        SERVICE_UUID
+                )
+                isOpen = true
+                do {
+                    val socket = mServerSocket.accept()
+                    Log.d(TAG, "Attaching server socket $socket.")
+                    cdm.attachSystemDataTransport(
+                            associationId,
+                            socket.inputStream,
+                            socket.outputStream
+                    )
+                } while (isOpen)
+            } catch (e: IOException) {
+                throw RuntimeException(e)
+            }
+        }
+
+        fun shutdown() {
+            if (!isOpen || !this::mServerSocket.isInitialized) return
+
+            try {
+                Log.d(TAG, "Closing server socket.")
+                cdm.detachSystemDataTransport(associationId)
+                mServerSocket.close()
+                isOpen = false
+            } catch (e: IOException) {
+                throw RuntimeException(e)
+            }
+        }
+    }
+}
diff --git a/tests/CompanionDeviceMultiDeviceTests/client/src/android/companion/multidevices/bluetooth/BluetoothController.kt b/tests/CompanionDeviceMultiDeviceTests/client/src/android/companion/multidevices/bluetooth/BluetoothController.kt
new file mode 100644
index 0000000..c4d2026
--- /dev/null
+++ b/tests/CompanionDeviceMultiDeviceTests/client/src/android/companion/multidevices/bluetooth/BluetoothController.kt
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2022 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.companion.multidevices.bluetooth
+
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothDevice
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.SystemClock
+import android.util.Log
+import androidx.test.uiautomator.UiDevice
+import java.util.concurrent.TimeoutException
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+
+/** Controls the local Bluetooth adapter for testing. */
+class BluetoothController(
+    private val context: Context,
+    private val adapter: BluetoothAdapter,
+    private val ui: UiDevice
+) {
+    companion object {
+        private const val TAG = "CDM_BluetoothController"
+    }
+
+    private val bluetoothUi by lazy { BluetoothUi(ui) }
+
+    init {
+        Log.d(TAG, "Registering pairing listener.")
+        context.registerReceiver(
+            PairingBroadcastReceiver(),
+            IntentFilter(BluetoothDevice.ACTION_PAIRING_REQUEST)
+        )
+    }
+
+    val isEnabled: Boolean
+        get() = adapter.isEnabled
+
+    /** Turns on the local Bluetooth adapter */
+    fun enableBluetooth() {
+        if (isEnabled) return
+
+        val intent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
+        intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
+        context.startActivity(intent)
+        bluetoothUi.clickAllowButton()
+        waitFor { adapter.state == BluetoothAdapter.STATE_ON }
+    }
+
+    /** Become discoverable for specified duration */
+    fun becomeDiscoverable(duration: Duration = 15.seconds) {
+        enableBluetooth()
+
+        val intent = Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE)
+        intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
+        intent.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, duration.inWholeSeconds)
+        context.startActivity(intent)
+        bluetoothUi.clickAllowButton()
+    }
+
+    /** Unpair all devices for cleanup */
+    fun unpairAllDevices() {
+        for (device in adapter.bondedDevices) {
+            Log.d(TAG, "Unpairing $device.")
+            if (!device.removeBond()) continue
+            waitFor { device.bondState == BluetoothDevice.BOND_NONE }
+        }
+    }
+
+    private fun waitFor(
+        interval: Duration = 1.seconds,
+        timeout: Duration = 5.seconds,
+        condition: () -> Boolean
+    ) {
+        var elapsed = 0L
+        while (elapsed < timeout.inWholeMilliseconds) {
+            if (condition.invoke()) return
+            SystemClock.sleep(interval.inWholeMilliseconds)
+            elapsed += interval.inWholeMilliseconds
+        }
+        throw TimeoutException("Bluetooth did not become an expected state.")
+    }
+
+    inner class PairingBroadcastReceiver : BroadcastReceiver() {
+        override fun onReceive(context: Context, intent: Intent) {
+            Log.d(TAG, "Received broadcast for ${intent.action}")
+
+            // onReceive() somehow blocks pairing prompt from launching
+            Thread { bluetoothUi.confirmPairingRequest() }.start()
+            context.unregisterReceiver(this)
+        }
+    }
+}
diff --git a/tests/CompanionDeviceMultiDeviceTests/client/src/android/companion/multidevices/bluetooth/BluetoothUi.kt b/tests/CompanionDeviceMultiDeviceTests/client/src/android/companion/multidevices/bluetooth/BluetoothUi.kt
new file mode 100644
index 0000000..6983cb0
--- /dev/null
+++ b/tests/CompanionDeviceMultiDeviceTests/client/src/android/companion/multidevices/bluetooth/BluetoothUi.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright (C) 2022 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.companion.multidevices.bluetooth
+
+import android.companion.cts.uicommon.CompanionDeviceManagerUi
+import android.util.Log
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.UiDevice
+import androidx.test.uiautomator.Until
+import java.util.regex.Pattern
+
+class BluetoothUi(private val ui: UiDevice) : CompanionDeviceManagerUi(ui) {
+    fun clickAllowButton() = click(ALLOW_BUTTON, "Allow button")
+
+    fun confirmPairingRequest(): Boolean {
+        if (ui.hasObject(PAIRING_PIN_ENTRY)) {
+            // It is prompting for a custom user pin entry
+            Log.d(TAG, "Is user entry prompt.")
+            ui.findObject(PAIRING_PIN_ENTRY).text = "0000"
+            click(OK_BUTTON, "Ok button")
+        } else {
+            // It just needs user consent
+            Log.d(TAG, "Looking for pair button.")
+            val button = ui.wait(Until.findObject(PAIR_BUTTON), 1_000)
+            if (button != null) {
+                Log.d(TAG, "Pair button found.")
+                button.click()
+                return true
+            }
+            Log.d(TAG, "Pair button not found.")
+        }
+        return false
+    }
+
+    companion object {
+        private const val TAG = "CDM_BluetoothUi"
+
+        private val ALLOW_TEXT_PATTERN = caseInsensitive("allow")
+        private val ALLOW_BUTTON = By.text(ALLOW_TEXT_PATTERN).clickable(true)
+
+        private val PAIRING_PIN_ENTRY = By.clazz(".EditText")
+
+        private val OK_TEXT_PATTERN = caseInsensitive("ok")
+        private val OK_BUTTON = By.text(OK_TEXT_PATTERN).clickable(true)
+
+        private val PAIR_TEXT_PATTERN = caseInsensitive("pair")
+        private val PAIR_BUTTON = By.text(PAIR_TEXT_PATTERN).clickable(true)
+
+        private fun caseInsensitive(text: String): Pattern =
+            Pattern.compile(text, Pattern.CASE_INSENSITIVE)
+    }
+}