Add multi-device tests for CDM.
Bug: 252817312 Test: atest CompanionDeviceManagerMultiDevicesTestCases Change-Id: I8e5092d764c7e04de47029dfde15dfc4a04fafc4
This commit is contained in:
6
tests/CompanionDeviceMultiDeviceTests/OWNERS
Normal file
6
tests/CompanionDeviceMultiDeviceTests/OWNERS
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bug component: 708992
|
||||
evanxinchen@google.com
|
||||
guojing@google.com
|
||||
jeremyns@google.com
|
||||
raphk@google.com
|
||||
yukl@google.com
|
||||
17
tests/CompanionDeviceMultiDeviceTests/README.md
Normal file
17
tests/CompanionDeviceMultiDeviceTests/README.md
Normal file
@@ -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
|
||||
```
|
||||
50
tests/CompanionDeviceMultiDeviceTests/client/Android.bp
Normal file
50
tests/CompanionDeviceMultiDeviceTests/client/Android.bp
Normal file
@@ -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"],
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
24
tests/CompanionDeviceMultiDeviceTests/client/proguard.flags
Normal file
24
tests/CompanionDeviceMultiDeviceTests/client/proguard.flags
Normal file
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
51
tests/CompanionDeviceMultiDeviceTests/host/Android.bp
Normal file
51
tests/CompanionDeviceMultiDeviceTests/host/Android.bp
Normal file
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
42
tests/CompanionDeviceMultiDeviceTests/host/AndroidTest.xml
Normal file
42
tests/CompanionDeviceMultiDeviceTests/host/AndroidTest.xml
Normal file
@@ -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>
|
||||
63
tests/CompanionDeviceMultiDeviceTests/host/cdm_base_test.py
Normal file
63
tests/CompanionDeviceMultiDeviceTests/host/cdm_base_test.py
Normal file
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
mobly==1.12.1
|
||||
Reference in New Issue
Block a user