Add multi-device tests for CDM.

Bug: 252817312
Test: atest CompanionDeviceManagerMultiDevicesTestCases
Change-Id: I8e5092d764c7e04de47029dfde15dfc4a04fafc4
diff --git a/tests/CompanionDeviceMultiDeviceTests/OWNERS b/tests/CompanionDeviceMultiDeviceTests/OWNERS
new file mode 100644
index 0000000..7517836
--- /dev/null
+++ b/tests/CompanionDeviceMultiDeviceTests/OWNERS
@@ -0,0 +1,6 @@
+# Bug component: 708992
[email protected]
[email protected]
[email protected]
[email protected]
[email protected]
diff --git a/tests/CompanionDeviceMultiDeviceTests/README.md b/tests/CompanionDeviceMultiDeviceTests/README.md
new file mode 100644
index 0000000..6cf735a
--- /dev/null
+++ b/tests/CompanionDeviceMultiDeviceTests/README.md
@@ -0,0 +1,17 @@
+## CDM Multi-device Tests
+
+### Device Setup
+To test on physical devices, connect _two_ devices locally and enable USB debugging setting on both devices.
+
+When running on a cloudtop or other remote setups, use pontis to connect the devices on remote set up by running `pontis start`.
+Verify that pontis client is connected via `pontis status` and confirm that both devices are in "connected" state via `adb devices`.
+
+See go/pontis for more details regarding this workflow.
+
+To test on virtual devices, follow instructions to [set up netsim on cuttlefish](https://g3doc.corp.google.com/ambient/d2di/sim/g3doc/guide/cuttlefish.md?cl=head).
+Launch _two_ instances of virtual devices by specifying `--num_instances=2` parameter.
+
+### Running the Test
+```
+atest CompanionDeviceManagerMultiDeviceTestCases
+```
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)
+    }
+}
diff --git a/tests/CompanionDeviceMultiDeviceTests/host/Android.bp b/tests/CompanionDeviceMultiDeviceTests/host/Android.bp
new file mode 100644
index 0000000..1167a3e
--- /dev/null
+++ b/tests/CompanionDeviceMultiDeviceTests/host/Android.bp
@@ -0,0 +1,51 @@
+// 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"],
+}
+
+python_test_host {
+    name: "CompanionDeviceManagerMultiDeviceTestCases",
+    main: "cdm_transport_test.py",
+    srcs: ["*.py"],
+    libs: [
+        "mobly",
+    ],
+    test_suites: [
+        "general-tests",
+    ],
+    test_options: {
+        unit_test: false,
+        tags: ["mobly"],
+    },
+    data: [
+        ":cdm_snippet",
+        "requirements.txt",
+    ],
+    version: {
+        py2: {
+            enabled: false,
+        },
+        py3: {
+            enabled: true,
+            embedded_launcher: true,
+        },
+    },
+}
diff --git a/tests/CompanionDeviceMultiDeviceTests/host/AndroidTest.xml b/tests/CompanionDeviceMultiDeviceTests/host/AndroidTest.xml
new file mode 100644
index 0000000..9d1813f
--- /dev/null
+++ b/tests/CompanionDeviceMultiDeviceTests/host/AndroidTest.xml
@@ -0,0 +1,42 @@
+<?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.
+-->
+<configuration description="Config for CDM multi-device test cases">
+    <option name="test-tag" value="CompanionDeviceMultiDeviceTests" />
+    <option name="config-descriptor:metadata" key="component" value="framework" />
+    <option name="config-descriptor:metadata" key="parameter" value="not_instant_app" />
+    <option name="config-descriptor:metadata" key="parameter" value="not_multi_abi" />
+    <option name="config-descriptor:metadata" key="parameter" value="secondary_user" />
+
+    <object class="com.android.tradefed.testtype.suite.module.DeviceFeatureModuleController"
+        type="module_controller">
+        <option name="required-feature" value="android.software.companion_device_setup" />
+    </object>
+
+    <device name="device1">
+        <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
+            <option name="test-file-name" value="cdm_snippet.apk" />
+        </target_preparer>
+    </device>
+    <device name="device2">
+        <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
+            <option name="test-file-name" value="cdm_snippet.apk" />
+        </target_preparer>
+    </device>
+
+    <test class="com.android.tradefed.testtype.mobly.MoblyBinaryHostTest">
+      <!-- The mobly-par-file-name should match the module name -->
+      <option name="mobly-par-file-name" value="CompanionDeviceManagerMultiDeviceTestCases" />
+      <!-- Timeout limit in milliseconds for all test cases of the python binary -->
+      <option name="mobly-test-timeout" value="60000" />
+    </test>
+</configuration>
diff --git a/tests/CompanionDeviceMultiDeviceTests/host/cdm_base_test.py b/tests/CompanionDeviceMultiDeviceTests/host/cdm_base_test.py
new file mode 100644
index 0000000..bb10658
--- /dev/null
+++ b/tests/CompanionDeviceMultiDeviceTests/host/cdm_base_test.py
@@ -0,0 +1,63 @@
+# Lint as: python3
+"""
+Base class for setting up devices for CDM functionalities.
+"""
+
+from mobly import base_test
+from mobly import utils
+from mobly.controllers import android_device
+
+CDM_SNIPPET_PACKAGE = 'android.companion.multidevices'
+
+
+class BaseTestClass(base_test.BaseTestClass):
+
+    def setup_class(self):
+        # Declare that two Android devices are needed.
+        self.sender, self.receiver = self.register_controller(
+            android_device, min_number=2)
+        self.sender_id = None
+        self.receiver_id = None
+
+        def _setup_device(device):
+            device.load_snippet('cdm', CDM_SNIPPET_PACKAGE)
+            device.adb.shell('input keyevent KEYCODE_WAKEUP')
+            device.adb.shell('input keyevent KEYCODE_MENU')
+            device.adb.shell('input keyevent KEYCODE_HOME')
+
+            # Clean up existing associations
+            device.cdm.disassociateAll()
+
+        # Sets up devices in parallel to save time.
+        utils.concurrent_exec(
+            _setup_device,
+            ((self.sender,), (self.receiver,)),
+            max_workers=2,
+            raise_on_exception=True)
+
+    def associate_devices(self) -> tuple[int, int]:
+        """Associate devices with each other and return association IDs for both"""
+        # If association already exists, don't need another
+        if self.sender_id and self.receiver_id:
+            return (self.sender_id, self.receiver_id)
+
+        receiver_name = self.receiver.cdm.becomeDiscoverable()
+        self.receiver_id = self.sender.cdm.associate(receiver_name)
+
+        sender_name = self.sender.cdm.becomeDiscoverable()
+        self.sender_id = self.receiver.cdm.associate(sender_name)
+
+        return (self.sender_id, self.receiver_id)
+
+    def attach_transports(self):
+        """Attach transports to both devices"""
+        self.associate_devices()
+
+        self.receiver.cdm.attachServerSocket(self.sender_id)
+        self.sender.cdm.attachClientSocket(self.receiver_id)
+
+    def teardown_class(self):
+        """Clean up the opened sockets"""
+        self.sender.cdm.closeAllSockets()
+        self.receiver.cdm.closeAllSockets()
+
diff --git a/tests/CompanionDeviceMultiDeviceTests/host/cdm_transport_test.py b/tests/CompanionDeviceMultiDeviceTests/host/cdm_transport_test.py
new file mode 100644
index 0000000..9cb2d10
--- /dev/null
+++ b/tests/CompanionDeviceMultiDeviceTests/host/cdm_transport_test.py
@@ -0,0 +1,36 @@
+# Lint as: python3
+"""
+Test E2E CDM functions on mobly.
+"""
+
+import cdm_base_test
+import sys
+
+from mobly import asserts
+from mobly import test_runner
+
+CDM_SNIPPET_PACKAGE = 'android.companion.multidevices'
+
+
+class TransportTestClass(cdm_base_test.BaseTestClass):
+
+    def test_permissions_sync(self):
+        """This tests permissions sync from one device to another."""
+
+        # associate and attach transports
+        self.attach_transports()
+
+        # start permissions sync
+        self.sender.cdm.startPermissionsSync(self.receiver_id)
+
+
+if __name__ == '__main__':
+    try:
+        # Take test args and remove standalone '--' from the list
+        index = sys.argv.index('--')
+        sys.argv = sys.argv[:1] + sys.argv[index + 1:]
+    except ValueError:
+        # Ignore if '--' is not in args
+        pass
+
+    test_runner.main()
\ No newline at end of file
diff --git a/tests/CompanionDeviceMultiDeviceTests/host/requirements.txt b/tests/CompanionDeviceMultiDeviceTests/host/requirements.txt
new file mode 100644
index 0000000..86a11aa
--- /dev/null
+++ b/tests/CompanionDeviceMultiDeviceTests/host/requirements.txt
@@ -0,0 +1 @@
+mobly==1.12.1