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