diff --git a/tests/CompanionDeviceMultiDeviceTests/OWNERS b/tests/CompanionDeviceMultiDeviceTests/OWNERS new file mode 100644 index 0000000000000..7517836d9d715 --- /dev/null +++ b/tests/CompanionDeviceMultiDeviceTests/OWNERS @@ -0,0 +1,6 @@ +# Bug component: 708992 +evanxinchen@google.com +guojing@google.com +jeremyns@google.com +raphk@google.com +yukl@google.com diff --git a/tests/CompanionDeviceMultiDeviceTests/README.md b/tests/CompanionDeviceMultiDeviceTests/README.md new file mode 100644 index 0000000000000..6cf735aaf00ad --- /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 0000000000000..1e68c9dd459f6 --- /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 0000000000000..11dc7634aab37 --- /dev/null +++ b/tests/CompanionDeviceMultiDeviceTests/client/AndroidManifest.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/CompanionDeviceMultiDeviceTests/client/proguard.flags b/tests/CompanionDeviceMultiDeviceTests/client/proguard.flags new file mode 100644 index 0000000000000..1c70253af87cb --- /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 0000000000000..3e4944a21258f --- /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 { + 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 0000000000000..ee587f5a8d4d5 --- /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 0000000000000..c7312d2e4b109 --- /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() + + // Registry of remote bluetooth devices + private val remoteDevices = mutableMapOf() + + // Set of connected client sockets + private val clientSockets = mutableMapOf() + } + + 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 0000000000000..c4d202694b87b --- /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 0000000000000..6983cb0354981 --- /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 0000000000000..1167a3e30ed49 --- /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 0000000000000..9d1813ff79bc0 --- /dev/null +++ b/tests/CompanionDeviceMultiDeviceTests/host/AndroidTest.xml @@ -0,0 +1,42 @@ + + + + diff --git a/tests/CompanionDeviceMultiDeviceTests/host/cdm_base_test.py b/tests/CompanionDeviceMultiDeviceTests/host/cdm_base_test.py new file mode 100644 index 0000000000000..bb10658c25c3b --- /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 0000000000000..9cb2d10fd2eea --- /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 0000000000000..86a11aa0abf19 --- /dev/null +++ b/tests/CompanionDeviceMultiDeviceTests/host/requirements.txt @@ -0,0 +1 @@ +mobly==1.12.1