Add deviceshadower and robotest for BluetoothClassicPairer.
Test: robo test.
Bug: 200231384
Change-Id: If53bf854be6172fe7eb01785c3f18551a063a92c
diff --git a/nearby/tests/robotests/Android.bp b/nearby/tests/robotests/Android.bp
new file mode 100644
index 0000000..14b0815
--- /dev/null
+++ b/nearby/tests/robotests/Android.bp
@@ -0,0 +1,52 @@
+//############################################
+// Nearby Robolectric test target. #
+//############################################
+
+package {
+ default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_app {
+ name: "FastPairTest",
+
+ srcs: ["src/**/*.java"],
+ java_resource_dirs: ["config"],
+
+ platform_apis: true,
+ optimize: {
+ enabled: false,
+ },
+
+ libs: [
+ "android-support-annotations",
+ "services.core",
+ ],
+
+ static_libs: [
+ "androidx.test.core",
+ "androidx.core_core",
+ "androidx.annotation_annotation",
+ "androidx.legacy_legacy-support-v4",
+ "androidx.recyclerview_recyclerview",
+ "androidx.preference_preference",
+ "androidx.appcompat_appcompat",
+ "androidx.lifecycle_lifecycle-runtime",
+ "androidx.mediarouter_mediarouter-nodeps",
+ "error_prone_annotations",
+ "mockito-robolectric-prebuilt",
+ "service-nearby",
+ "truth-prebuilt",
+ "robolectric_android-all-stub",
+ "Robolectric_all-target",
+ ],
+}
+
+android_robolectric_test {
+ name: "FastPairRoboTests",
+ srcs: ["src/**/*.java"],
+ instrumentation_for: "FastPairTest",
+ test_options: {
+ // timeout in seconds.
+ timeout: 36000,
+ },
+}
diff --git a/nearby/tests/robotests/AndroidManifest.xml b/nearby/tests/robotests/AndroidManifest.xml
new file mode 100644
index 0000000..25376cf
--- /dev/null
+++ b/nearby/tests/robotests/AndroidManifest.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright (C) 2018 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="com.android.server.nearby.common.bluetooth.fastpair.test">
+</manifest>
diff --git a/nearby/tests/robotests/config/robolectric.properties b/nearby/tests/robotests/config/robolectric.properties
new file mode 100644
index 0000000..932de7d
--- /dev/null
+++ b/nearby/tests/robotests/config/robolectric.properties
@@ -0,0 +1,16 @@
+#
+# Copyright (C) 2021 Google Inc.
+#
+# 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.
+#
+sdk=NEWEST_SDK
\ No newline at end of file
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Bluelet.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Bluelet.java
new file mode 100644
index 0000000..182fde7
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Bluelet.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower;
+
+import android.os.ParcelUuid;
+
+/**
+ * User interface for mocking and simulation of a Bluetooth device.
+ */
+public interface Bluelet {
+
+ /**
+ * See {@link #setCreateBondOutcome}.
+ */
+ enum CreateBondOutcome {
+ SUCCESS,
+ FAILURE,
+ TIMEOUT
+ }
+
+ /**
+ * See {@link #setIoCapabilities}. Note that Bluetooth specifies a few more choices, but this is
+ * all DeviceShadower currently supports.
+ */
+ enum IoCapabilities {
+ NO_INPUT_NO_OUTPUT,
+ DISPLAY_YES_NO,
+ KEYBOARD_ONLY
+ }
+
+ /**
+ * See {@link #setFetchUuidsTiming}.
+ */
+ enum FetchUuidsTiming {
+ BEFORE_BONDING,
+ AFTER_BONDING,
+ NEVER
+ }
+
+ /**
+ * Set the initial state of the local Bluetooth adapter at the beginning of the test.
+ * <p>This method is not associated with broadcast event and is intended to be called at the
+ * beginning of the test. Allowed states:
+ *
+ * @see android.bluetooth.BluetoothAdapter#STATE_OFF
+ * @see android.bluetooth.BluetoothAdapter#STATE_ON
+ * </p>
+ */
+ Bluelet setAdapterInitialState(int state) throws IllegalArgumentException;
+
+ /**
+ * Set the bluetooth class of the local Bluetooth device at the beginning of the test.
+ * <p>
+ *
+ * @see android.bluetooth.BluetoothClass.Device
+ * @see android.bluetooth.BluetoothClass.Service
+ */
+ Bluelet setBluetoothClass(int bluetoothClass);
+
+ /**
+ * Set the scan mode of the local Bluetooth device at the beginning of the test.
+ */
+ Bluelet setScanMode(int scanMode);
+
+ /**
+ * Set the Bluetooth profiles supported by this device (e.g. A2DP Sink).
+ */
+ Bluelet setProfileUuids(ParcelUuid... profileUuids);
+
+ /**
+ * Makes bond attempts with this device succeed or fail.
+ *
+ * @param failureReason Ignored unless outcome is {@link CreateBondOutcome#FAILURE}. This is
+ * delivered in the intent that indicates bond state has changed to BOND_NONE. Values:
+ * https://cs.corp.google.com/android/frameworks/base/core/java/android/bluetooth/BluetoothDevice.java?rcl=38d9ee4cd661c10e012f71051d23644c65607eed&l=472
+ */
+ Bluelet setCreateBondOutcome(CreateBondOutcome outcome, int failureReason);
+
+ /**
+ * Sets the IO capabilities of this device. When bonding, a device states its IO capabilities in
+ * the pairing request. The pairing variant used depends on the IO capabilities of both devices
+ * (e.g. Just Works is the only available option for a NoInputNoOutput device, while Numeric
+ * Comparison aka Passkey Confirmation is used if both devices have a display and the ability to
+ * confirm/deny).
+ *
+ * @see <a href="https://blog.bluetooth.com/bluetooth-pairing-part-4">Bluetooth blog</a>
+ */
+ Bluelet setIoCapabilities(IoCapabilities ioCapabilities);
+
+ /**
+ * Make the device refuse connections. By default, connections are accepted.
+ *
+ * @param refuse Connections are refused if True.
+ */
+ Bluelet setRefuseConnections(boolean refuse);
+
+ /**
+ * Make the device refuse GATT connections. By default. connections are accepted.
+ *
+ * @param refuse GATT connections are refused if true.
+ */
+ Bluelet setRefuseGattConnections(boolean refuse);
+
+ /**
+ * When to send the ACTION_UUID broadcast. This can be {@link FetchUuidsTiming#BEFORE_BONDING},
+ * {@link FetchUuidsTiming#AFTER_BONDING}, or {@link FetchUuidsTiming#NEVER}. The default is
+ * {@link FetchUuidsTiming#AFTER_BONDING}.
+ */
+ Bluelet setFetchUuidsTiming(FetchUuidsTiming fetchUuidsTiming);
+
+ /**
+ * Adds a bonded device to the BluetoothAdapter.
+ */
+ Bluelet addBondedDevice(String address);
+
+ /**
+ * Enables the CVE-2019-2225 represents that the pairing variant will switch from Just Works to
+ * Consent when local device's io capability is Display Yes/No and remote is NoInputNoOutput.
+ *
+ * @see <a href="https://source.android.com/security/bulletin/2019-12-01#system">the security
+ * bulletin at 2019-12-01</a>
+ */
+ Bluelet enableCVE20192225(boolean value);
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/DeviceShadowEnvironment.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/DeviceShadowEnvironment.java
new file mode 100644
index 0000000..513d649
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/DeviceShadowEnvironment.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower;
+
+import com.android.libraries.testing.deviceshadower.Enums.Distance;
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+
+import java.util.concurrent.Callable;
+import java.util.concurrent.Future;
+
+/**
+ * Environment to setup and config Bluetooth unit test.
+ */
+public class DeviceShadowEnvironment {
+
+ private static final String TAG = "DeviceShadowEnvironment";
+ private static final long RESET_TIMEOUT_MILLIS = 3000;
+
+ private static boolean sIsInitialized = false;
+
+ private DeviceShadowEnvironment() {
+ }
+
+ public static void init() {
+ sIsInitialized = true;
+ DeviceShadowEnvironmentImpl.reset();
+ }
+
+ public static void reset() {
+ sIsInitialized = false;
+
+ // Order matters because each steps check and manipulate internal objects in order.
+ // Wait Scheduler and executors complete, and shut down executors.
+ DeviceShadowEnvironmentImpl.await(RESET_TIMEOUT_MILLIS);
+
+ // Throw RuntimeException if there is any internal exceptions.
+ DeviceShadowEnvironmentImpl.checkInternalExceptions();
+
+ // Clear internal exceptions, and devicelets.
+ DeviceShadowEnvironmentImpl.reset();
+ }
+
+ public static boolean await(long timeoutMillis) {
+ return DeviceShadowEnvironmentImpl.await(timeoutMillis);
+ }
+
+ public static Devicelet addDevice(final String address) {
+ return DeviceShadowEnvironmentImpl.addDevice(address);
+ }
+
+ public static void removeDevice(String address) {
+ DeviceShadowEnvironmentImpl.removeDevice(address);
+ }
+
+ public static void setLocalDevice(final String address) {
+ DeviceShadowEnvironmentImpl.setLocalDevice(address);
+ }
+
+ public static void putNear(String address1, String address2) {
+ DeviceShadowEnvironmentImpl.setDistance(address1, address2, Distance.NEAR);
+ }
+
+ public static void setDistance(String address1, String address2, Distance distance) {
+ DeviceShadowEnvironmentImpl.setDistance(address1, address2, distance);
+ }
+
+ public static Future<Void> run(final String address, final Runnable snippet) {
+ return run(
+ address,
+ () -> {
+ snippet.run();
+ return null;
+ });
+ }
+
+ public static <T> Future<T> run(final String address, final Callable<T> snippet) {
+ return DeviceShadowEnvironmentImpl.run(address, snippet);
+ }
+
+ /* package */
+ static boolean isInitialized() {
+ return sIsInitialized;
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/DeviceShadowEnvironmentInternal.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/DeviceShadowEnvironmentInternal.java
new file mode 100644
index 0000000..a5f8e6d
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/DeviceShadowEnvironmentInternal.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower;
+
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.sms.SmsContentProvider;
+
+/**
+ * Internal interface for device shadower.
+ */
+public class DeviceShadowEnvironmentInternal {
+
+ /**
+ * Set an interruptible point to tested code.
+ * <p>
+ * This should only make changes when DeviceShadowEnvironment initialized, which means only in
+ * test cases.
+ */
+ public static void setInterruptibleBluetooth(int identifier) {
+ if (DeviceShadowEnvironment.isInitialized()) {
+ assert identifier > 0;
+ DeviceShadowEnvironmentImpl.setInterruptibleBluetooth(identifier);
+ }
+ }
+
+ /**
+ * Mark all bluetooth operation broken after identifier in tested code.
+ */
+ public static void interruptBluetooth(String address, int identifier) {
+ DeviceShadowEnvironmentImpl.interruptBluetooth(address, identifier);
+ }
+
+ /**
+ * Return SMS content provider to be registered by robolectric context.
+ */
+ public static Class<SmsContentProvider> getSmsContentProviderClass() {
+ return SmsContentProvider.class;
+ }
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Devicelet.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Devicelet.java
new file mode 100644
index 0000000..bf31ead
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Devicelet.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower;
+
+/**
+ * Devicelet is the handler to operate shadowed device objects in DeviceShadower.
+ */
+public interface Devicelet {
+
+ Bluelet bluetooth();
+
+ Nfclet nfc();
+
+ Smslet sms();
+
+ String getAddress();
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Enums.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Enums.java
new file mode 100644
index 0000000..9eb3514
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Enums.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower;
+
+/**
+ * Contains Enums used by DeviceShadower in interface and internally.
+ */
+public interface Enums {
+
+ /**
+ * Represents vague distance between two devicelets.
+ */
+ enum Distance {
+ NEAR,
+ MID,
+ FAR,
+ AWAY,
+ }
+
+ /**
+ * Abstract base interface for operations.
+ */
+ interface Operation {
+
+ }
+
+ /**
+ * NFC operations.
+ */
+ enum NfcOperation implements Operation {
+ GET_ADAPTER,
+ ENABLE,
+ DISABLE,
+ }
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Nfclet.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Nfclet.java
new file mode 100644
index 0000000..4b00f24
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Nfclet.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower;
+
+/**
+ * Interface of Nfclet
+ */
+public interface Nfclet {
+
+ Nfclet setInitialState(int state);
+
+ Nfclet setInterruptOperation(Enums.NfcOperation operation);
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Smslet.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Smslet.java
new file mode 100644
index 0000000..483fab6
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/Smslet.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower;
+
+import android.net.Uri;
+
+/**
+ * Interface of Smslet
+ */
+public interface Smslet {
+
+ Smslet addSms(Uri uri, String body);
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetooth.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetooth.java
new file mode 100644
index 0000000..be8390e
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetooth.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright 2021 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.bluetooth;
+
+import android.content.AttributionSource;
+import android.os.ParcelFileDescriptor;
+import android.os.ParcelUuid;
+
+/**
+ * Fake interface replacement for hidden IBluetooth class
+ */
+public interface IBluetooth {
+
+ // Bluetooth settings.
+ String getAddress();
+
+ String getName();
+
+ boolean setName(String name);
+
+ // Remote device properties.
+ int getRemoteClass(BluetoothDevice device);
+
+ String getRemoteName(BluetoothDevice device);
+
+ int getRemoteType(BluetoothDevice device, AttributionSource attributionSource);
+
+ ParcelUuid[] getRemoteUuids(BluetoothDevice device);
+
+ boolean fetchRemoteUuids(BluetoothDevice device);
+
+ // Bluetooth discovery.
+ int getScanMode();
+
+ boolean setScanMode(int mode, int duration);
+
+ int getDiscoverableTimeout();
+
+ boolean setDiscoverableTimeout(int timeout);
+
+ boolean startDiscovery();
+
+ boolean cancelDiscovery();
+
+ boolean isDiscovering();
+
+ // Adapter state.
+ boolean isEnabled();
+
+ int getState();
+
+ boolean enable();
+
+ boolean disable();
+
+ // Rfcomm sockets.
+ ParcelFileDescriptor connectSocket(BluetoothDevice device, int type, ParcelUuid uuid,
+ int port, int flag);
+
+ ParcelFileDescriptor createSocketChannel(int type, String serviceName, ParcelUuid uuid,
+ int port, int flag);
+
+ // BLE settings.
+ /* SINCE SDK 21 */ boolean isMultiAdvertisementSupported();
+
+ /* SINCE SDK 22 */ boolean isPeripheralModeSupported();
+
+ /* SINCE SDK 21 */ boolean isOffloadedFilteringSupported();
+
+ // Bonding (pairing).
+ int getBondState(BluetoothDevice device, AttributionSource attributionSource);
+
+ boolean createBond(BluetoothDevice device, int transport, OobData remoteP192Data,
+ OobData remoteP256Data, AttributionSource attributionSource);
+
+ boolean setPairingConfirmation(BluetoothDevice device, boolean accept,
+ AttributionSource attributionSource);
+
+ boolean setPasskey(BluetoothDevice device, int passkey);
+
+ boolean cancelBondProcess(BluetoothDevice device);
+
+ boolean removeBond(BluetoothDevice device);
+
+ BluetoothDevice[] getBondedDevices();
+
+ // Connecting to profiles.
+ int getAdapterConnectionState();
+
+ int getProfileConnectionState(int profile);
+
+ // Access permissions
+ int getPhonebookAccessPermission(BluetoothDevice device);
+
+ boolean setPhonebookAccessPermission(BluetoothDevice device, int value);
+
+ int getMessageAccessPermission(BluetoothDevice device);
+
+ boolean setMessageAccessPermission(BluetoothDevice device, int value);
+
+ int getSimAccessPermission(BluetoothDevice device);
+
+ boolean setSimAccessPermission(BluetoothDevice device, int value);
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothGatt.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothGatt.java
new file mode 100644
index 0000000..16e4f01
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothGatt.java
@@ -0,0 +1,144 @@
+/*
+ * Copyright 2021 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.bluetooth;
+
+import android.bluetooth.le.AdvertiseData;
+import android.bluetooth.le.AdvertiseSettings;
+import android.bluetooth.le.ScanFilter;
+import android.bluetooth.le.ScanSettings;
+import android.os.ParcelUuid;
+
+import java.util.List;
+
+/**
+ * Fake interface replacement for IBluetoothGatt
+ * TODO(b/200231384): include >=N interface.
+ */
+public interface IBluetoothGatt {
+
+ /* ONLY SDK 23 */
+ void startScan(int appIf, boolean isServer, ScanSettings settings,
+ List<ScanFilter> filters, List<?> scanStorages, String callPackage);
+
+ /* ONLY SDK 21 */
+ void startScan(int appIf, boolean isServer, ScanSettings settings,
+ List<ScanFilter> filters, List<?> scanStorages);
+
+ /* SINCE SDK 21 */
+ void stopScan(int appIf, boolean isServer);
+
+ /* SINCE SDK 21 */
+ void startMultiAdvertising(
+ int appIf, AdvertiseData advertiseData, AdvertiseData scanResponse,
+ AdvertiseSettings settings);
+
+ /* SINCE SDK 21 */
+ void stopMultiAdvertising(int appIf);
+
+ /* SINCE SDK 21 */
+ void registerClient(ParcelUuid appId, IBluetoothGattCallback callback);
+
+ /* SINCE SDK 21 */
+ void unregisterClient(int clientIf);
+
+ /* SINCE SDK 21 */
+ void clientConnect(int clientIf, String address, boolean isDirect, int transport);
+
+ /* SINCE SDK 21 */
+ void clientDisconnect(int clientIf, String address);
+
+ /* SINCE SDK 21 */
+ void discoverServices(int clientIf, String address);
+
+ /* SINCE SDK 21 */
+ void readCharacteristic(int clientIf, String address, int srvcType,
+ int srvcInstanceId, ParcelUuid srvcId, int charInstanceId, ParcelUuid charId,
+ int authReq);
+
+ /* SINCE SDK 21 */
+ void writeCharacteristic(int clientIf, String address, int srvcType,
+ int srvcInstanceId, ParcelUuid srvcId, int charInstanceId, ParcelUuid charId,
+ int writeType, int authReq, byte[] value);
+
+ /* SINCE SDK 21 */
+ void readDescriptor(int clientIf, String address, int srvcType,
+ int srvcInstanceId, ParcelUuid srvcId, int charInstanceId, ParcelUuid charId,
+ int descrInstanceId, ParcelUuid descrUuid, int authReq);
+
+ /* SINCE SDK 21 */
+ void writeDescriptor(int clientIf, String address, int srvcType,
+ int srvcInstanceId, ParcelUuid srvcId, int charInstanceId, ParcelUuid charId,
+ int descrInstanceId, ParcelUuid descrId, int writeType, int authReq, byte[] value);
+
+ /* SINCE SDK 21 */
+ void registerForNotification(int clientIf, String address, int srvcType,
+ int srvcInstanceId, ParcelUuid srvcId, int charInstanceId, ParcelUuid charId,
+ boolean enable);
+
+ /* SINCE SDK 21 */
+ void registerServer(ParcelUuid appId, IBluetoothGattServerCallback callback);
+
+ /* SINCE SDK 21 */
+ void unregisterServer(int serverIf);
+
+ /* SINCE SDK 21 */
+ void serverConnect(int servertIf, String address, boolean isDirect, int transport);
+
+ /* SINCE SDK 21 */
+ void serverDisconnect(int serverIf, String address);
+
+ /* SINCE SDK 21 */
+ void beginServiceDeclaration(int serverIf, int srvcType, int srvcInstanceId, int minHandles,
+ ParcelUuid srvcId, boolean advertisePreferred);
+
+ /* SINCE SDK 21 */
+ void addIncludedService(int serverIf, int srvcType, int srvcInstanceId, ParcelUuid srvcId);
+
+ /* SINCE SDK 21 */
+ void addCharacteristic(int serverIf, ParcelUuid charId, int properties, int permissions);
+
+ /* SINCE SDK 21 */
+ void addDescriptor(int serverIf, ParcelUuid descId, int permissions);
+
+ /* SINCE SDK 21 */
+ void endServiceDeclaration(int serverIf);
+
+ /* SINCE SDK 21 */
+ void removeService(int serverIf, int srvcType, int srvcInstanceId, ParcelUuid srvcId);
+
+ /* SINCE SDK 21 */
+ void clearServices(int serverIf);
+
+ /* SINCE SDK 21 */
+ void sendResponse(int serverIf, String address, int requestId,
+ int status, int offset, byte[] value);
+
+ /* SINCE SDK 21 */
+ void sendNotification(int serverIf, String address, int srvcType,
+ int srvcInstanceId, ParcelUuid srvcId, int charInstanceId, ParcelUuid charId,
+ boolean confirm, byte[] value);
+
+ /* SINCE SDK 21 */
+ void configureMTU(int clientIf, String address, int mtu);
+
+ /* SINCE SDK 21 */
+ void connectionParameterUpdate(int clientIf, String address, int connectionPriority);
+
+ void disconnectAll();
+
+ List<BluetoothDevice> getDevicesMatchingConnectionStates(int[] states);
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothGattCallback.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothGattCallback.java
new file mode 100644
index 0000000..b29369b
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothGattCallback.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2021 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.bluetooth;
+
+import android.bluetooth.le.AdvertiseSettings;
+import android.bluetooth.le.ScanResult;
+import android.os.ParcelUuid;
+
+/**
+ * Fake interface replacement for IBluetoothGattCallback
+ * TODO(b/200231384): include >=N interface.
+ */
+public interface IBluetoothGattCallback {
+
+ /* SINCE SDK 21 */
+ void onClientRegistered(int status, int clientIf);
+
+ /* SINCE SDK 21 */
+ void onClientConnectionState(int status, int clientIf, boolean connected, String address);
+
+ /* ONLY SDK 19 */
+ void onScanResult(String address, int rssi, byte[] advData);
+
+ /* SINCE SDK 21 */
+ void onScanResult(ScanResult scanResult);
+
+ /* SINCE SDK 21 */
+ void onGetService(String address, int srvcType, int srvcInstId, ParcelUuid srvcUuid);
+
+ /* SINCE SDK 21 */
+ void onGetIncludedService(String address, int srvcType, int srvcInstId,
+ ParcelUuid srvcUuid, int inclSrvcType,
+ int inclSrvcInstId, ParcelUuid inclSrvcUuid);
+
+ /* SINCE SDK 21 */
+ void onGetCharacteristic(String address, int srvcType,
+ int srvcInstId, ParcelUuid srvcUuid,
+ int charInstId, ParcelUuid charUuid,
+ int charProps);
+
+ /* SINCE SDK 21 */
+ void onGetDescriptor(String address, int srvcType,
+ int srvcInstId, ParcelUuid srvcUuid,
+ int charInstId, ParcelUuid charUuid,
+ int descrInstId, ParcelUuid descrUuid);
+
+ /* SINCE SDK 21 */
+ void onSearchComplete(String address, int status);
+
+ /* SINCE SDK 21 */
+ void onCharacteristicRead(String address, int status, int srvcType,
+ int srvcInstId, ParcelUuid srvcUuid,
+ int charInstId, ParcelUuid charUuid,
+ byte[] value);
+
+ /* SINCE SDK 21 */
+ void onCharacteristicWrite(String address, int status, int srvcType,
+ int srvcInstId, ParcelUuid srvcUuid,
+ int charInstId, ParcelUuid charUuid);
+
+ /* SINCE SDK 21 */
+ void onExecuteWrite(String address, int status);
+
+ /* SINCE SDK 21 */
+ void onDescriptorRead(String address, int status, int srvcType,
+ int srvcInstId, ParcelUuid srvcUuid,
+ int charInstId, ParcelUuid charUuid,
+ int descrInstId, ParcelUuid descrUuid,
+ byte[] value);
+
+ /* SINCE SDK 21 */
+ void onDescriptorWrite(String address, int status, int srvcType,
+ int srvcInstId, ParcelUuid srvcUuid,
+ int charInstId, ParcelUuid charUuid,
+ int descrInstId, ParcelUuid descrUuid);
+
+ /* SINCE SDK 21 */
+ void onNotify(String address, int srvcType,
+ int srvcInstId, ParcelUuid srvcUuid,
+ int charInstId, ParcelUuid charUuid,
+ byte[] value);
+
+ /* SINCE SDK 21 */
+ void onReadRemoteRssi(String address, int rssi, int status);
+
+ /* SDK 21 */
+ void onMultiAdvertiseCallback(int status, boolean isStart,
+ AdvertiseSettings advertiseSettings);
+
+ /* SDK 21 */
+ void onConfigureMTU(String address, int mtu, int status);
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothGattServerCallback.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothGattServerCallback.java
new file mode 100644
index 0000000..10b91bb
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothGattServerCallback.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2021 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.bluetooth;
+
+import android.os.ParcelUuid;
+
+/**
+ * Fake interface of internal IBluetoothGattServerCallback.
+ */
+public interface IBluetoothGattServerCallback {
+
+ /* SINCE SDK 21 */
+ void onServerRegistered(int status, int serverIf);
+
+ /* SINCE SDK 21 */
+ void onScanResult(String address, int rssi, byte[] advData);
+
+ /* SINCE SDK 21 */
+ void onServerConnectionState(int status, int serverIf, boolean connected, String address);
+
+ /* SINCE SDK 21 */
+ void onServiceAdded(int status, int srvcType, int srvcInstId, ParcelUuid srvcId);
+
+ /* SINCE SDK 21 */
+ void onCharacteristicReadRequest(String address, int transId, int offset, boolean isLong,
+ int srvcType, int srvcInstId, ParcelUuid srvcId, int charInstId, ParcelUuid charId);
+
+ /* SINCE SDK 21 */
+ void onDescriptorReadRequest(String address, int transId, int offset, boolean isLong,
+ int srvcType, int srvcInstId, ParcelUuid srvcId,
+ int charInstId, ParcelUuid charId, ParcelUuid descrId);
+
+ /* SINCE SDK 21 */
+ void onCharacteristicWriteRequest(String address, int transId, int offset, int length,
+ boolean isPrep, boolean needRsp, int srvcType, int srvcInstId, ParcelUuid srvcId,
+ int charInstId, ParcelUuid charId, byte[] value);
+
+ /* SINCE SDK 21 */
+ void onDescriptorWriteRequest(String address, int transId, int offset, int length,
+ boolean isPrep, boolean needRsp, int srvcType, int srvcInstId, ParcelUuid srvcId,
+ int charInstId, ParcelUuid charId, ParcelUuid descrId, byte[] value);
+
+ /* SINCE SDK 21 */
+ void onExecuteWrite(String address, int transId, boolean execWrite);
+
+ /* SINCE SDK 21 */
+ void onNotificationSent(String address, int status);
+
+ /* SINCE SDK 22 */
+ void onMtuChanged(String address, int mtu);
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothManager.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothManager.java
new file mode 100644
index 0000000..6bb2209
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothManager.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2021 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.
+ */
+
+/**
+ * Intentionally in package android.bluetooth to fake existing interface in Android.
+ */
+package android.bluetooth;
+
+/**
+ * Fake interface for IBluetoothManager.
+ */
+public interface IBluetoothManager {
+
+ boolean enable();
+
+ boolean disable(boolean persist);
+
+ String getAddress();
+
+ String getName();
+
+ IBluetooth registerAdapter(IBluetoothManagerCallback callback);
+
+ IBluetoothGatt getBluetoothGatt();
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothManagerCallback.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothManagerCallback.java
new file mode 100644
index 0000000..f39b82f
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/bluetooth/IBluetoothManagerCallback.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2021 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.bluetooth;
+
+/**
+ * Fake interface replacement for hidden IBluetoothManagerCallback class
+ */
+public interface IBluetoothManagerCallback {
+
+}
+
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/nfc/BeamShareData.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/nfc/BeamShareData.java
new file mode 100644
index 0000000..5357a9b
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/nfc/BeamShareData.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2021 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.nfc;
+
+import android.net.Uri;
+import android.os.UserHandle;
+
+/**
+ * Fake BeamShareData.
+ */
+public class BeamShareData {
+
+ public NdefMessage ndefMessage;
+ public Uri[] uris;
+ public UserHandle userHandle;
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/nfc/IAppCallback.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/nfc/IAppCallback.java
new file mode 100644
index 0000000..7b62f19
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/nfc/IAppCallback.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2021 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.nfc;
+
+/**
+ * Fake interface for nfc service.
+ */
+public interface IAppCallback {
+
+ /* M */ void onNdefPushComplete(byte peerLlcpVersion);
+
+ /* M */ BeamShareData createBeamShareData(byte peerLlcpVersion);
+
+ /* L */ void onNdefPushComplete();
+
+ /* L */ BeamShareData createBeamShareData();
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/nfc/INfcAdapter.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/nfc/INfcAdapter.java
new file mode 100644
index 0000000..08acdbc
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/fakes/android/nfc/INfcAdapter.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2021 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.nfc;
+
+/**
+ * Fake interface of INfcAdapter
+ */
+public interface INfcAdapter {
+
+ void setAppCallback(IAppCallback callback);
+
+ boolean enable();
+
+ boolean disable(boolean saveState);
+
+ int getState();
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BleAdvertiser.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BleAdvertiser.java
new file mode 100644
index 0000000..f3328c8
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BleAdvertiser.java
@@ -0,0 +1,171 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.helpers.bluetooth;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.le.AdvertiseCallback;
+import android.bluetooth.le.AdvertiseData;
+import android.bluetooth.le.AdvertiseSettings;
+import android.bluetooth.le.BluetoothLeAdvertiser;
+import android.util.Log;
+
+import com.android.libraries.testing.deviceshadower.DeviceShadowEnvironment;
+
+import com.google.common.base.Preconditions;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Future;
+
+/**
+ * Helper class to operate a device as BLE advertiser.
+ */
+public class BleAdvertiser {
+
+ private static final String TAG = "BleAdvertiser";
+
+ private static final int DEFAULT_MODE = AdvertiseSettings.ADVERTISE_MODE_BALANCED;
+ private static final int DEFAULT_TX_POWER_LEVEL = AdvertiseSettings.ADVERTISE_TX_POWER_HIGH;
+ private static final boolean DEFAULT_CONNECTABLE = true;
+ private static final int DEFAULT_TIMEOUT = 0;
+
+
+ /**
+ * Callback of {@link BleAdvertiser}.
+ */
+ public interface Callback {
+
+ void onStartFailure(String address, int errorCode);
+
+ void onStartSuccess(String address, AdvertiseSettings settingsInEffect);
+ }
+
+ /**
+ * Builder class of {@link BleAdvertiser}.
+ */
+ public static final class Builder {
+
+ private final String mAddress;
+ private final Callback mCallback;
+ private AdvertiseSettings mSettings = defaultSettings();
+ private AdvertiseData mData;
+ private AdvertiseData mResponse;
+
+ public Builder(String address, Callback callback) {
+ this.mAddress = Preconditions.checkNotNull(address);
+ this.mCallback = Preconditions.checkNotNull(callback);
+ }
+
+ public Builder setAdvertiseSettings(AdvertiseSettings settings) {
+ this.mSettings = settings;
+ return this;
+ }
+
+ public Builder setAdvertiseData(AdvertiseData data) {
+ this.mData = data;
+ return this;
+ }
+
+ public Builder setResponseData(AdvertiseData response) {
+ this.mResponse = response;
+ return this;
+ }
+
+ public BleAdvertiser build() {
+ return new BleAdvertiser(mAddress, mCallback, mSettings, mData, mResponse);
+ }
+ }
+
+ private static AdvertiseSettings defaultSettings() {
+ return new AdvertiseSettings.Builder()
+ .setAdvertiseMode(DEFAULT_MODE)
+ .setConnectable(DEFAULT_CONNECTABLE)
+ .setTimeout(DEFAULT_TIMEOUT)
+ .setTxPowerLevel(DEFAULT_TX_POWER_LEVEL).build();
+ }
+
+ private final String mAddress;
+ private final Callback mCallback;
+ private final AdvertiseSettings mSettings;
+ private final AdvertiseData mData;
+ private final AdvertiseData mResponse;
+ private final CountDownLatch mStartAdvertiseLatch;
+ private BluetoothLeAdvertiser mAdvertiser;
+
+ private BleAdvertiser(String address, Callback callback, AdvertiseSettings settings,
+ AdvertiseData data, AdvertiseData response) {
+ this.mAddress = address;
+ this.mCallback = callback;
+ this.mSettings = settings;
+ this.mData = data;
+ this.mResponse = response;
+ mStartAdvertiseLatch = new CountDownLatch(1);
+ DeviceShadowEnvironment.addDevice(address).bluetooth()
+ .setAdapterInitialState(BluetoothAdapter.STATE_ON);
+ }
+
+ /**
+ * Starts advertising.
+ */
+ public Future<Void> start() {
+ return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+ @Override
+ public void run() {
+ mAdvertiser = BluetoothAdapter.getDefaultAdapter().getBluetoothLeAdvertiser();
+ mAdvertiser.startAdvertising(mSettings, mData, mResponse, mAdvertiseCallback);
+ }
+ });
+ }
+
+ /**
+ * Stops advertising.
+ */
+ public Future<Void> stop() {
+ return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+ @Override
+ public void run() {
+ mAdvertiser.stopAdvertising(mAdvertiseCallback);
+ }
+ });
+ }
+
+ public void waitTillAdvertiseCompleted() {
+ try {
+ mStartAdvertiseLatch.await();
+ } catch (InterruptedException e) {
+ Log.w(TAG, mAddress + " fails to wait till advertise completed: ", e);
+ }
+ }
+
+ private final AdvertiseCallback mAdvertiseCallback = new AdvertiseCallback() {
+ @Override
+ public void onStartSuccess(AdvertiseSettings settingsInEffect) {
+ Log.v(TAG,
+ String.format("onStartSuccess(settingsInEffect: %s) on %s ", settingsInEffect,
+ mAddress));
+ mCallback.onStartSuccess(mAddress, settingsInEffect);
+ mStartAdvertiseLatch.countDown();
+ }
+
+ @Override
+ public void onStartFailure(int errorCode) {
+ Log.v(TAG, String.format("onStartFailure(errorCode: %d) on %s", errorCode, mAddress));
+ mCallback.onStartFailure(mAddress, errorCode);
+ mStartAdvertiseLatch.countDown();
+ }
+ };
+}
+
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BleScanner.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BleScanner.java
new file mode 100644
index 0000000..6a44c2b
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BleScanner.java
@@ -0,0 +1,235 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.helpers.bluetooth;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.le.BluetoothLeScanner;
+import android.bluetooth.le.ScanCallback;
+import android.bluetooth.le.ScanFilter;
+import android.bluetooth.le.ScanResult;
+import android.bluetooth.le.ScanSettings;
+import android.util.Log;
+
+import com.android.libraries.testing.deviceshadower.DeviceShadowEnvironment;
+
+import com.google.common.base.Preconditions;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.Future;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Helper class to operate a device as BLE scanner.
+ */
+public class BleScanner {
+
+ private static final String TAG = "BleScanner";
+
+ private static final int DEFAULT_MODE = ScanSettings.SCAN_MODE_LOW_LATENCY;
+ private static final int DEFAULT_CALLBACK_TYPE = ScanSettings.CALLBACK_TYPE_ALL_MATCHES;
+ private static final long DEFAULT_DELAY = 0L;
+
+ /**
+ * Callback of {@link BleScanner}.
+ */
+ public interface Callback {
+
+ void onScanResult(String address, int callbackType, ScanResult result);
+
+ void onBatchScanResults(String address, List<ScanResult> results);
+
+ void onScanFailed(String address, int errorCode);
+ }
+
+ /**
+ * Builder class of {@link BleScanner}.
+ */
+ public static final class Builder {
+
+ private final String mAddress;
+ private final Callback mCallback;
+ private ScanSettings mSettings = defaultSettings();
+ private List<ScanFilter> mFilters;
+ private int mNumOfExpectedScanCallbacks = 1;
+
+ public Builder(String address, Callback callback) {
+ this.mAddress = Preconditions.checkNotNull(address);
+ this.mCallback = Preconditions.checkNotNull(callback);
+ }
+
+ public Builder setScanSettings(ScanSettings settings) {
+ this.mSettings = settings;
+ return this;
+ }
+
+ public Builder addScanFilter(ScanFilter... filterArgs) {
+ if (this.mFilters == null) {
+ this.mFilters = new ArrayList<>();
+ }
+ for (ScanFilter filter : filterArgs) {
+ this.mFilters.add(filter);
+ }
+ return this;
+ }
+
+ /**
+ * Sets number of expected scan result callback.
+ *
+ * @param num Number of expected scan result callback, default to 1.
+ */
+ public Builder setNumOfExpectedScanCallbacks(int num) {
+ mNumOfExpectedScanCallbacks = num;
+ return this;
+ }
+
+ public BleScanner build() {
+ return new BleScanner(
+ mAddress, mCallback, mSettings, mFilters, mNumOfExpectedScanCallbacks);
+ }
+ }
+
+ private static ScanSettings defaultSettings() {
+ return new ScanSettings.Builder()
+ .setScanMode(DEFAULT_MODE)
+ .setCallbackType(DEFAULT_CALLBACK_TYPE)
+ .setReportDelay(DEFAULT_DELAY).build();
+ }
+
+ private final String mAddress;
+ private final Callback mCallback;
+ private final ScanSettings mSettings;
+ private final List<ScanFilter> mFilters;
+ private final BlockingQueue<Integer> mScanResultCounts;
+ private int mNumOfExpectedScanCallbacks;
+ private int mNumOfReceivedScanCallbacks;
+ private BluetoothLeScanner mScanner;
+
+ private BleScanner(String address, Callback callback, ScanSettings settings,
+ List<ScanFilter> filters, int numOfExpectedScanResult) {
+ this.mAddress = address;
+ this.mCallback = callback;
+ this.mSettings = settings;
+ this.mFilters = filters;
+ this.mNumOfExpectedScanCallbacks = numOfExpectedScanResult;
+ this.mNumOfReceivedScanCallbacks = 0;
+ this.mScanResultCounts = new LinkedBlockingQueue<>(numOfExpectedScanResult);
+ DeviceShadowEnvironment.addDevice(address).bluetooth()
+ .setAdapterInitialState(BluetoothAdapter.STATE_ON);
+ }
+
+ public Future<Void> start() {
+ return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+ @Override
+ public void run() {
+ mScanner = BluetoothAdapter.getDefaultAdapter().getBluetoothLeScanner();
+ mScanner.startScan(mFilters, mSettings, mScanCallback);
+ }
+ });
+ }
+
+ public void waitTillNextScanResult(long timeoutMillis) {
+ Integer result = null;
+ if (mNumOfReceivedScanCallbacks >= mNumOfExpectedScanCallbacks) {
+ return;
+ }
+ try {
+ if (timeoutMillis < 0) {
+ result = mScanResultCounts.take();
+ } else {
+ result = mScanResultCounts.poll(timeoutMillis, TimeUnit.MILLISECONDS);
+ }
+ if (result != null && result >= 0) {
+ mNumOfReceivedScanCallbacks++;
+ }
+ Log.v(TAG, "Scan results: " + result);
+ } catch (InterruptedException e) {
+ Log.w(TAG, mAddress + " fails to wait till next scan result: ", e);
+ }
+ }
+
+ public void waitTillNextScanResult() {
+ waitTillNextScanResult(-1);
+ }
+
+ public void waitTillAllScanResults() {
+ while (mNumOfReceivedScanCallbacks < mNumOfExpectedScanCallbacks) {
+ try {
+ if (mScanResultCounts.take() >= 0) {
+ mNumOfReceivedScanCallbacks++;
+ }
+ } catch (InterruptedException e) {
+ Log.w(TAG, String.format("%s fails to wait scan result", mAddress), e);
+ return;
+ }
+ }
+ }
+
+ public Future<Void> stop() {
+ return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+ @Override
+ public void run() {
+ mScanner = BluetoothAdapter.getDefaultAdapter().getBluetoothLeScanner();
+ mScanner.stopScan(mScanCallback);
+ }
+ });
+ }
+
+ private final ScanCallback mScanCallback = new ScanCallback() {
+ @Override
+ public void onScanResult(int callbackType, ScanResult result) {
+ Log.v(TAG, String.format("onScanResult(callbackType: %d, result: %s) on %s",
+ callbackType, result, mAddress));
+ mCallback.onScanResult(mAddress, callbackType, result);
+ try {
+ mScanResultCounts.put(1);
+ } catch (InterruptedException e) {
+ // no-op.
+ }
+ }
+
+ @Override
+ public void onBatchScanResults(List<ScanResult> results) {
+ /**** Not supported yet.
+ Log.v(TAG, String.format("onBatchScanResults(results: %s) on %s",
+ Arrays.toString(results.toArray()), address));
+ callback.onBatchScanResults(address, results);
+ try {
+ scanResultCounts.put(results.size());
+ } catch (InterruptedException e) {
+ // no-op.
+ }
+ */
+ }
+
+ @Override
+ public void onScanFailed(int errorCode) {
+ /**** Not supported yet.
+ Log.v(TAG, String.format("onScanFailed(errorCode: %d) on %s", errorCode, address));
+ callback.onScanFailed(address, errorCode);
+ try {
+ scanResultCounts.put(-1);
+ } catch (InterruptedException e) {
+ // no-op.
+ }
+ */
+ }
+ };
+}
+
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BluetoothGattClient.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BluetoothGattClient.java
new file mode 100644
index 0000000..69e77af
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BluetoothGattClient.java
@@ -0,0 +1,324 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.helpers.bluetooth;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCallback;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothGattService;
+import android.content.Context;
+import android.util.Log;
+
+import com.android.libraries.testing.deviceshadower.DeviceShadowEnvironment;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Helper class to operate a device as gatt client.
+ */
+public class BluetoothGattClient {
+
+ private static final String TAG = "BluetoothGattClient";
+ private static final int LATCH_TIMEOUT_MILLIS = 1000;
+
+ /**
+ * Callback of BluetoothGattClient.
+ */
+ public interface Callback {
+
+ void onConnectionStateChange(String address, int status, int newState);
+
+ void onCharacteristicChanged(String address, UUID uuid, byte[] value);
+
+ void onCharacteristicRead(String address, UUID uuid, byte[] value, int status);
+
+ void onCharacteristicWrite(String address, UUID uuid, byte[] value, int status);
+
+ void onDescriptorRead(String address, UUID uuid, byte[] value, int status);
+
+ void onDescriptorWrite(String address, UUID uuid, byte[] value, int status);
+
+ void onServicesDiscovered(
+ UUID[] serviceUuid, UUID[] characteristicUuid, UUID[] descriptorUuid, int status);
+
+ void onConfigureMTU(String address, int mtu, int status);
+ }
+
+ private final String mAddress;
+ private final Callback mCallback;
+ private final Context mContext;
+ private final Map<UUID, BluetoothGattCharacteristic> mCharacteristics = new HashMap<>();
+ private final Map<UUID, BluetoothGattDescriptor> mDescriptors = new HashMap<>();
+ private BluetoothGatt mGatt;
+ private CountDownLatch mConnectionLatch;
+ private CountDownLatch mServiceDiscoverLatch;
+
+ public BluetoothGattClient(String address, Callback callback, Context context) {
+ this.mAddress = address;
+ this.mCallback = callback;
+ this.mContext = context;
+ DeviceShadowEnvironment.addDevice(address).bluetooth()
+ .setAdapterInitialState(BluetoothAdapter.STATE_ON);
+ }
+
+ public Future<Void> connect(final String remoteAddress) {
+ return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+ @Override
+ public void run() {
+ mConnectionLatch = new CountDownLatch(1);
+ mGatt = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(remoteAddress)
+ .connectGatt(mContext, false /* auto connect */, mGattCallback);
+ try {
+ mConnectionLatch.await(LATCH_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ // no-op.
+ }
+
+ mServiceDiscoverLatch = new CountDownLatch(1);
+ mGatt.discoverServices();
+ try {
+ mServiceDiscoverLatch.await(LATCH_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ // no-op.
+ }
+ }
+ });
+ }
+
+ public Future<Void> close() {
+ return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+ @Override
+ public void run() {
+ mGatt.disconnect();
+ mGatt.close();
+ }
+ });
+ }
+
+ public Future<Void> readCharacteristic(final UUID uuid) {
+ return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+ @Override
+ public void run() {
+ mGatt.readCharacteristic(mCharacteristics.get(uuid));
+ }
+ });
+ }
+
+ public Future<Void> setNotification(final UUID uuid) {
+ return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+ @Override
+ public void run() {
+ mGatt.setCharacteristicNotification(mCharacteristics.get(uuid), true);
+ }
+ });
+ }
+
+ public Future<Void> writeCharacteristic(final UUID uuid, final byte[] value) {
+ return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+ @Override
+ public void run() {
+ BluetoothGattCharacteristic characteristic = mCharacteristics.get(uuid);
+ characteristic.setValue(value);
+ mGatt.writeCharacteristic(characteristic);
+ }
+ });
+ }
+
+ /**
+ * Reads the value of a descriptor with given UUID.
+ *
+ * <p>If different characteristics on the service have the same descriptor, use {@link
+ * BluetoothGattClient#readDescriptor(UUID, UUID)} instead.
+ */
+ public Future<Void> readDescriptor(final UUID uuid) {
+ return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+ @Override
+ public void run() {
+ mGatt.readDescriptor(mDescriptors.get(uuid));
+ }
+ });
+ }
+
+ /**
+ * Reads the descriptor value of the specified characteristic.
+ */
+ public Future<Void> readDescriptor(final UUID descriptorUuid, final UUID characteristicUuid) {
+ return DeviceShadowEnvironment.run(
+ mAddress,
+ new Runnable() {
+ @Override
+ public void run() {
+ mGatt.readDescriptor(
+ mCharacteristics.get(characteristicUuid)
+ .getDescriptor(descriptorUuid));
+ }
+ });
+ }
+
+ /**
+ * Writes to the descriptor with given UUID.
+ *
+ * <p>If different characteristics on the service have the same descriptor, use {@link
+ * BluetoothGattClient#writeDescriptor(UUID, UUID, byte[])} instead.
+ */
+ public Future<Void> writeDescriptor(final UUID uuid, final byte[] value) {
+ return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+ @Override
+ public void run() {
+ BluetoothGattDescriptor descriptor = mDescriptors.get(uuid);
+ descriptor.setValue(value);
+ mGatt.writeDescriptor(descriptor);
+ }
+ });
+ }
+
+ /**
+ * Writes to the descriptor of the specified characteristic.
+ */
+ public Future<Void> writeDescriptor(
+ final UUID descriptorUuid, final UUID characteristicUuid, final byte[] value) {
+ return DeviceShadowEnvironment.run(
+ mAddress,
+ new Runnable() {
+ @Override
+ public void run() {
+ BluetoothGattDescriptor descriptor =
+ mCharacteristics.get(characteristicUuid)
+ .getDescriptor(descriptorUuid);
+ descriptor.setValue(value);
+ mGatt.writeDescriptor(descriptor);
+ }
+ });
+ }
+
+ public Future<Void> requestMtu(int mtu) {
+ return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+ @Override
+ public void run() {
+ mGatt.requestMtu(mtu);
+ }
+ });
+ }
+
+ private final BluetoothGattCallback mGattCallback = new BluetoothGattCallback() {
+ @Override
+ public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
+ Log.v(TAG, String.format("onConnectionStateChange(status: %s, newState: %s)",
+ status, newState));
+ if (mConnectionLatch != null) {
+ mConnectionLatch.countDown();
+ }
+ mCallback.onConnectionStateChange(gatt.getDevice().getAddress(), status, newState);
+ }
+
+ @Override
+ public void onCharacteristicChanged(
+ BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
+ Log.v(TAG, String.format("onCharacteristicChanged(characteristic: %s, value: %s)",
+ characteristic.getUuid(), Arrays.toString(characteristic.getValue())));
+ mCallback.onCharacteristicChanged(
+ gatt.getDevice().getAddress(), characteristic.getUuid(),
+ characteristic.getValue());
+ }
+
+ @Override
+ public void onCharacteristicRead(
+ BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
+ Log.v(TAG, String.format("onCharacteristicRead(descriptor: %s, status: %s)",
+ characteristic.getUuid(), status));
+ mCallback.onCharacteristicRead(
+ gatt.getDevice().getAddress(), characteristic.getUuid(),
+ characteristic.getValue(),
+ status);
+ }
+
+ @Override
+ public void onCharacteristicWrite(
+ BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
+ Log.v(TAG, String.format("onCharacteristicWrite(descriptor: %s, status: %s)",
+ characteristic.getUuid(), status));
+ mCallback.onCharacteristicWrite(gatt.getDevice().getAddress(),
+ characteristic.getUuid(), characteristic.getValue(), status);
+ }
+
+ @Override
+ public void onDescriptorRead(
+ BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
+ Log.v(TAG, String.format("onDescriptorRead(descriptor: %s, status: %s)",
+ descriptor.getUuid(), status));
+ mCallback.onDescriptorRead(
+ gatt.getDevice().getAddress(), descriptor.getUuid(), descriptor.getValue(),
+ status);
+ }
+
+ @Override
+ public void onDescriptorWrite(
+ BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
+ Log.v(TAG, String.format("onDescriptorWrite(descriptor: %s, status: %s)",
+ descriptor.getUuid(), status));
+ mCallback.onDescriptorWrite(
+ gatt.getDevice().getAddress(), descriptor.getUuid(), descriptor.getValue(),
+ status);
+ }
+
+ @Override
+ public synchronized void onServicesDiscovered(BluetoothGatt gatt, int status) {
+ Log.v(TAG, "Discovered service: " + gatt.getServices());
+ List<UUID> serviceUuid = new ArrayList<>();
+ List<UUID> characteristicUuid = new ArrayList<>();
+ List<UUID> descriptorUuid = new ArrayList<>();
+ for (BluetoothGattService service : gatt.getServices()) {
+ serviceUuid.add(service.getUuid());
+ for (BluetoothGattCharacteristic characteristic : service.getCharacteristics()) {
+ mCharacteristics.put(characteristic.getUuid(), characteristic);
+ characteristicUuid.add(characteristic.getUuid());
+ for (BluetoothGattDescriptor descriptor : characteristic.getDescriptors()) {
+ mDescriptors.put(descriptor.getUuid(), descriptor);
+ descriptorUuid.add(descriptor.getUuid());
+ }
+ }
+ }
+
+ Collections.sort(serviceUuid);
+ Collections.sort(characteristicUuid);
+ Collections.sort(descriptorUuid);
+
+ mCallback.onServicesDiscovered(serviceUuid.toArray(new UUID[serviceUuid.size()]),
+ characteristicUuid.toArray(new UUID[characteristicUuid.size()]),
+ descriptorUuid.toArray(new UUID[descriptorUuid.size()]),
+ status);
+ mServiceDiscoverLatch.countDown();
+ }
+
+ @Override
+ public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) {
+ Log.v(TAG, String.format("onMtuChanged(mtu: %s, status: %s)", mtu, status));
+ mCallback.onConfigureMTU(gatt.getDevice().getAddress(), mtu, status);
+ }
+ };
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BluetoothGattMaster.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BluetoothGattMaster.java
new file mode 100644
index 0000000..e9f364a
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BluetoothGattMaster.java
@@ -0,0 +1,236 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.helpers.bluetooth;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothGattServer;
+import android.bluetooth.BluetoothGattServerCallback;
+import android.bluetooth.BluetoothGattService;
+import android.bluetooth.BluetoothManager;
+import android.content.Context;
+import android.util.Log;
+
+import com.android.libraries.testing.deviceshadower.DeviceShadowEnvironment;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.Future;
+
+/**
+ * Helper class to operate a device as gatt server.
+ */
+public class BluetoothGattMaster {
+
+ private static final String TAG = "BluetoothGattMaster";
+
+ /**
+ * Callback of BluetoothGattMaster.
+ */
+ public interface Callback {
+
+ void onConnectionStateChange(String address, int status, int newState);
+
+ void onCharacteristicReadRequest(String address, UUID uuid);
+
+ void onCharacteristicWriteRequest(String address, UUID uuid, byte[] value,
+ boolean preparedWrite, boolean responseNeeded);
+
+ void onDescriptorReadRequest(String address, UUID uuid);
+
+ void onDescriptorWriteRequest(String address, UUID uuid, byte[] value,
+ boolean preparedWrite, boolean responseNeeded);
+
+ void onNotificationSent(String address, int status);
+
+ void onExecuteWrite(String address, boolean execute);
+
+ void onServiceAdded(UUID uuid, int status);
+
+ void onMtuChanged(String address, int mtu);
+ }
+
+ private final String mAddress;
+ private final Callback mCallback;
+ private final Context mContext;
+ private BluetoothGattServer mGattServer;
+ private final Map<UUID, BluetoothGattCharacteristic> mCharacteristics = new HashMap<>();
+
+ public BluetoothGattMaster(String address, Callback callback, Context context) {
+ this.mAddress = address;
+ this.mCallback = callback;
+ this.mContext = context;
+ DeviceShadowEnvironment.addDevice(address).bluetooth()
+ .setAdapterInitialState(BluetoothAdapter.STATE_ON);
+ }
+
+ public Future<Void> start(final BluetoothGattService service) {
+ return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+ @Override
+ public void run() {
+ BluetoothManager manager = mContext.getSystemService(BluetoothManager.class);
+ mGattServer = manager.openGattServer(mContext, mGattServerCallback);
+ mGattServer.addService(service);
+ }
+ });
+ }
+
+ public Future<Void> stop() {
+ return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+ @Override
+ public void run() {
+ mGattServer.close();
+ }
+ });
+ }
+
+ public Future<Void> notifyCharacteristic(
+ final String remoteAddress, final UUID uuid, final byte[] value,
+ final boolean confirm) {
+ return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+ @Override
+ public void run() {
+ BluetoothGattCharacteristic characteristic = mCharacteristics.get(uuid);
+ characteristic.setValue(value);
+ mGattServer.notifyCharacteristicChanged(
+ BluetoothAdapter.getDefaultAdapter().getRemoteDevice(remoteAddress),
+ characteristic, confirm);
+ }
+ });
+ }
+
+ private BluetoothGattServerCallback mGattServerCallback = new BluetoothGattServerCallback() {
+ @Override
+ public void onConnectionStateChange(BluetoothDevice device, int status, int newState) {
+ String address = device.getAddress();
+ Log.v(TAG, String.format(
+ "BluetoothGattServerManager.onConnectionStateChange on %s: status %d,"
+ + " newState %d", address, status, newState));
+ mCallback.onConnectionStateChange(address, status, newState);
+ }
+
+ @Override
+ public void onCharacteristicReadRequest(BluetoothDevice device, int requestId, int offset,
+ BluetoothGattCharacteristic characteristic) {
+ String address = device.getAddress();
+ UUID uuid = characteristic.getUuid();
+ Log.v(TAG,
+ String.format("BluetoothGattServerManager.onCharacteristicReadRequest on %s: "
+ + "characteristic %s, request %d, offset %d",
+ address, uuid, requestId, offset));
+ mCallback.onCharacteristicReadRequest(address, uuid);
+ mGattServer.sendResponse(
+ device, requestId, BluetoothGatt.GATT_SUCCESS, offset,
+ characteristic.getValue());
+ }
+
+ @Override
+ public void onCharacteristicWriteRequest(BluetoothDevice device, int requestId,
+ BluetoothGattCharacteristic characteristic, boolean preparedWrite,
+ boolean responseNeeded,
+ int offset, byte[] value) {
+ String address = device.getAddress();
+ UUID uuid = characteristic.getUuid();
+ Log.v(TAG,
+ String.format("BluetoothGattServerManager.onCharacteristicWriteRequest on %s: "
+ + "characteristic %s, request %d, offset %d, preparedWrite %b, "
+ + "responseNeeded %b",
+ address, uuid, requestId, offset, preparedWrite, responseNeeded));
+ mCallback.onCharacteristicWriteRequest(address, uuid, value, preparedWrite,
+ responseNeeded);
+
+ if (responseNeeded) {
+ mGattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset,
+ null);
+ }
+ }
+
+ @Override
+ public void onDescriptorReadRequest(BluetoothDevice device, int requestId, int offset,
+ BluetoothGattDescriptor descriptor) {
+ String address = device.getAddress();
+ UUID uuid = descriptor.getUuid();
+ Log.v(TAG, String.format("BluetoothGattServerManager.onDescriptorReadRequest on %s: "
+ + " descriptor %s, requestId %d, offset %d",
+ address, uuid, requestId, offset));
+ mCallback.onDescriptorReadRequest(address, uuid);
+ mGattServer.sendResponse(
+ device, requestId, BluetoothGatt.GATT_SUCCESS, offset, descriptor.getValue());
+ }
+
+ @Override
+ public void onDescriptorWriteRequest(BluetoothDevice device, int requestId,
+ BluetoothGattDescriptor descriptor, boolean preparedWrite, boolean responseNeeded,
+ int offset, byte[] value) {
+ String address = device.getAddress();
+ UUID uuid = descriptor.getUuid();
+ Log.v(TAG, String.format("BluetoothGattServerManager.onDescriptorWriteRequest on %s: "
+ + "descriptor %s, requestId %d, offset %d, preparedWrite %b, "
+ + "responseNeeded %b",
+ address, uuid, requestId, offset, preparedWrite, responseNeeded));
+ mCallback.onDescriptorWriteRequest(address, uuid, value, preparedWrite, responseNeeded);
+
+ if (responseNeeded) {
+ mGattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset,
+ null);
+ }
+ }
+
+ @Override
+ public void onNotificationSent(BluetoothDevice device, int status) {
+ String address = device.getAddress();
+ Log.v(TAG,
+ String.format("BluetoothGattServerManager.onNotificationSent on %s: status %d",
+ address, status));
+ mCallback.onNotificationSent(address, status);
+ }
+
+ @Override
+ public void onExecuteWrite(BluetoothDevice device, int requestId, boolean execute) {
+ /*** Not implemented yet
+ String address = device.getAddress();
+ Log.v(TAG, String.format(
+ "BluetoothGattServerManager.onExecuteWrite on %s: requestId %d, execute %b",
+ address, requestId, execute));
+ callback.onExecuteWrite(address, execute);
+ */
+ }
+
+ @Override
+ public void onServiceAdded(int status, BluetoothGattService service) {
+ UUID uuid = service.getUuid();
+ Log.v(TAG, String.format(
+ "BluetoothGattServerManager.onServiceAdded: service %s, status %d",
+ uuid, status));
+ mCallback.onServiceAdded(uuid, status);
+
+ for (BluetoothGattCharacteristic characteristic : service.getCharacteristics()) {
+ mCharacteristics.put(characteristic.getUuid(), characteristic);
+ }
+ }
+
+ @Override
+ public void onMtuChanged(BluetoothDevice device, int mtu) {
+ Log.v(TAG, String.format("onMtuChanged(mtu: %s)", mtu));
+ mCallback.onMtuChanged(device.getAddress(), mtu);
+ }
+ };
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BluetoothRfcommAcceptor.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BluetoothRfcommAcceptor.java
new file mode 100644
index 0000000..5204c2a
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BluetoothRfcommAcceptor.java
@@ -0,0 +1,224 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.helpers.bluetooth;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothServerSocket;
+import android.bluetooth.BluetoothSocket;
+import android.util.Log;
+
+import com.android.libraries.testing.deviceshadower.DeviceShadowEnvironment;
+import com.android.libraries.testing.deviceshadower.DeviceShadowEnvironmentInternal;
+import com.android.libraries.testing.deviceshadower.helpers.utils.IOUtils;
+
+import java.io.IOException;
+import java.util.Queue;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Helper class to operate a device with basic functionality to accept BluetoothRfcommConnection.
+ *
+ * <p>
+ * Usage: // Create a virtual device to accept incoming connection. BluetoothRfcommAcceptor acceptor
+ * = new BluetoothRfcommAcceptor(address, uuid, callback); // Start accepting incoming connection,
+ * with given uuid. acceptor.start(); // Connector needs to wait till acceptor started to make sure
+ * there is a server socket created. acceptor.waitTillServerSocketStarted();
+ *
+ * // Connector can initiate connection.
+ *
+ * // A blocking call to wait for connection. acceptor.waitTillConnected();
+ *
+ * // Acceptor sends a message acceptor.send("Hello".getBytes());
+ *
+ * // Cancel acceptor to release all blocking calls. acceptor.cancel();
+ */
+public class BluetoothRfcommAcceptor {
+
+ private static final String TAG = "BluetoothRfcommAcceptor";
+
+ /**
+ * Identifiers to control Bluetooth operation.
+ */
+ public static final int PRE_START = 4;
+ public static final int PRE_ACCEPT = 1;
+ public static final int PRE_WRITE = 3;
+ public static final int PRE_READ = 2;
+
+ private final String mAddress;
+ private final UUID mUuid;
+ private BluetoothSocket mSocket;
+ private BluetoothServerSocket mServerSocket;
+
+ private final AtomicBoolean mCancelled;
+ private final Callback mCallback;
+ private final CountDownLatch mStartLatch = new CountDownLatch(1);
+ private final CountDownLatch mConnectLatch = new CountDownLatch(1);
+ private final Queue<CountDownLatch> mReadLatches = new ConcurrentLinkedQueue<>();
+
+ /**
+ * Callback of BluetoothRfcommAcceptor.
+ */
+ public interface Callback {
+
+ void onSocketAccepted(BluetoothSocket socket);
+
+ void onDataReceived(byte[] data);
+
+ void onDataWritten(byte[] data);
+
+ void onError(Exception exception);
+ }
+
+ public BluetoothRfcommAcceptor(String address, UUID uuid, Callback callback) {
+ this.mAddress = address;
+ this.mUuid = uuid;
+ this.mCallback = callback;
+ this.mCancelled = new AtomicBoolean(false);
+ DeviceShadowEnvironment.addDevice(address).bluetooth()
+ .setAdapterInitialState(BluetoothAdapter.STATE_ON);
+ }
+
+ /**
+ * Start bluetooth server socket, accept incoming connection, and receive incoming data once
+ * connected.
+ */
+ public Future<Void> start() {
+ return DeviceShadowEnvironment.run(mAddress, mCode);
+ }
+
+ /**
+ * Blocking call to wait bluetooth server socket started.
+ */
+ public void waitTillServerSocketStarted() {
+ try {
+ mStartLatch.await();
+ } catch (InterruptedException e) {
+ Log.w(TAG, mAddress + " fail to wait till started: ", e);
+ }
+ }
+
+ public void waitTillConnected() {
+ try {
+ mConnectLatch.await();
+ } catch (InterruptedException e) {
+ Log.w(TAG, mAddress + " fail to wait till started: ", e);
+ }
+ }
+
+ public void waitTillDataReceived() {
+ try {
+ if (mReadLatches.size() > 0) {
+ mReadLatches.poll().await();
+ }
+ } catch (InterruptedException e) {
+ // no-op
+ }
+ }
+
+ /**
+ * Stop receiving data by closing socket.
+ */
+ public Future<Void> cancel() {
+ return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+ @Override
+ public void run() {
+ mCancelled.set(true);
+ try {
+ mSocket.close();
+ } catch (IOException e) {
+ Log.w(TAG, mAddress + " fail to close server socket", e);
+ }
+ }
+ });
+ }
+
+ /**
+ * Send data to connected device.
+ */
+ public Future<Void> send(final byte[] data) {
+ return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+ @Override
+ public void run() {
+ if (mSocket != null) {
+ try {
+ DeviceShadowEnvironmentInternal.setInterruptibleBluetooth(PRE_WRITE);
+ IOUtils.write(mSocket.getOutputStream(), data);
+ Log.d(TAG, mAddress + " write: " + new String(data));
+ mCallback.onDataWritten(data);
+ } catch (IOException e) {
+ Log.w(TAG, mAddress + " fail to write: ", e);
+ mCallback.onError(new IOException("Fail to write", e));
+ }
+ }
+ }
+ });
+ }
+
+ private Runnable mCode = new Runnable() {
+ @Override
+ public void run() {
+ try {
+ DeviceShadowEnvironmentInternal.setInterruptibleBluetooth(PRE_START);
+ mServerSocket = BluetoothAdapter.getDefaultAdapter()
+ .listenUsingInsecureRfcommWithServiceRecord("AA", mUuid);
+ } catch (IOException e) {
+ Log.w(TAG, mAddress + " fail to start server socket: ", e);
+ mCallback.onError(new IOException("Fail to start server socket", e));
+ return;
+ } finally {
+ mStartLatch.countDown();
+ }
+
+ try {
+ DeviceShadowEnvironmentInternal.setInterruptibleBluetooth(PRE_ACCEPT);
+ mSocket = mServerSocket.accept();
+ Log.d(TAG, mAddress + " accept: " + mSocket.getRemoteDevice().getAddress());
+ mCallback.onSocketAccepted(mSocket);
+ mServerSocket.close();
+ } catch (IOException e) {
+ Log.w(TAG, mAddress + " fail to connect: ", e);
+ mCallback.onError(new IOException("Fail to connect", e));
+ return;
+ } finally {
+ mConnectLatch.countDown();
+ }
+
+ do {
+ try {
+ CountDownLatch latch = new CountDownLatch(1);
+ mReadLatches.add(latch);
+ DeviceShadowEnvironmentInternal.setInterruptibleBluetooth(PRE_READ);
+ byte[] data = IOUtils.read(mSocket.getInputStream());
+ Log.d(TAG, mAddress + " read: " + new String(data));
+ mCallback.onDataReceived(data);
+ latch.countDown();
+ } catch (IOException e) {
+ Log.w(TAG, mAddress + " fail to read: ", e);
+ mCallback.onError(new IOException("Fail to read", e));
+ return;
+ }
+ } while (!mCancelled.get());
+
+ Log.d(TAG, mAddress + " stop receiving");
+ }
+ };
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BluetoothRfcommConnector.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BluetoothRfcommConnector.java
new file mode 100644
index 0000000..e386d59
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/bluetooth/BluetoothRfcommConnector.java
@@ -0,0 +1,194 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.helpers.bluetooth;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothSocket;
+import android.util.Log;
+
+import com.android.libraries.testing.deviceshadower.DeviceShadowEnvironment;
+import com.android.libraries.testing.deviceshadower.DeviceShadowEnvironmentInternal;
+import com.android.libraries.testing.deviceshadower.helpers.utils.IOUtils;
+
+import java.io.IOException;
+import java.util.Queue;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Helper class to operate a device with basic functionality to accept BluetoothRfcommConnection.
+ *
+ * <p>
+ * Usage: // Create a virtual device to initiate connection. BluetoothRfcommConnector connector =
+ * new BluetoothRfcommConnector(address, callback); // Start connection to a remote address with
+ * given uuid. connector.start(remoteAddress, remoteUuid);
+ *
+ * // A blocking call to wait for connection. connector.waitTillConnected();
+ *
+ * // Connector sends a message connector.send("Hello".getBytes());
+ *
+ * // Cancel connector to release all blocking calls. connector.cancel();
+ */
+public class BluetoothRfcommConnector {
+
+ private static final String TAG = "BluetoothRfcommConnector";
+
+ /**
+ * Identifiers to control Bluetooth operation.
+ */
+ public static final int PRE_CONNECT = 1;
+ public static final int PRE_READ = 2;
+ public static final int PRE_WRITE = 3;
+
+ private final String mAddress;
+ private String mRemoteAddress = null;
+ private final UUID mRemoteUuid;
+ private BluetoothSocket mSocket;
+
+ private final Callback mCallback;
+ private final AtomicBoolean mCancelled;
+ private final CountDownLatch mConnectLatch = new CountDownLatch(1);
+ private final Queue<CountDownLatch> mReadLatches = new ConcurrentLinkedQueue<>();
+
+ /**
+ * Callback of BluetoothRfcommConnector.
+ */
+ public interface Callback {
+
+ void onConnected(BluetoothSocket socket);
+
+ void onDataReceived(byte[] data);
+
+ void onDataWritten(byte[] data);
+
+ void onError(Exception exception);
+ }
+
+ public BluetoothRfcommConnector(String address, UUID uuid, Callback callback) {
+ this.mAddress = address;
+ this.mRemoteUuid = uuid;
+ this.mCallback = callback;
+ this.mCancelled = new AtomicBoolean(false);
+ DeviceShadowEnvironment.addDevice(address).bluetooth()
+ .setAdapterInitialState(BluetoothAdapter.STATE_ON);
+ }
+
+ /**
+ * Start connection to a remote address, and receive data once connected.
+ */
+ public Future<Void> start(String remoteAddress) {
+ this.mRemoteAddress = remoteAddress;
+ return DeviceShadowEnvironment.run(mAddress, mCode);
+ }
+
+ /**
+ * Stop receiving data.
+ */
+ public Future<Void> cancel() {
+ return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+ @Override
+ public void run() {
+ mCancelled.set(true);
+ try {
+ mSocket.close();
+ } catch (IOException e) {
+ Log.w(TAG, mAddress + " fail to close socket", e);
+ }
+ }
+ });
+ }
+
+ public void waitTillConnected() {
+ try {
+ mConnectLatch.await();
+ } catch (InterruptedException e) {
+ Log.w(TAG, mAddress + " fail to wait till started: ", e);
+ }
+ }
+
+ public void waitTillDataReceived() {
+ try {
+ if (mReadLatches.size() > 0) {
+ mReadLatches.poll().await();
+ }
+ } catch (InterruptedException e) {
+ // no-op.
+ }
+ }
+
+ /**
+ * Send data to conneceted device.
+ */
+ public Future<Void> send(final byte[] data) {
+ return DeviceShadowEnvironment.run(mAddress, new Runnable() {
+ @Override
+ public void run() {
+ if (mSocket != null) {
+ try {
+ DeviceShadowEnvironmentInternal.setInterruptibleBluetooth(PRE_WRITE);
+ IOUtils.write(mSocket.getOutputStream(), data);
+ Log.d(TAG, mAddress + " write: " + new String(data));
+ mCallback.onDataWritten(data);
+ } catch (IOException e) {
+ Log.w(TAG, mAddress + " fail to write: ", e);
+ mCallback.onError(new IOException("Fail to write", e));
+ }
+ }
+ }
+ });
+ }
+
+ private Runnable mCode = new Runnable() {
+ @Override
+ public void run() {
+ try {
+ DeviceShadowEnvironmentInternal.setInterruptibleBluetooth(PRE_CONNECT);
+ mSocket = BluetoothAdapter.getDefaultAdapter()
+ .getRemoteDevice(mRemoteAddress)
+ .createInsecureRfcommSocketToServiceRecord(mRemoteUuid);
+ mSocket.connect();
+ Log.d(TAG, mAddress + " accept: " + mSocket.getRemoteDevice().getAddress());
+ mCallback.onConnected(mSocket);
+ } catch (IOException e) {
+ Log.w(TAG, mAddress + " fail to connect: ", e);
+ mCallback.onError(new IOException("Fail to connect", e));
+ } finally {
+ mConnectLatch.countDown();
+ }
+
+ try {
+ do {
+ CountDownLatch latch = new CountDownLatch(1);
+ mReadLatches.add(latch);
+ DeviceShadowEnvironmentInternal.setInterruptibleBluetooth(PRE_READ);
+ byte[] data = IOUtils.read(mSocket.getInputStream());
+ Log.d(TAG, mAddress + " read: " + new String(data));
+ mCallback.onDataReceived(data);
+ latch.countDown();
+ } while (!mCancelled.get());
+ } catch (IOException e) {
+ Log.w(TAG, mAddress + " fail to read: ", e);
+ mCallback.onError(new IOException("Fail to read", e));
+ }
+ Log.d(TAG, mAddress + " stop receiving");
+ }
+ };
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/nfc/NfcActivity.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/nfc/NfcActivity.java
new file mode 100644
index 0000000..8ae4435
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/nfc/NfcActivity.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.helpers.nfc;
+
+import android.app.Activity;
+
+/**
+ * Activity that triggers or receives NFC events.
+ */
+public class NfcActivity extends Activity {
+
+ private NfcReceiver.Callback mCallback;
+
+ public void setCallback(NfcReceiver.Callback callback) {
+ this.mCallback = callback;
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ NfcReceiver.processIntent(mCallback, getIntent());
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/nfc/NfcReceiver.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/nfc/NfcReceiver.java
new file mode 100644
index 0000000..b85a124
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/nfc/NfcReceiver.java
@@ -0,0 +1,121 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.helpers.nfc;
+
+import android.app.Activity;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.nfc.NdefMessage;
+import android.nfc.NfcAdapter;
+import android.os.Parcelable;
+import android.util.Log;
+
+import com.android.libraries.testing.deviceshadower.DeviceShadowEnvironment;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Helper class to receive NFC events.
+ */
+public class NfcReceiver {
+
+ private static final String TAG = "NfcReceiver";
+
+ /**
+ * Callback to receive message.
+ */
+ public interface Callback {
+
+ void onReceive(String message);
+ }
+
+ private final String mAddress;
+ private final Activity mActivity;
+ private CountDownLatch mReceiveLatch;
+
+ private final BroadcastReceiver mReceiver;
+ private final IntentFilter mFilter;
+
+ public NfcReceiver(String address, Activity activity, final Callback callback) {
+ this(address, activity, new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (NfcAdapter.ACTION_NDEF_DISCOVERED.equals(intent.getAction())) {
+ processIntent(callback, intent);
+ }
+ }
+ });
+ DeviceShadowEnvironment.addDevice(address);
+ }
+
+ public NfcReceiver(
+ final String address, Activity activity, final BroadcastReceiver clientReceiver) {
+ this.mAddress = address;
+ this.mActivity = activity;
+
+ this.mFilter = new IntentFilter(NfcAdapter.ACTION_NDEF_DISCOVERED);
+ this.mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ Log.v(TAG, "Receive broadcast on device " + address);
+ clientReceiver.onReceive(context, intent);
+ mReceiveLatch.countDown();
+ }
+ };
+ DeviceShadowEnvironment.addDevice(address);
+ }
+
+ public void startReceive() throws InterruptedException, ExecutionException {
+ mReceiveLatch = new CountDownLatch(1);
+
+ DeviceShadowEnvironment.run(mAddress, new Runnable() {
+ @Override
+ public void run() {
+ mActivity.getApplication().registerReceiver(mReceiver, mFilter);
+ }
+ }).get();
+ }
+
+ public void waitUntilReceive(long timeoutMillis) throws InterruptedException {
+ mReceiveLatch.await(timeoutMillis, TimeUnit.MILLISECONDS);
+ }
+
+ public void stopReceive() throws InterruptedException, ExecutionException {
+ DeviceShadowEnvironment.run(mAddress, new Runnable() {
+ @Override
+ public void run() {
+ mActivity.getApplication().unregisterReceiver(mReceiver);
+ }
+ }).get();
+ }
+
+ static void processIntent(Callback callback, Intent intent) {
+ Parcelable[] rawMsgs = intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES);
+ if (rawMsgs != null && rawMsgs.length > 0) {
+ // only one message sent during the beam
+ NdefMessage msg = (NdefMessage) rawMsgs[0];
+ if (callback != null) {
+ callback.onReceive(new String(msg.getRecords()[0].getPayload()));
+ }
+ }
+ }
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/nfc/NfcSender.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/nfc/NfcSender.java
new file mode 100644
index 0000000..dbbb5fa
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/nfc/NfcSender.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.helpers.nfc;
+
+import android.app.Activity;
+import android.nfc.NdefMessage;
+import android.nfc.NdefRecord;
+import android.nfc.NfcAdapter;
+import android.nfc.NfcAdapter.CreateNdefMessageCallback;
+import android.nfc.NfcAdapter.OnNdefPushCompleteCallback;
+import android.nfc.NfcEvent;
+
+import com.android.libraries.testing.deviceshadower.DeviceShadowEnvironment;
+
+import java.util.concurrent.ExecutionException;
+
+/**
+ * Helper class to send NFC events.
+ */
+public class NfcSender {
+
+ private static final String NFC_PACKAGE = "DS_PKG";
+ private static final String NFC_TAG = "DS_TAG";
+
+ /**
+ * Callback to update sender status.
+ */
+ public interface Callback {
+
+ void onSend(String message);
+ }
+
+ private final String mAddress;
+ private final Activity mActivity;
+ private final Callback mCallback;
+ private final SenderCallback mSenderCallback;
+ private String mSessage;
+
+ public NfcSender(String address, Activity activity, Callback callback) {
+ this.mCallback = callback;
+ this.mAddress = address;
+ this.mActivity = activity;
+ DeviceShadowEnvironment.addDevice(address);
+ this.mSenderCallback = new SenderCallback();
+ }
+
+ public void startSend(String message) throws InterruptedException, ExecutionException {
+ this.mSessage = message;
+ DeviceShadowEnvironment.run(mAddress, new Runnable() {
+ @Override
+ public void run() {
+ NfcAdapter nfcAdapter = NfcAdapter.getDefaultAdapter(mActivity);
+ nfcAdapter.setNdefPushMessageCallback(mSenderCallback, mActivity);
+ nfcAdapter.setOnNdefPushCompleteCallback(mSenderCallback, mActivity);
+ }
+ }).get();
+ }
+
+ class SenderCallback implements CreateNdefMessageCallback, OnNdefPushCompleteCallback {
+
+ @Override
+ public NdefMessage createNdefMessage(NfcEvent event) {
+ NdefMessage msg = new NdefMessage(new NdefRecord[]{
+ NdefRecord.createExternal(NFC_PACKAGE, NFC_TAG, mSessage.getBytes())
+ });
+ return msg;
+ }
+
+ @Override
+ public void onNdefPushComplete(NfcEvent event) {
+ mCallback.onSend(mSessage);
+ }
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/utils/IOUtils.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/utils/IOUtils.java
new file mode 100644
index 0000000..d89754b
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/helpers/utils/IOUtils.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.helpers.utils;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+
+/**
+ * Utils for IO methods.
+ */
+public class IOUtils {
+
+ /**
+ * Write num of bytes to be sent and payload through OutputStream.
+ */
+ public static void write(OutputStream os, byte[] data) throws IOException {
+ ByteBuffer buffer = ByteBuffer.allocate(4 + data.length).putInt(data.length).put(data);
+ os.write(buffer.array());
+ }
+
+ /**
+ * Read num of bytes to be read, and payload through InputStream.
+ *
+ * @return payload received.
+ */
+ public static byte[] read(InputStream is) throws IOException {
+ byte[] size = new byte[4];
+ is.read(size, 0, 4 /* bytes of int type */);
+
+ byte[] data = new byte[ByteBuffer.wrap(size).getInt()];
+ is.read(data);
+ return data;
+ }
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/DeviceShadowEnvironmentImpl.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/DeviceShadowEnvironmentImpl.java
new file mode 100644
index 0000000..6a06ce4
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/DeviceShadowEnvironmentImpl.java
@@ -0,0 +1,353 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.internal;
+
+import android.content.ContentProvider;
+import android.os.Looper;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.libraries.testing.deviceshadower.Enums.Distance;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.BlueletImpl;
+import com.android.libraries.testing.deviceshadower.internal.common.NamedRunnable;
+import com.android.libraries.testing.deviceshadower.internal.common.Scheduler;
+import com.android.libraries.testing.deviceshadower.internal.nfc.NfcletImpl;
+import com.android.libraries.testing.deviceshadower.internal.sms.SmsContentProvider;
+import com.android.libraries.testing.deviceshadower.internal.sms.SmsletImpl;
+import com.android.libraries.testing.deviceshadower.internal.utils.Logger;
+
+import com.google.common.collect.ImmutableList;
+
+import org.robolectric.Shadows;
+import org.robolectric.shadows.ShadowLooper;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Proxy to manage internal data models, and help shadows to exchange data.
+ */
+public class DeviceShadowEnvironmentImpl {
+
+ private static final Logger LOGGER = Logger.create("DeviceShadowEnvironmentImpl");
+ private static final long SCHEDULER_WAIT_TIMEOUT_MILLIS = 5000L;
+
+ // ThreadLocal to store local address for each device.
+ private static InheritableThreadLocal<DeviceletImpl> sLocalDeviceletImpl =
+ new InheritableThreadLocal<>();
+
+ // Devicelets contains all registered devicelet to simulate a device.
+ private static final Map<String, DeviceletImpl> DEVICELETS = new ConcurrentHashMap<>();
+
+ @VisibleForTesting
+ static final Map<String, ExecutorService> EXECUTORS = new ConcurrentHashMap<>();
+
+ private static final List<DeviceShadowException> INTERNAL_EXCEPTIONS =
+ Collections.synchronizedList(new ArrayList<DeviceShadowException>());
+
+ private static final ContentProvider smsContentProvider = new SmsContentProvider();
+
+ public static DeviceletImpl getDeviceletImpl(String address) {
+ return DEVICELETS.get(address);
+ }
+
+ public static void checkInternalExceptions() {
+ if (INTERNAL_EXCEPTIONS.size() > 0) {
+ for (DeviceShadowException exception : INTERNAL_EXCEPTIONS) {
+ LOGGER.e("Internal exception", exception);
+ }
+ INTERNAL_EXCEPTIONS.clear();
+ throw new RuntimeException("DeviceShadower has internal exceptions");
+ }
+ }
+
+ public static void reset() {
+ // reset local devicelet for single device testing
+ sLocalDeviceletImpl.remove();
+ DEVICELETS.clear();
+ BlueletImpl.reset();
+ INTERNAL_EXCEPTIONS.clear();
+ }
+
+ public static boolean await(long timeoutMillis) {
+ boolean schedulerDone = false;
+ try {
+ schedulerDone = Scheduler.await(timeoutMillis);
+ } catch (InterruptedException e) {
+ // no-op.
+ } finally {
+ if (!schedulerDone) {
+ catchInternalException(new DeviceShadowException("Scheduler not complete"));
+ for (DeviceletImpl devicelet : DEVICELETS.values()) {
+ LOGGER.e(
+ String.format(
+ "Device %s\n\tUI: %s\n\tService: %s",
+ devicelet.getAddress(),
+ devicelet.getUiScheduler(),
+ devicelet.getServiceScheduler()));
+ }
+ Scheduler.clear();
+ }
+ }
+ for (ExecutorService executor : EXECUTORS.values()) {
+ executor.shutdownNow();
+ }
+ boolean terminateSuccess = true;
+ for (ExecutorService executor : EXECUTORS.values()) {
+ try {
+ executor.awaitTermination(timeoutMillis, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ terminateSuccess = false;
+ }
+ if (!executor.isTerminated()) {
+ LOGGER.e("Failed to terminate executor.");
+ terminateSuccess = false;
+ }
+ }
+ EXECUTORS.clear();
+ return schedulerDone && terminateSuccess;
+ }
+
+ public static boolean hasLocalDeviceletImpl() {
+ return sLocalDeviceletImpl.get() != null;
+ }
+
+ public static DeviceletImpl getLocalDeviceletImpl() {
+ return sLocalDeviceletImpl.get();
+ }
+
+ public static List<DeviceletImpl> getDeviceletImpls() {
+ return ImmutableList.copyOf(DEVICELETS.values());
+ }
+
+ public static BlueletImpl getLocalBlueletImpl() {
+ return sLocalDeviceletImpl.get().blueletImpl();
+ }
+
+ public static BlueletImpl getBlueletImpl(String address) {
+ DeviceletImpl devicelet = getDeviceletImpl(address);
+ return devicelet == null ? null : devicelet.blueletImpl();
+ }
+
+ public static NfcletImpl getLocalNfcletImpl() {
+ return sLocalDeviceletImpl.get().nfcletImpl();
+ }
+
+ public static NfcletImpl getNfcletImpl(String address) {
+ DeviceletImpl devicelet = getDeviceletImpl(address);
+ return devicelet == null ? null : devicelet.nfcletImpl();
+ }
+
+ public static SmsletImpl getLocalSmsletImpl() {
+ return sLocalDeviceletImpl.get().smsletImpl();
+ }
+
+ public static ContentProvider getSmsContentProvider() {
+ return smsContentProvider;
+ }
+
+ @SuppressWarnings("FutureReturnValueIgnored")
+ public static DeviceletImpl addDevice(String address) {
+ EXECUTORS.put(address, Executors.newCachedThreadPool());
+
+ // DeviceShadower keeps track of the "local" device based on the current thread. It uses an
+ // InheritableThreadLocal, so threads created by the current thread also get the same
+ // thread-local value. Add the device on its own thread, to set the thread local for that
+ // thread and its children.
+ try {
+ EXECUTORS
+ .get(address)
+ .submit(
+ () -> {
+ DeviceletImpl devicelet = new DeviceletImpl(address);
+ DEVICELETS.put(address, devicelet);
+ setLocalDevice(address);
+ // Ensure these threads are actually created, by posting one empty
+ // runnable.
+ devicelet.getServiceScheduler()
+ .post(NamedRunnable.create("Init", () -> {
+ }));
+ devicelet.getUiScheduler().post(NamedRunnable.create("Init", () -> {
+ }));
+ })
+ .get();
+ } catch (InterruptedException | ExecutionException e) {
+ throw new IllegalStateException(e);
+ }
+
+ return DEVICELETS.get(address);
+ }
+
+ public static void removeDevice(String address) {
+ DEVICELETS.remove(address);
+ EXECUTORS.remove(address);
+ }
+
+ public static void setInterruptibleBluetooth(int identifier) {
+ getLocalBlueletImpl().setInterruptible(identifier);
+ }
+
+ public static void interruptBluetooth(String address, int identifier) {
+ getBlueletImpl(address).interrupt(identifier);
+ }
+
+ public static void setDistance(String address1, String address2, final Distance distance) {
+ final DeviceletImpl device1 = getDeviceletImpl(address1);
+ final DeviceletImpl device2 = getDeviceletImpl(address2);
+
+ Future<Void> result1 = null;
+ Future<Void> result2 = null;
+ if (device1.updateDistance(address2, distance)) {
+ result1 =
+ run(
+ address1,
+ () -> {
+ device1.onDistanceChange(device2, distance);
+ return null;
+ });
+ }
+
+ if (device2.updateDistance(address1, distance)) {
+ result2 =
+ run(
+ address2,
+ () -> {
+ device2.onDistanceChange(device1, distance);
+ return null;
+ });
+ }
+
+ try {
+ if (result1 != null) {
+ result1.get();
+ }
+ if (result2 != null) {
+ result2.get();
+ }
+ } catch (InterruptedException | ExecutionException e) {
+ catchInternalException(new DeviceShadowException(e));
+ }
+ }
+
+ /**
+ * Set local Bluelet for current thread.
+ *
+ * <p>This can be used to convert current running thread to hold a bluelet object, so that unit
+ * test does not have to call BluetoothEnvironment.run() to run code.
+ */
+ @VisibleForTesting
+ public static void setLocalDevice(String address) {
+ DeviceletImpl local = DEVICELETS.get(address);
+ if (local == null) {
+ throw new RuntimeException(address + " is not initialized by BluetoothEnvironment");
+ }
+ sLocalDeviceletImpl.set(local);
+ }
+
+ public static <T> Future<T> run(final String address, final Callable<T> snippet) {
+ return EXECUTORS
+ .get(address)
+ .submit(
+ () -> {
+ DeviceShadowEnvironmentImpl.setLocalDevice(address);
+ ShadowLooper mainLooper = Shadows.shadowOf(Looper.getMainLooper());
+ try {
+ T result = snippet.call();
+
+ // Avoid idling the main looper in paused mode since doing so is
+ // only allowed from the main thread.
+ if (!mainLooper.isPaused()) {
+ // In Robolectric, runnable doesn't run when posting thread
+ // differs from looper thread, idle main looper explicitly to
+ // execute posted Runnables.
+ ShadowLooper.idleMainLooper();
+ }
+
+ // Wait all scheduled runnables complete.
+ Scheduler.await(SCHEDULER_WAIT_TIMEOUT_MILLIS);
+ return result;
+ } catch (Exception e) {
+ LOGGER.e("Fail to call code on device: " + address, e);
+ if (!mainLooper.isPaused()) {
+ // reset() is not supported in paused mode.
+ mainLooper.reset();
+ }
+ throw new RuntimeException(e);
+ }
+ });
+ }
+
+ // @CanIgnoreReturnValue
+ // Return value can be ignored because {@link Scheduler} will call
+ // {@link catchInternalException} to catch exceptions, and throw when test completes.
+ public static Future<?> runOnUi(String address, NamedRunnable snippet) {
+ Scheduler scheduler = DeviceShadowEnvironmentImpl.getDeviceletImpl(address)
+ .getUiScheduler();
+ return run(scheduler, address, snippet);
+ }
+
+ // @CanIgnoreReturnValue
+ // Return value can be ignored because {@link Scheduler} will call
+ // {@link catchInternalException} to catch exceptions, and throw when test completes.
+ public static Future<?> runOnService(String address, NamedRunnable snippet) {
+ Scheduler scheduler =
+ DeviceShadowEnvironmentImpl.getDeviceletImpl(address).getServiceScheduler();
+ return run(scheduler, address, snippet);
+ }
+
+ // @CanIgnoreReturnValue
+ // Return value can be ignored because {@link Scheduler} will call
+ // {@link catchInternalException} to catch exceptions, and throw when test completes.
+ private static Future<?> run(
+ Scheduler scheduler, final String address, final NamedRunnable snippet) {
+ return scheduler.post(
+ NamedRunnable.create(
+ snippet.toString(),
+ () -> {
+ DeviceShadowEnvironmentImpl.setLocalDevice(address);
+ snippet.run();
+ }));
+ }
+
+ public static void catchInternalException(Exception exception) {
+ INTERNAL_EXCEPTIONS.add(new DeviceShadowException(exception));
+ }
+
+ // This is used to test Device Shadower internal.
+ @VisibleForTesting
+ public static void setDeviceletForTest(String address, DeviceletImpl devicelet) {
+ DEVICELETS.put(address, devicelet);
+ }
+
+ @VisibleForTesting
+ public static void setExecutorForTest(String address) {
+ setExecutorForTest(address, Executors.newCachedThreadPool());
+ }
+
+ @VisibleForTesting
+ public static void setExecutorForTest(String address, ExecutorService executor) {
+ EXECUTORS.put(address, executor);
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/DeviceShadowException.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/DeviceShadowException.java
new file mode 100644
index 0000000..77d358f
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/DeviceShadowException.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.internal;
+
+/**
+ * Internal exception to indicate error from DeviceShadower framework.
+ */
+public class DeviceShadowException extends Exception {
+
+ public DeviceShadowException(Throwable e) {
+ super(e);
+ }
+
+ public DeviceShadowException(String msg) {
+ super(msg);
+ }
+
+ public DeviceShadowException(String msg, Throwable e) {
+ super(msg, e);
+ }
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/DeviceletImpl.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/DeviceletImpl.java
new file mode 100644
index 0000000..9aea065
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/DeviceletImpl.java
@@ -0,0 +1,121 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.internal;
+
+import com.android.libraries.testing.deviceshadower.Bluelet;
+import com.android.libraries.testing.deviceshadower.Devicelet;
+import com.android.libraries.testing.deviceshadower.Enums.Distance;
+import com.android.libraries.testing.deviceshadower.Nfclet;
+import com.android.libraries.testing.deviceshadower.Smslet;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.BlueletImpl;
+import com.android.libraries.testing.deviceshadower.internal.common.BroadcastManager;
+import com.android.libraries.testing.deviceshadower.internal.common.Scheduler;
+import com.android.libraries.testing.deviceshadower.internal.nfc.NfcletImpl;
+import com.android.libraries.testing.deviceshadower.internal.sms.SmsletImpl;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * DeviceletImpl is the implementation to hold different medium-let in DeviceShadowEnvironment.
+ */
+public class DeviceletImpl implements Devicelet {
+
+ private final BlueletImpl mBluelet;
+ private final NfcletImpl mNfclet;
+ private final SmsletImpl mSmslet;
+ private final BroadcastManager mBroadcastManager;
+ private final String mAddress;
+ private final Map<String, Distance> mDistanceMap = new HashMap<>();
+ private final Scheduler mServiceScheduler;
+ private final Scheduler mUiScheduler;
+
+ public DeviceletImpl(String address) {
+ this.mAddress = address;
+ this.mServiceScheduler = new Scheduler(address + "-service");
+ this.mUiScheduler = new Scheduler(address + "-main");
+ this.mBroadcastManager = new BroadcastManager(mUiScheduler);
+ this.mBluelet = new BlueletImpl(address, mBroadcastManager);
+ this.mNfclet = new NfcletImpl();
+ this.mSmslet = new SmsletImpl();
+ }
+
+ @Override
+ public Bluelet bluetooth() {
+ return mBluelet;
+ }
+
+ public BlueletImpl blueletImpl() {
+ return mBluelet;
+ }
+
+ @Override
+ public Nfclet nfc() {
+ return mNfclet;
+ }
+
+ public NfcletImpl nfcletImpl() {
+ return mNfclet;
+ }
+
+ @Override
+ public Smslet sms() {
+ return mSmslet;
+ }
+
+ public SmsletImpl smsletImpl() {
+ return mSmslet;
+ }
+
+ public BroadcastManager getBroadcastManager() {
+ return mBroadcastManager;
+ }
+
+ @Override
+ public String getAddress() {
+ return mAddress;
+ }
+
+ Scheduler getServiceScheduler() {
+ return mServiceScheduler;
+ }
+
+ Scheduler getUiScheduler() {
+ return mUiScheduler;
+ }
+
+ /**
+ * Update distance to remote device.
+ *
+ * @return true if distance updated.
+ */
+ /*package*/ boolean updateDistance(String remoteAddress, Distance distance) {
+ Distance currentDistance = mDistanceMap.get(remoteAddress);
+ if (currentDistance == null || !distance.equals(currentDistance)) {
+ mDistanceMap.put(remoteAddress, distance);
+ return true;
+ }
+ return false;
+ }
+
+ /*package*/ void onDistanceChange(DeviceletImpl remote, Distance distance) {
+ if (distance == Distance.NEAR) {
+ mNfclet.onNear(remote.mNfclet);
+ }
+ }
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/AdapterDelegate.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/AdapterDelegate.java
new file mode 100644
index 0000000..b5227b7
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/AdapterDelegate.java
@@ -0,0 +1,259 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.internal.bluetooth;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothClass.Device;
+import android.os.Build.VERSION;
+
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.DeviceletImpl;
+import com.android.libraries.testing.deviceshadower.internal.common.NamedRunnable;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import javax.annotation.concurrent.GuardedBy;
+
+/**
+ * Class handling Bluetooth Adapter State change. Currently async event processing is not supported,
+ * and there is no deferred operation when adapter is in a pending state.
+ */
+class AdapterDelegate {
+
+ /**
+ * Callback for adapter
+ */
+ public interface Callback {
+
+ void onAdapterStateChange(State prevState, State newState);
+
+ void onBleStateChange(State prevState, State newState);
+
+ void onDiscoveryStarted();
+
+ void onDiscoveryFinished();
+
+ void onDeviceFound(String address, int bluetoothClass, String name);
+ }
+
+ @GuardedBy("this")
+ private State mCurrentState;
+
+ private final String mAddress;
+ private final Callback mCallback;
+ private AtomicBoolean mIsDiscovering = new AtomicBoolean(false);
+ private final AtomicInteger mScanMode = new AtomicInteger(BluetoothAdapter.SCAN_MODE_NONE);
+ private int mBluetoothClass = Device.PHONE_SMART;
+
+ AdapterDelegate(String address, Callback callback) {
+ this.mAddress = address;
+ this.mCurrentState = State.OFF;
+ this.mCallback = callback;
+ }
+
+ synchronized void processEvent(Event event) {
+ State newState = TRANSITION[mCurrentState.ordinal()][event.ordinal()];
+ if (newState == null) {
+ return;
+ }
+ State prevState = mCurrentState;
+ mCurrentState = newState;
+ handleStateChange(prevState, newState);
+ }
+
+ private void handleStateChange(State prevState, State newState) {
+ // TODO(b/200231384): fake service bind/unbind on state change
+ if (prevState.equals(newState)) {
+ return;
+ }
+ if (VERSION.SDK_INT < 23) {
+ mCallback.onAdapterStateChange(prevState, newState);
+ } else {
+ mCallback.onBleStateChange(prevState, newState);
+ if (newState.equals(State.BLE_TURNING_ON)
+ || newState.equals(State.BLE_TURNING_OFF)
+ || newState.equals(State.OFF)
+ || (newState.equals(State.BLE_ON) && prevState.equals(State.BLE_TURNING_ON))) {
+ return;
+ }
+ if (newState.equals(State.BLE_ON)) {
+ newState = State.OFF;
+ } else if (prevState.equals(State.BLE_ON)) {
+ prevState = State.OFF;
+ }
+ mCallback.onAdapterStateChange(prevState, newState);
+ }
+ }
+
+ synchronized State getState() {
+ return mCurrentState;
+ }
+
+ synchronized void setState(State state) {
+ mCurrentState = state;
+ }
+
+ void setBluetoothClass(int bluetoothClass) {
+ this.mBluetoothClass = bluetoothClass;
+ }
+
+ int getBluetoothClass() {
+ return mBluetoothClass;
+ }
+
+ @SuppressWarnings("FutureReturnValueIgnored")
+ void startDiscovery() {
+ synchronized (this) {
+ if (mIsDiscovering.get()) {
+ return;
+ }
+ mIsDiscovering.set(true);
+ }
+
+ mCallback.onDiscoveryStarted();
+
+ NamedRunnable onDeviceFound =
+ NamedRunnable.create(
+ "BluetoothAdapter.onDeviceFound",
+ new Runnable() {
+ @Override
+ public void run() {
+ List<DeviceletImpl> devices =
+ DeviceShadowEnvironmentImpl.getDeviceletImpls();
+ for (DeviceletImpl devicelet : devices) {
+ BlueletImpl bluelet = devicelet.blueletImpl();
+ if (mAddress.equals(devicelet.getAddress())
+ || bluelet.getAdapterDelegate().mScanMode.get()
+ != BluetoothAdapter
+ .SCAN_MODE_CONNECTABLE_DISCOVERABLE) {
+ continue;
+ }
+ mCallback.onDeviceFound(
+ bluelet.address,
+ bluelet.getAdapterDelegate().mBluetoothClass,
+ bluelet.mName);
+ }
+ finishDiscovery();
+ }
+ });
+
+ DeviceShadowEnvironmentImpl.runOnUi(mAddress, onDeviceFound);
+ }
+
+ void cancelDiscovery() {
+ finishDiscovery();
+ }
+
+ boolean isDiscovering() {
+ return mIsDiscovering.get();
+ }
+
+ void setScanMode(int scanMode) {
+ // TODO(b/200231384): broadcast scan mode change.
+ this.mScanMode.set(scanMode);
+ }
+
+ int getScanMode() {
+ return mScanMode.get();
+ }
+
+ private void finishDiscovery() {
+ synchronized (this) {
+ if (!mIsDiscovering.get()) {
+ return;
+ }
+ mIsDiscovering.set(false);
+ }
+ mCallback.onDiscoveryFinished();
+ }
+
+ enum State {
+ OFF(BluetoothAdapter.STATE_OFF),
+ TURNING_ON(BluetoothAdapter.STATE_TURNING_ON),
+ ON(BluetoothAdapter.STATE_ON),
+ TURNING_OFF(BluetoothAdapter.STATE_TURNING_OFF),
+ // States for API23+
+ BLE_TURNING_ON(BluetoothConstants.STATE_BLE_TURNING_ON),
+ BLE_ON(BluetoothConstants.STATE_BLE_ON),
+ BLE_TURNING_OFF(BluetoothConstants.STATE_BLE_TURNING_OFF);
+
+ private static final Map<Integer, State> LOOKUP = new HashMap<>();
+
+ static {
+ for (State state : State.values()) {
+ LOOKUP.put(state.getValue(), state);
+ }
+ }
+
+ static State lookup(int value) {
+ return LOOKUP.get(value);
+ }
+
+ private final int mValue;
+
+ State(int value) {
+ this.mValue = value;
+ }
+
+ int getValue() {
+ return mValue;
+ }
+ }
+
+ /*
+ * Represents Bluetooth events which can trigger adapter state change.
+ */
+ enum Event {
+ USER_TURN_ON,
+ USER_TURN_OFF,
+ BREDR_STARTED,
+ BREDR_STOPPED,
+ // Events for API23+
+ BLE_TURN_ON,
+ BLE_TURN_OFF,
+ BLE_STARTED,
+ BLE_STOPPED
+ }
+
+ private static final State[][] TRANSITION =
+ new State[State.values().length][Event.values().length];
+
+ static {
+ if (VERSION.SDK_INT < 23) {
+ // transition table before API23
+ TRANSITION[State.OFF.ordinal()][Event.USER_TURN_ON.ordinal()] = State.TURNING_ON;
+ TRANSITION[State.TURNING_ON.ordinal()][Event.BREDR_STARTED.ordinal()] = State.ON;
+ TRANSITION[State.ON.ordinal()][Event.USER_TURN_OFF.ordinal()] = State.TURNING_OFF;
+ TRANSITION[State.TURNING_OFF.ordinal()][Event.BREDR_STOPPED.ordinal()] = State.OFF;
+ } else {
+ // transition table starting from API23
+ TRANSITION[State.OFF.ordinal()][Event.BLE_TURN_ON.ordinal()] = State.BLE_TURNING_ON;
+ TRANSITION[State.BLE_TURNING_ON.ordinal()][Event.BLE_STARTED.ordinal()] = State.BLE_ON;
+ TRANSITION[State.BLE_ON.ordinal()][Event.USER_TURN_ON.ordinal()] = State.TURNING_ON;
+ TRANSITION[State.TURNING_ON.ordinal()][Event.BREDR_STARTED.ordinal()] = State.ON;
+ TRANSITION[State.ON.ordinal()][Event.BLE_TURN_OFF.ordinal()] = State.TURNING_OFF;
+ TRANSITION[State.TURNING_OFF.ordinal()][Event.BREDR_STOPPED.ordinal()] = State.BLE_ON;
+ TRANSITION[State.BLE_ON.ordinal()][Event.USER_TURN_OFF.ordinal()] =
+ State.BLE_TURNING_OFF;
+ TRANSITION[State.BLE_TURNING_OFF.ordinal()][Event.BLE_STOPPED.ordinal()] = State.OFF;
+ }
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/BlueletImpl.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/BlueletImpl.java
new file mode 100644
index 0000000..4e534e3
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/BlueletImpl.java
@@ -0,0 +1,495 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.internal.bluetooth;
+
+import static org.robolectric.util.ReflectionHelpers.callConstructor;
+
+import android.Manifest.permission;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothClass;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothProfile;
+import android.bluetooth.IBluetoothManager;
+import android.content.AttributionSource;
+import android.content.Intent;
+import android.os.Build.VERSION;
+import android.os.ParcelUuid;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.libraries.testing.deviceshadower.Bluelet;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.AdapterDelegate.Event;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.AdapterDelegate.State;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.connection.RfcommDelegate;
+import com.android.libraries.testing.deviceshadower.internal.common.BroadcastManager;
+import com.android.libraries.testing.deviceshadower.internal.common.Interrupter;
+import com.android.libraries.testing.deviceshadower.internal.utils.Logger;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableMap;
+
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * A container class of a real-world Bluetooth device.
+ */
+public class BlueletImpl implements Bluelet {
+
+ enum PairingConfirmation {
+ UNKNOWN,
+ CONFIRMED,
+ DENIED
+ }
+
+ /**
+ * See hidden {@link #EXTRA_REASON} and reason values in {@link BluetoothDevice}.
+ */
+ static final int REASON_SUCCESS = 0;
+ /**
+ * See hidden {@link #EXTRA_REASON} and reason values in {@link BluetoothDevice}.
+ */
+ static final int UNBOND_REASON_AUTH_FAILED = 1;
+ /**
+ * See hidden {@link #EXTRA_REASON} and reason values in {@link BluetoothDevice}.
+ */
+ static final int UNBOND_REASON_AUTH_CANCELED = 3;
+
+ /**
+ * Hidden in {@link BluetoothDevice}.
+ */
+ private static final String EXTRA_REASON = "android.bluetooth.device.extra.REASON";
+
+ private static final Logger LOGGER = Logger.create("BlueletImpl");
+
+ private static final ImmutableMap<Integer, Integer> PROFILE_STATE_TO_ADAPTER_STATE =
+ ImmutableMap.<Integer, Integer>builder()
+ .put(BluetoothProfile.STATE_CONNECTED, BluetoothAdapter.STATE_CONNECTED)
+ .put(BluetoothProfile.STATE_CONNECTING, BluetoothAdapter.STATE_CONNECTING)
+ .put(BluetoothProfile.STATE_DISCONNECTING, BluetoothAdapter.STATE_DISCONNECTING)
+ .put(BluetoothProfile.STATE_DISCONNECTED, BluetoothAdapter.STATE_DISCONNECTED)
+ .build();
+
+ public static void reset() {
+ RfcommDelegate.reset();
+ }
+
+ public final String address;
+ String mName;
+ ParcelUuid[] mProfileUuids = new ParcelUuid[0];
+ int mPhonebookAccessPermission;
+ int mMessageAccessPermission;
+ int mSimAccessPermission;
+ final BluetoothAdapter mAdapter;
+ int mPassKey;
+
+ private CreateBondOutcome mCreateBondOutcome = CreateBondOutcome.SUCCESS;
+ private int mCreateBondFailureReason;
+ private IoCapabilities mIoCapabilities = IoCapabilities.NO_INPUT_NO_OUTPUT;
+ private boolean mRefuseConnections;
+ private FetchUuidsTiming mFetchUuidsTiming = FetchUuidsTiming.AFTER_BONDING;
+ private boolean mEnableCVE20192225;
+
+ private final Interrupter mInterrupter;
+ private final AdapterDelegate mAdapterDelegate;
+ private final RfcommDelegate mRfcommDelegate;
+ private final GattDelegate mGattDelegate;
+ private final BluetoothBroadcastHandler mBluetoothBroadcastHandler;
+ private final Map<String, Integer> mRemoteAddressToBondState = new HashMap<>();
+ private final Map<String, PairingConfirmation> mRemoteAddressToPairingConfirmation =
+ new HashMap<>();
+ private final Map<Integer, Integer> mProfileTypeToConnectionState = new HashMap<>();
+ private final Set<BluetoothDevice> mBondedDevices = new HashSet<>();
+
+ public BlueletImpl(String address, BroadcastManager broadcastManager) {
+ this.address = address;
+ this.mName = address;
+ this.mAdapter = callConstructor(BluetoothAdapter.class,
+ ClassParameter.from(IBluetoothManager.class, new IBluetoothManagerImpl()),
+ ClassParameter.from(AttributionSource.class,
+ AttributionSource.myAttributionSource()));
+ mBluetoothBroadcastHandler = new BluetoothBroadcastHandler(broadcastManager);
+ mInterrupter = new Interrupter();
+ mAdapterDelegate = new AdapterDelegate(address, mBluetoothBroadcastHandler);
+ mRfcommDelegate = new RfcommDelegate(address, mBluetoothBroadcastHandler, mInterrupter);
+ mGattDelegate = new GattDelegate(address);
+ }
+
+ @Override
+ public Bluelet setAdapterInitialState(int state) throws IllegalArgumentException {
+ LOGGER.d(String.format("Address: %s, setAdapterInitialState(%d)", address, state));
+ Preconditions.checkArgument(
+ state == BluetoothAdapter.STATE_OFF || state == BluetoothAdapter.STATE_ON,
+ "State must be BluetoothAdapter.STATE_ON or BluetoothAdapter.STATE_OFF.");
+ mAdapterDelegate.setState(State.lookup(state));
+ return this;
+ }
+
+ @Override
+ public Bluelet setBluetoothClass(int bluetoothClass) {
+ mAdapterDelegate.setBluetoothClass(bluetoothClass);
+ return this;
+ }
+
+ @Override
+ public Bluelet setScanMode(int scanMode) {
+ mAdapterDelegate.setScanMode(scanMode);
+ return this;
+ }
+
+ @Override
+ public Bluelet setProfileUuids(ParcelUuid... profileUuids) {
+ this.mProfileUuids = profileUuids;
+ return this;
+ }
+
+ @Override
+ public Bluelet setIoCapabilities(IoCapabilities ioCapabilities) {
+ this.mIoCapabilities = ioCapabilities;
+ return this;
+ }
+
+ @Override
+ public Bluelet setCreateBondOutcome(CreateBondOutcome outcome, int failureReason) {
+ mCreateBondOutcome = outcome;
+ mCreateBondFailureReason = failureReason;
+ return this;
+ }
+
+ @Override
+ public Bluelet setRefuseConnections(boolean refuse) {
+ mRefuseConnections = refuse;
+ return this;
+ }
+
+ @Override
+ public Bluelet setRefuseGattConnections(boolean refuse) {
+ getGattDelegate().setRefuseConnections(refuse);
+ return this;
+ }
+
+ @Override
+ public Bluelet setFetchUuidsTiming(FetchUuidsTiming fetchUuidsTiming) {
+ this.mFetchUuidsTiming = fetchUuidsTiming;
+ return this;
+ }
+
+ @Override
+ public Bluelet addBondedDevice(String address) {
+ this.mBondedDevices.add(mAdapter.getRemoteDevice(address));
+ return this;
+ }
+
+ @Override
+ public Bluelet enableCVE20192225(boolean value) {
+ this.mEnableCVE20192225 = value;
+ return this;
+ }
+
+ IoCapabilities getIoCapabilities() {
+ return mIoCapabilities;
+ }
+
+ CreateBondOutcome getCreateBondOutcome() {
+ return mCreateBondOutcome;
+ }
+
+ int getCreateBondFailureReason() {
+ return mCreateBondFailureReason;
+ }
+
+ public boolean getRefuseConnections() {
+ return mRefuseConnections;
+ }
+
+ public FetchUuidsTiming getFetchUuidsTiming() {
+ return mFetchUuidsTiming;
+ }
+
+ BluetoothDevice[] getBondedDevices() {
+ return mBondedDevices.toArray(new BluetoothDevice[0]);
+ }
+
+ public boolean getEnableCVE20192225() {
+ return mEnableCVE20192225;
+ }
+
+ public void enableAdapter() {
+ LOGGER.d(String.format("Address: %s, enableAdapter()", address));
+ // TODO(b/200231384): async enabling, configurable delay, failure path
+ if (VERSION.SDK_INT < 23) {
+ mAdapterDelegate.processEvent(Event.USER_TURN_ON);
+ mAdapterDelegate.processEvent(Event.BREDR_STARTED);
+ } else {
+ mAdapterDelegate.processEvent(Event.BLE_TURN_ON);
+ mAdapterDelegate.processEvent(Event.BLE_STARTED);
+ mAdapterDelegate.processEvent(Event.USER_TURN_ON);
+ mAdapterDelegate.processEvent(Event.BREDR_STARTED);
+ }
+ }
+
+ public void disableAdapter() {
+ LOGGER.d(String.format("Address: %s, disableAdapter()", address));
+ // TODO(b/200231384): async disabling, configurable delay, failure path
+ if (VERSION.SDK_INT < 23) {
+ mAdapterDelegate.processEvent(Event.USER_TURN_OFF);
+ mAdapterDelegate.processEvent(Event.BREDR_STOPPED);
+ } else {
+ mAdapterDelegate.processEvent(Event.BLE_TURN_OFF);
+ mAdapterDelegate.processEvent(Event.BREDR_STOPPED);
+ mAdapterDelegate.processEvent(Event.USER_TURN_OFF);
+ mAdapterDelegate.processEvent(Event.BLE_STOPPED);
+ }
+ }
+
+ public AdapterDelegate getAdapterDelegate() {
+ return mAdapterDelegate;
+ }
+
+ public RfcommDelegate getRfcommDelegate() {
+ return mRfcommDelegate;
+ }
+
+ public GattDelegate getGattDelegate() {
+ return mGattDelegate;
+ }
+
+ public BluetoothAdapter getAdapter() {
+ return mAdapter;
+ }
+
+ public void setInterruptible(int identifier) {
+ LOGGER.d(String.format("Address: %s, setInterruptible(%d)", address, identifier));
+ mInterrupter.setInterruptible(identifier);
+ }
+
+ public void interrupt(int identifier) {
+ LOGGER.d(String.format("Address: %s, interrupt(%d)", address, identifier));
+ mInterrupter.interrupt(identifier);
+ }
+
+ @VisibleForTesting
+ public void setAdapterState(int state) throws IllegalArgumentException {
+ State s = State.lookup(state);
+ if (s == null) {
+ throw new IllegalArgumentException();
+ }
+ mAdapterDelegate.setState(s);
+ }
+
+ public int getBondState(String remoteAddress) {
+ return mRemoteAddressToBondState.containsKey(remoteAddress)
+ ? mRemoteAddressToBondState.get(remoteAddress)
+ : BluetoothDevice.BOND_NONE;
+ }
+
+ public void setBondState(String remoteAddress, int bondState, int failureReason) {
+ Intent intent =
+ newDeviceIntent(BluetoothDevice.ACTION_BOND_STATE_CHANGED, remoteAddress)
+ .putExtra(BluetoothDevice.EXTRA_BOND_STATE, bondState)
+ .putExtra(BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE,
+ getBondState(remoteAddress));
+
+ if (failureReason != REASON_SUCCESS) {
+ intent.putExtra(EXTRA_REASON, failureReason);
+ }
+
+ LOGGER.d(
+ String.format(
+ "Address: %s, Bluetooth Bond State Change Intent: remote=%s, %s -> %s "
+ + "(reason=%s)",
+ address, remoteAddress, getBondState(remoteAddress), bondState,
+ failureReason));
+ mRemoteAddressToBondState.put(remoteAddress, bondState);
+ mBluetoothBroadcastHandler.mBroadcastManager.sendBroadcast(
+ intent, android.Manifest.permission.BLUETOOTH);
+ }
+
+ public void onPairingRequest(String remoteAddress, int variant, int key) {
+ Intent intent =
+ newDeviceIntent(BluetoothDevice.ACTION_PAIRING_REQUEST, remoteAddress)
+ .putExtra(BluetoothDevice.EXTRA_PAIRING_VARIANT, variant)
+ .putExtra(BluetoothDevice.EXTRA_PAIRING_KEY, key);
+
+ LOGGER.d(
+ String.format(
+ "Address: %s, Bluetooth Pairing Request Intent: remote=%s, variant=%s, "
+ + "key=%s", address, remoteAddress, variant, key));
+ mBluetoothBroadcastHandler.mBroadcastManager.sendBroadcast(intent, permission.BLUETOOTH);
+ }
+
+ public PairingConfirmation getPairingConfirmation(String remoteAddress) {
+ PairingConfirmation confirmation = mRemoteAddressToPairingConfirmation.get(remoteAddress);
+ return confirmation == null ? PairingConfirmation.UNKNOWN : confirmation;
+ }
+
+ public void setPairingConfirmation(String remoteAddress, PairingConfirmation confirmation) {
+ mRemoteAddressToPairingConfirmation.put(remoteAddress, confirmation);
+ }
+
+ public void onFetchedUuids(String remoteAddress, ParcelUuid[] profileUuids) {
+ Intent intent =
+ newDeviceIntent(BluetoothDevice.ACTION_UUID, remoteAddress)
+ .putExtra(BluetoothDevice.EXTRA_UUID, profileUuids);
+
+ LOGGER.d(
+ String.format(
+ "Address: %s, Bluetooth Found UUIDs Intent: remoteAddress=%s, uuids=%s",
+ address, remoteAddress, Arrays.toString(profileUuids)));
+ mBluetoothBroadcastHandler.mBroadcastManager.sendBroadcast(
+ intent, android.Manifest.permission.BLUETOOTH);
+ }
+
+ private static int maxProfileState(int a, int b) {
+ // Prefer connected > connecting > disconnecting > disconnected.
+ switch (a) {
+ case BluetoothProfile.STATE_CONNECTED:
+ return a;
+ case BluetoothProfile.STATE_CONNECTING:
+ return b == BluetoothProfile.STATE_CONNECTED ? b : a;
+ case BluetoothProfile.STATE_DISCONNECTING:
+ return b == BluetoothProfile.STATE_CONNECTED
+ || b == BluetoothProfile.STATE_CONNECTING
+ ? b
+ : a;
+ case BluetoothProfile.STATE_DISCONNECTED:
+ default:
+ return b;
+ }
+ }
+
+ public int getAdapterConnectionState() {
+ int maxState = BluetoothProfile.STATE_DISCONNECTED;
+ for (int state : mProfileTypeToConnectionState.values()) {
+ maxState = maxProfileState(maxState, state);
+ }
+ return PROFILE_STATE_TO_ADAPTER_STATE.get(maxState);
+ }
+
+ public int getProfileConnectionState(int profileType) {
+ return mProfileTypeToConnectionState.containsKey(profileType)
+ ? mProfileTypeToConnectionState.get(profileType)
+ : BluetoothProfile.STATE_DISCONNECTED;
+ }
+
+ public void setProfileConnectionState(int profileType, int state, String remoteAddress) {
+ int previousAdapterState = getAdapterConnectionState();
+ mProfileTypeToConnectionState.put(profileType, state);
+ int adapterState = getAdapterConnectionState();
+ if (previousAdapterState != adapterState) {
+ Intent intent =
+ newDeviceIntent(BluetoothAdapter.ACTION_CONNECTION_STATE_CHANGED, remoteAddress)
+ .putExtra(BluetoothAdapter.EXTRA_PREVIOUS_CONNECTION_STATE,
+ previousAdapterState)
+ .putExtra(BluetoothAdapter.EXTRA_CONNECTION_STATE, adapterState);
+
+ LOGGER.d(
+ "Adapter Connection State Changed Intent: "
+ + previousAdapterState
+ + " -> "
+ + adapterState);
+ mBluetoothBroadcastHandler.mBroadcastManager.sendBroadcast(
+ intent, android.Manifest.permission.BLUETOOTH);
+ }
+ }
+
+ static class BluetoothBroadcastHandler implements AdapterDelegate.Callback,
+ RfcommDelegate.Callback {
+
+ private final BroadcastManager mBroadcastManager;
+
+ BluetoothBroadcastHandler(BroadcastManager broadcastManager) {
+ this.mBroadcastManager = broadcastManager;
+ }
+
+ @Override
+ public void onAdapterStateChange(State prevState, State newState) {
+ int prev = prevState.getValue();
+ int cur = newState.getValue();
+ LOGGER.d("Bluetooth State Change Intent: " + State.lookup(prev) + " -> " + State.lookup(
+ cur));
+ Intent intent = new Intent(BluetoothAdapter.ACTION_STATE_CHANGED);
+ intent.putExtra(BluetoothAdapter.EXTRA_PREVIOUS_STATE, prev);
+ intent.putExtra(BluetoothAdapter.EXTRA_STATE, cur);
+ mBroadcastManager.sendBroadcast(intent, android.Manifest.permission.BLUETOOTH);
+ }
+
+ @Override
+ public void onBleStateChange(State prevState, State newState) {
+ int prev = prevState.getValue();
+ int cur = newState.getValue();
+ LOGGER.d("BLE State Change Intent: " + State.lookup(prev) + " -> " + State.lookup(cur));
+ Intent intent = new Intent(BluetoothConstants.ACTION_BLE_STATE_CHANGED);
+ intent.putExtra(BluetoothAdapter.EXTRA_PREVIOUS_STATE, prev);
+ intent.putExtra(BluetoothAdapter.EXTRA_STATE, cur);
+ mBroadcastManager.sendBroadcast(intent, android.Manifest.permission.BLUETOOTH);
+ }
+
+ @Override
+ public void onConnectionStateChange(String remoteAddress, boolean isConnected) {
+ LOGGER.d("Bluetooth Connection State Change Intent, isConnected: " + isConnected);
+ Intent intent =
+ isConnected
+ ? newDeviceIntent(BluetoothDevice.ACTION_ACL_CONNECTED, remoteAddress)
+ : newDeviceIntent(BluetoothDevice.ACTION_ACL_DISCONNECTED,
+ remoteAddress);
+ mBroadcastManager.sendBroadcast(intent, android.Manifest.permission.BLUETOOTH);
+ }
+
+ @Override
+ public void onDiscoveryStarted() {
+ LOGGER.d("Bluetooth discovery started.");
+ Intent intent = new Intent(BluetoothAdapter.ACTION_DISCOVERY_STARTED);
+ mBroadcastManager.sendBroadcast(intent, android.Manifest.permission.BLUETOOTH);
+ }
+
+ @Override
+ public void onDiscoveryFinished() {
+ LOGGER.d("Bluetooth discovery finished.");
+ Intent intent = new Intent(BluetoothAdapter.ACTION_DISCOVERY_FINISHED);
+ mBroadcastManager.sendBroadcast(intent, android.Manifest.permission.BLUETOOTH);
+ }
+
+ @Override
+ public void onDeviceFound(String address, int bluetoothClass, String name) {
+ LOGGER.d("Bluetooth device found, address: " + address);
+ Intent intent =
+ newDeviceIntent(BluetoothDevice.ACTION_FOUND, address)
+ .putExtra(
+ BluetoothDevice.EXTRA_CLASS,
+ callConstructor(
+ BluetoothClass.class,
+ ClassParameter.from(int.class, bluetoothClass)))
+ .putExtra(BluetoothDevice.EXTRA_NAME, name);
+ // TODO(b/200231384): support rssi
+ // TODO(b/200231384): send broadcast with additional ACCESS_COARSE_LOCATION permission
+ // once broadcast permission is implemented.
+ mBroadcastManager.sendBroadcast(intent, android.Manifest.permission.BLUETOOTH);
+ }
+ }
+
+ private static Intent newDeviceIntent(String action, String address) {
+ return new Intent(action)
+ .putExtra(
+ BluetoothDevice.EXTRA_DEVICE,
+ BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address));
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/BluetoothConstants.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/BluetoothConstants.java
new file mode 100644
index 0000000..fa519da
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/BluetoothConstants.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.internal.bluetooth;
+
+/**
+ * A class to hold Bluetooth constants.
+ */
+public class BluetoothConstants {
+
+ /*** Bluetooth Adapter State ***/
+ // Must be identical to BluetoothAdapter hidden field STATE_BLE_TURNING_ON
+ public static final int STATE_BLE_TURNING_ON = 14;
+
+ // Must be identical to BluetoothAdapter hidden field STATE_BLE_ON
+ public static final int STATE_BLE_ON = 15;
+
+ // Must be identical to BluetoothAdapter hidden field STATE_BLE_TURNING_OFF
+ public static final int STATE_BLE_TURNING_OFF = 16;
+
+ // Must be identical to BluetoothAdapter hidden field ACTION_BLE_STATE_CHANGED
+ public static final String ACTION_BLE_STATE_CHANGED =
+ "android.bluetooth.adapter.action.BLE_STATE_CHANGED";
+
+ /*** Rfcomm Socket ***/
+ // Must be identical to BluetoothSocket field TYPE_RFCOMM.
+ // The field was package-private before M.
+ public static final int TYPE_RFCOMM = 1;
+
+ public static final int SOCKET_CLOSE = -10000;
+
+ // Android Bluetooth use -1 as port when creating server socket with uuid
+ public static final int SERVER_SOCKET_CHANNEL_AUTO_ASSIGN = -1;
+
+ // Android Bluetooth use -1 as port when creating socket with a uuid
+ public static final int SOCKET_CHANNEL_CONNECT_WITH_UUID = -1;
+
+ /*** BLE Advertise/Scan ***/
+ // Must be identical to AdvertiseCallback hidden field ADVERTISE_SUCCESS.
+ public static final int ADVERTISE_SUCCESS = 0;
+
+ // Must be identical to ScanRecord field DATA_TYPE_FLAGS.
+ public static final int DATA_TYPE_FLAGS = 0x01;
+
+ // Must be identical to ScanRecord field DATA_TYPE_SERVICE_UUIDS_128_BIT_COMPLETE.
+ public static final int DATA_TYPE_SERVICE_UUIDS_128_BIT_COMPLETE = 0x07;
+
+ // Must be identical to ScanRecord field DATA_TYPE_LOCAL_NAME_COMPLETE.
+ public static final int DATA_TYPE_LOCAL_NAME_COMPLETE = 0x09;
+
+ // Must be identical to ScanRecord field DATA_TYPE_TX_POWER_LEVEL.
+ public static final int DATA_TYPE_TX_POWER_LEVEL = 0x0A;
+
+ // Must be identical to ScanRecord field DATA_TYPE_SERVICE_DATA.
+ public static final int DATA_TYPE_SERVICE_DATA = 0x16;
+
+ // Must be identical to ScanRecord field DATA_TYPE_MANUFACTURER_SPECIFIC_DATA.
+ public static final int DATA_TYPE_MANUFACTURER_SPECIFIC_DATA = 0xFF;
+
+ /**
+ * @see #DATA_TYPE_FLAGS
+ */
+ public interface Flags {
+
+ byte LE_LIMITED_DISCOVERABLE_MODE = 1;
+ byte LE_GENERAL_DISCOVERABLE_MODE = 1 << 1;
+ byte BR_EDR_NOT_SUPPORTED = 1 << 2;
+ byte SIMULTANEOUS_LE_AND_BR_EDR_CONTROLLER = 1 << 3;
+ byte SIMULTANEOUS_LE_AND_BR_EDR_HOST = 1 << 4;
+ }
+
+ /**
+ * Observed that Android sets this for {@link #DATA_TYPE_FLAGS} when a packet is connectable (on
+ * a Nexus 6P running 7.1.2).
+ */
+ public static final byte FLAGS_IN_CONNECTABLE_PACKETS =
+ Flags.BR_EDR_NOT_SUPPORTED
+ | Flags.LE_GENERAL_DISCOVERABLE_MODE
+ | Flags.SIMULTANEOUS_LE_AND_BR_EDR_CONTROLLER
+ | Flags.SIMULTANEOUS_LE_AND_BR_EDR_HOST;
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/GattDelegate.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/GattDelegate.java
new file mode 100644
index 0000000..4618561
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/GattDelegate.java
@@ -0,0 +1,609 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.internal.bluetooth;
+
+import android.annotation.Nullable;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.IBluetoothGattCallback;
+import android.bluetooth.IBluetoothGattServerCallback;
+import android.bluetooth.le.AdvertiseData;
+import android.bluetooth.le.AdvertiseSettings;
+import android.bluetooth.le.ScanFilter;
+import android.bluetooth.le.ScanRecord;
+import android.bluetooth.le.ScanResult;
+import android.bluetooth.le.ScanSettings;
+import android.os.Build;
+import android.os.Build.VERSION;
+import android.os.ParcelUuid;
+import android.os.SystemClock;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.DeviceletImpl;
+import com.android.libraries.testing.deviceshadower.internal.utils.GattHelper;
+import com.android.libraries.testing.deviceshadower.internal.utils.Logger;
+
+import com.google.common.base.Preconditions;
+import com.google.common.primitives.Bytes;
+
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Delegate to operate gatt operations.
+ */
+public class GattDelegate {
+
+ private static final int DEFAULT_RSSI = -50;
+ private static final Logger LOGGER = Logger.create("GattDelegate");
+
+ // chipset properties
+ // use 2 as API 21 requires multi-advertisement support to use Le Advertising.
+ private final int mMaxAdvertiseInstances = 2;
+ private final AtomicBoolean mIsOffloadedFilteringSupported = new AtomicBoolean(false);
+ private final String mAddress;
+ private final AtomicInteger mCurrentClientIf = new AtomicInteger(0);
+ private final AtomicInteger mCurrentServerIf = new AtomicInteger(0);
+ private final AtomicBoolean mCurrentConnectionState = new AtomicBoolean(false);
+ private final Map<ParcelUuid, Service> mServices = new HashMap<>();
+ private final Map<Integer, IBluetoothGattCallback> mClientCallbacks;
+ private final Map<Integer, IBluetoothGattServerCallback> mServerCallbacks;
+ private final Map<Integer, Advertiser> mAdvertisers;
+ private final Map<Integer, Scanner> mScanners;
+ @Nullable
+ private Request mLastRequest;
+ private boolean mConnectable = true;
+
+ /**
+ * The parameters of a request, e.g. readCharacteristic(). Subclass for each request.
+ *
+ * @see #getLastRequest()
+ */
+ abstract static class Request {
+
+ final int mSrvcType;
+ final int mSrvcInstId;
+ final ParcelUuid mSrvcId;
+ final int mCharInstId;
+ final ParcelUuid mCharId;
+
+ Request(int srvcType, int srvcInstId, ParcelUuid srvcId, int charInstId,
+ ParcelUuid charId) {
+ this.mSrvcType = srvcType;
+ this.mSrvcInstId = srvcInstId;
+ this.mSrvcId = srvcId;
+ this.mCharInstId = charInstId;
+ this.mCharId = charId;
+ }
+ }
+
+ /**
+ * Corresponds to {@link android.bluetooth.IBluetoothGatt#readCharacteristic}.
+ */
+ static class ReadCharacteristicRequest extends Request {
+
+ ReadCharacteristicRequest(
+ int srvcType, int srvcInstId, ParcelUuid srvcId, int charInstId,
+ ParcelUuid charId) {
+ super(srvcType, srvcInstId, srvcId, charInstId, charId);
+ }
+ }
+
+ /**
+ * Corresponds to {@link android.bluetooth.IBluetoothGatt#readDescriptor}.
+ */
+ static class ReadDescriptorRequest extends Request {
+
+ final int mDescrInstId;
+ final ParcelUuid mDescrId;
+
+ ReadDescriptorRequest(
+ int srvcType,
+ int srvcInstId,
+ ParcelUuid srvcId,
+ int charInstId,
+ ParcelUuid charId,
+ int descrInstId,
+ ParcelUuid descrId) {
+ super(srvcType, srvcInstId, srvcId, charInstId, charId);
+ this.mDescrInstId = descrInstId;
+ this.mDescrId = descrId;
+ }
+ }
+
+ GattDelegate(String address) {
+ this(
+ address,
+ new HashMap<>(),
+ new HashMap<>(),
+ new ConcurrentHashMap<>(),
+ new ConcurrentHashMap<>());
+ }
+
+ @VisibleForTesting
+ GattDelegate(
+ String address,
+ Map<Integer, IBluetoothGattCallback> clientCallbacks,
+ Map<Integer, IBluetoothGattServerCallback> serverCallbacks,
+ Map<Integer, Advertiser> advertisers,
+ Map<Integer, Scanner> scanners) {
+ this.mAddress = address;
+ this.mClientCallbacks = clientCallbacks;
+ this.mServerCallbacks = serverCallbacks;
+ this.mAdvertisers = advertisers;
+ this.mScanners = scanners;
+ }
+
+ public void setRefuseConnections(boolean refuse) {
+ this.mConnectable = !refuse;
+ }
+
+ /**
+ * Used to maintain state between the request (e.g. readCharacteristic()) and sendResponse().
+ */
+ @Nullable
+ Request getLastRequest() {
+ return mLastRequest;
+ }
+
+ /**
+ * @see #getLastRequest()
+ */
+ void setLastRequest(@Nullable Request params) {
+ mLastRequest = params;
+ }
+
+ public int getClientIf() {
+ // TODO(b/200231384): support multiple client if.
+ return mCurrentClientIf.get();
+ }
+
+ public int getServerIf() {
+ // TODO(b/200231384): support multiple server if.
+ return mCurrentServerIf.get();
+ }
+
+ public IBluetoothGattServerCallback getServerCallback(int serverIf) {
+ return mServerCallbacks.get(serverIf);
+ }
+
+ public IBluetoothGattCallback getClientCallback(int clientIf) {
+ return mClientCallbacks.get(clientIf);
+ }
+
+ public int registerServer(IBluetoothGattServerCallback callback) {
+ mServerCallbacks.put(mCurrentServerIf.incrementAndGet(), callback);
+ return getServerIf();
+ }
+
+ public int registerClient(IBluetoothGattCallback callback) {
+ mClientCallbacks.put(mCurrentClientIf.incrementAndGet(), callback);
+ LOGGER.d(String.format("Client registered on %s, clientIf: %d", mAddress, getClientIf()));
+ return getClientIf();
+ }
+
+ public void unregisterClient(int clientIf) {
+ mClientCallbacks.remove(clientIf);
+ LOGGER.d(String.format("Client unregistered on %s, clientIf: %d", mAddress, clientIf));
+ }
+
+ public void unregisterServer(int serverIf) {
+ mServerCallbacks.remove(serverIf);
+ }
+
+ public int getMaxAdvertiseInstances() {
+ return mMaxAdvertiseInstances;
+ }
+
+ public boolean isOffloadedFilteringSupported() {
+ return mIsOffloadedFilteringSupported.get();
+ }
+
+ public boolean connect(String address) {
+ return mConnectable;
+ }
+
+ public boolean disconnect(String address) {
+ return true;
+ }
+
+ public void clientConnectionStateChange(
+ int state, int clientIf, boolean connected, String address) {
+ if (connected != mCurrentConnectionState.get()) {
+ mCurrentConnectionState.set(connected);
+ IBluetoothGattCallback callback = getClientCallback(clientIf);
+ if (callback != null) {
+ callback.onClientConnectionState(state, clientIf, connected, address);
+ }
+ }
+ }
+
+ public void serverConnectionStateChange(
+ int state, int serverIf, boolean connected, String address) {
+ if (connected != mCurrentConnectionState.get()) {
+ mCurrentConnectionState.set(connected);
+ IBluetoothGattServerCallback callback = getServerCallback(serverIf);
+ if (callback != null) {
+ callback.onServerConnectionState(state, serverIf, connected, address);
+ }
+ }
+ }
+
+ public Service addService(ParcelUuid uuid) {
+ Service srvc = new Service(uuid);
+ mServices.put(uuid, srvc);
+ return srvc;
+ }
+
+ public Collection<Service> getServices() {
+ return mServices.values();
+ }
+
+ public Service getService(ParcelUuid uuid) {
+ return mServices.get(uuid);
+ }
+
+ public void clientSetMtu(int clientIf, int mtu, String serverAddress) {
+ IBluetoothGattCallback callback = getClientCallback(clientIf);
+ if (callback != null && Build.VERSION.SDK_INT >= 21) {
+ callback.onConfigureMTU(serverAddress, mtu, BluetoothGatt.GATT_SUCCESS);
+ }
+ }
+
+ public void serverSetMtu(int serverIf, int mtu, String clientAddress) {
+ IBluetoothGattServerCallback callback = getServerCallback(serverIf);
+ if (callback != null && Build.VERSION.SDK_INT >= 22) {
+ callback.onMtuChanged(clientAddress, mtu);
+ }
+ }
+
+ public void startMultiAdvertising(
+ int appIf,
+ AdvertiseData advertiseData,
+ AdvertiseData scanResponse,
+ final AdvertiseSettings settings) {
+ LOGGER.d(String.format("startMultiAdvertising(%d) on %s", appIf, mAddress));
+ final Advertiser advertiser =
+ new Advertiser(
+ appIf,
+ mAddress,
+ DeviceShadowEnvironmentImpl.getLocalBlueletImpl().mName,
+ txPowerFromFlag(settings.getTxPowerLevel()),
+ advertiseData,
+ scanResponse,
+ settings);
+ mAdvertisers.put(appIf, advertiser);
+ final IBluetoothGattCallback callback = mClientCallbacks.get(appIf);
+ @SuppressWarnings("unused") // go/futurereturn-lsc
+ Future<?> possiblyIgnoredError =
+ DeviceShadowEnvironmentImpl.run(
+ mAddress,
+ () -> {
+ callback.onMultiAdvertiseCallback(
+ BluetoothConstants.ADVERTISE_SUCCESS, true /* isStart */,
+ settings);
+ return null;
+ });
+ }
+
+ /**
+ * Returns TxPower in dBm as measured at the source.
+ *
+ * <p>Note that this will vary by device and the values are only roughly accurate. The
+ * measurements were taken with a Nexus 6. Copied from the TxEddystone-UID app:
+ * {https://github.com/google/eddystone/blob/master/eddystone-uid/tools/txeddystone-uid/TxEddystone-UID/app/src/main/java/com/google/sample/txeddystone_uid/MainActivity.java}
+ */
+ private static byte txPowerFromFlag(int txPowerFlag) {
+ switch (txPowerFlag) {
+ case AdvertiseSettings.ADVERTISE_TX_POWER_HIGH:
+ return (byte) -16;
+ case AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM:
+ return (byte) -26;
+ case AdvertiseSettings.ADVERTISE_TX_POWER_LOW:
+ return (byte) -35;
+ case AdvertiseSettings.ADVERTISE_TX_POWER_ULTRA_LOW:
+ return (byte) -59;
+ default:
+ throw new IllegalStateException("Unknown TxPower level=" + txPowerFlag);
+ }
+ }
+
+ public void stopMultiAdvertising(int appIf) {
+ LOGGER.d(String.format("stopAdvertising(%d) on %s", appIf, mAddress));
+ Advertiser advertiser = mAdvertisers.get(appIf);
+ if (advertiser == null) {
+ LOGGER.d(String.format("Advertising already stopped on %s, clientIf: %d", mAddress,
+ appIf));
+ return;
+ }
+ mAdvertisers.remove(appIf);
+ final IBluetoothGattCallback callback = mClientCallbacks.get(appIf);
+ @SuppressWarnings("unused") // go/futurereturn-lsc
+ Future<?> possiblyIgnoredError =
+ DeviceShadowEnvironmentImpl.run(
+ mAddress,
+ () -> {
+ callback.onMultiAdvertiseCallback(
+ BluetoothConstants.ADVERTISE_SUCCESS, false /* isStart */,
+ null /* setting */);
+ return null;
+ });
+ }
+
+ public void startScan(final int appIf, ScanSettings settings, List<ScanFilter> filters) {
+ LOGGER.d(String.format("startScan(%d) on %s", appIf, mAddress));
+ if (filters == null) {
+ filters = new ArrayList<>();
+ }
+ final Scanner scanner = new Scanner(appIf, settings, filters);
+ mScanners.put(appIf, scanner);
+ @SuppressWarnings("unused") // go/futurereturn-lsc
+ Future<?> possiblyIgnoredError =
+ DeviceShadowEnvironmentImpl.run(
+ mAddress,
+ () -> {
+ try {
+ scan(scanner);
+ } catch (InterruptedException e) {
+ LOGGER.e(
+ String.format("Failed to scan on %s, clientIf: %d.",
+ mAddress, scanner.mClientIf),
+ e);
+ }
+ return null;
+ });
+ }
+
+ // TODO(b/200231384): support periodic scan with interval and scan window.
+ private void scan(Scanner scanner) throws InterruptedException {
+ // fetch existing advertisements
+ List<DeviceletImpl> devicelets = DeviceShadowEnvironmentImpl.getDeviceletImpls();
+ for (DeviceletImpl devicelet : devicelets) {
+ BlueletImpl bluelet = devicelet.blueletImpl();
+ if (bluelet.address.equals(mAddress)) {
+ continue;
+ }
+ for (Advertiser advertiser : bluelet.getGattDelegate().mAdvertisers.values()) {
+ if (VERSION.SDK_INT < 21) {
+ throw new UnsupportedOperationException(
+ String.format("API %d is not supported.", VERSION.SDK_INT));
+ }
+
+ byte[] advertiseData =
+ GattHelper.convertAdvertiseData(
+ advertiser.mAdvertiseData,
+ advertiser.mTxPowerLevel,
+ advertiser.mName,
+ advertiser.mSettings.isConnectable());
+ byte[] scanResponse =
+ GattHelper.convertAdvertiseData(
+ advertiser.mScanResponse,
+ advertiser.mTxPowerLevel,
+ advertiser.mName,
+ advertiser.mSettings.isConnectable());
+
+ ScanRecord scanRecord =
+ ReflectionHelpers.callStaticMethod(
+ ScanRecord.class,
+ "parseFromBytes",
+ ClassParameter.from(byte[].class,
+ Bytes.concat(advertiseData, scanResponse)));
+ ScanResult scanResult =
+ new ScanResult(
+ BluetoothAdapter.getDefaultAdapter()
+ .getRemoteDevice(advertiser.mAddress),
+ scanRecord,
+ DEFAULT_RSSI,
+ SystemClock.elapsedRealtimeNanos());
+
+ if (!matchFilters(scanResult, scanner.mFilters)) {
+ continue;
+ }
+
+ IBluetoothGattCallback callback = mClientCallbacks.get(scanner.mClientIf);
+ if (callback == null) {
+ LOGGER.e(
+ String.format("Callback is null on %s, clientIf: %d", mAddress,
+ scanner.mClientIf));
+ return;
+ }
+ callback.onScanResult(scanResult);
+ }
+ }
+ }
+
+ private boolean matchFilters(ScanResult scanResult, List<ScanFilter> filters) {
+ for (ScanFilter filter : filters) {
+ if (!filter.matches(scanResult)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ public void stopScan(int appIf) {
+ LOGGER.d(String.format("stopScan(%d) on %s", appIf, mAddress));
+ Scanner scanner = mScanners.get(appIf);
+ if (scanner == null) {
+ LOGGER.d(
+ String.format("Scanning already stopped on %s, clientIf: %d", mAddress, appIf));
+ return;
+ }
+ mScanners.remove(appIf);
+ }
+
+ static class Service {
+
+ private Map<ParcelUuid, Characteristic> mCharacteristics = new HashMap<>();
+ private ParcelUuid mUuid;
+
+ Service(ParcelUuid uuid) {
+ this.mUuid = uuid;
+ }
+
+ Characteristic getCharacteristic(ParcelUuid uuid) {
+ return mCharacteristics.get(uuid);
+ }
+
+ Characteristic addCharacteristic(ParcelUuid uuid, int properties, int permissions) {
+ Characteristic ch = new Characteristic(uuid, properties, permissions);
+ mCharacteristics.put(uuid, ch);
+ return ch;
+ }
+
+ Collection<Characteristic> getCharacteristics() {
+ return mCharacteristics.values();
+ }
+
+ ParcelUuid getUuid() {
+ return this.mUuid;
+ }
+ }
+
+ static class Characteristic {
+
+ private int mProperties;
+ private ParcelUuid mUuid;
+ private Map<ParcelUuid, Descriptor> mDescriptors = new HashMap<>();
+ private Set<String> mNotifyClients = new HashSet<>();
+ private byte[] mValue;
+
+ Characteristic(ParcelUuid uuid, int properties, int permissions) {
+ this.mProperties = properties;
+ this.mUuid = uuid;
+ }
+
+ Descriptor getDescriptor(ParcelUuid uuid) {
+ return mDescriptors.get(uuid);
+ }
+
+ Descriptor addDescriptor(ParcelUuid uuid, int permissions) {
+ Descriptor desc = new Descriptor(uuid, permissions);
+ mDescriptors.put(uuid, desc);
+ return desc;
+ }
+
+ Collection<Descriptor> getDescriptors() {
+ return mDescriptors.values();
+ }
+
+ void setValue(byte[] value) {
+ this.mValue = value;
+ }
+
+ byte[] getValue() {
+ return mValue;
+ }
+
+ ParcelUuid getUuid() {
+ return mUuid;
+ }
+
+ int getProperties() {
+ return mProperties;
+ }
+
+ void registerNotification(String client, int clientIf) {
+ mNotifyClients.add(client);
+ }
+
+ Set<String> getNotifyClients() {
+ return mNotifyClients;
+ }
+ }
+
+ static class Descriptor {
+
+ int mPermissions;
+ ParcelUuid mUuid;
+ byte[] mValue;
+
+ Descriptor(ParcelUuid uuid, int permissions) {
+ this.mUuid = uuid;
+ this.mPermissions = permissions;
+ }
+
+ void setValue(byte[] value) {
+ this.mValue = value;
+ }
+
+ byte[] getValue() {
+ return mValue;
+ }
+
+ ParcelUuid getUuid() {
+ return mUuid;
+ }
+ }
+
+ @VisibleForTesting
+ static class Advertiser {
+
+ final int mClientIf;
+ final String mAddress;
+ final String mName;
+ final int mTxPowerLevel;
+ final AdvertiseData mAdvertiseData;
+ @Nullable
+ final AdvertiseData mScanResponse;
+ final AdvertiseSettings mSettings;
+
+ Advertiser(
+ int clientIf,
+ String address,
+ String name,
+ int txPowerLevel,
+ AdvertiseData advertiseData,
+ AdvertiseData scanResponse,
+ AdvertiseSettings settings) {
+ this.mClientIf = clientIf;
+ this.mAddress = Preconditions.checkNotNull(address);
+ this.mName = name;
+ this.mTxPowerLevel = txPowerLevel;
+ this.mAdvertiseData = Preconditions.checkNotNull(advertiseData);
+ this.mScanResponse = scanResponse;
+ this.mSettings = Preconditions.checkNotNull(settings);
+ }
+ }
+
+ @VisibleForTesting
+ static class Scanner {
+
+ final int mClientIf;
+ final ScanSettings mSettings;
+ final List<ScanFilter> mFilters;
+
+ Scanner(int clientIf, ScanSettings settings, List<ScanFilter> filters) {
+ this.mClientIf = clientIf;
+ this.mSettings = Preconditions.checkNotNull(settings);
+ this.mFilters = Preconditions.checkNotNull(filters);
+ }
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/IBluetoothGattImpl.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/IBluetoothGattImpl.java
new file mode 100644
index 0000000..0ac287d
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/IBluetoothGattImpl.java
@@ -0,0 +1,707 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.internal.bluetooth;
+
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.IBluetoothGatt;
+import android.bluetooth.IBluetoothGattCallback;
+import android.bluetooth.IBluetoothGattServerCallback;
+import android.bluetooth.le.AdvertiseData;
+import android.bluetooth.le.AdvertiseSettings;
+import android.bluetooth.le.ScanFilter;
+import android.bluetooth.le.ScanSettings;
+import android.os.ParcelUuid;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.GattDelegate.ReadCharacteristicRequest;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.GattDelegate.ReadDescriptorRequest;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.GattDelegate.Request;
+import com.android.libraries.testing.deviceshadower.internal.common.NamedRunnable;
+import com.android.libraries.testing.deviceshadower.internal.utils.Logger;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Implementation of IBluetoothGatt.
+ */
+public class IBluetoothGattImpl implements IBluetoothGatt {
+
+ private static final Logger LOGGER = Logger.create("IBluetoothGattImpl");
+ private GattDelegate.Service mCurrentService;
+ private GattDelegate.Characteristic mCurrentCharacteristic;
+
+ @Override
+ public void startScan(
+ int appIf,
+ boolean isServer,
+ ScanSettings settings,
+ List<ScanFilter> filters,
+ List<?> scanStorages,
+ String callingPackage) {
+ localGattDelegate().startScan(appIf, settings, filters);
+ }
+
+ @Override
+ public void startScan(
+ int appIf,
+ boolean isServer,
+ ScanSettings settings,
+ List<ScanFilter> filters,
+ List<?> scanStorages) {
+ startScan(appIf, isServer, settings, filters, scanStorages, "" /* callingPackage */);
+ }
+
+ @Override
+ public void stopScan(int appIf, boolean isServer) {
+ localGattDelegate().stopScan(appIf);
+ }
+
+ @Override
+ public void startMultiAdvertising(
+ int appIf,
+ AdvertiseData advertiseData,
+ AdvertiseData scanResponse,
+ AdvertiseSettings settings) {
+ localGattDelegate().startMultiAdvertising(appIf, advertiseData, scanResponse, settings);
+ }
+
+ @Override
+ public void stopMultiAdvertising(int appIf) {
+ localGattDelegate().stopMultiAdvertising(appIf);
+ }
+
+ @Override
+ @SuppressWarnings("FutureReturnValueIgnored")
+ public void registerClient(ParcelUuid appId, final IBluetoothGattCallback callback) {
+ final int clientIf = localGattDelegate().registerClient(callback);
+ NamedRunnable onClientRegistered =
+ NamedRunnable.create(
+ "ClientGatt.onClientRegistered=" + clientIf,
+ () -> {
+ callback.onClientRegistered(BluetoothGatt.GATT_SUCCESS, clientIf);
+ });
+
+ DeviceShadowEnvironmentImpl.runOnService(localAddress(), onClientRegistered);
+ }
+
+ @Override
+ public void unregisterClient(int clientIf) {
+ localGattDelegate().unregisterClient(clientIf);
+ }
+
+ @Override
+ @SuppressWarnings("FutureReturnValueIgnored")
+ public void clientConnect(
+ final int clientIf, final String serverAddress, boolean isDirect, int transport) {
+ // TODO(b/200231384): implement auto connect.
+ String clientAddress = localAddress();
+ int serverIf = remoteGattDelegate(serverAddress).getServerIf();
+ boolean success = remoteGattDelegate(serverAddress).connect(clientAddress);
+ if (!success) {
+ LOGGER.i(String.format("clientConnect failed: %s connect %s", serverAddress,
+ clientAddress));
+ return;
+ }
+
+ DeviceShadowEnvironmentImpl.runOnService(
+ clientAddress,
+ newClientConnectionStateChangeRunnable(clientIf, true, serverAddress));
+
+ DeviceShadowEnvironmentImpl.runOnService(
+ serverAddress,
+ newServerConnectionStateChangeRunnable(serverIf, true, clientAddress));
+ }
+
+ @Override
+ @SuppressWarnings("FutureReturnValueIgnored")
+ public void clientDisconnect(final int clientIf, final String serverAddress) {
+ final String clientAddress = localAddress();
+ remoteGattDelegate(serverAddress).disconnect(clientAddress);
+ int serverIf = remoteGattDelegate(serverAddress).getServerIf();
+
+
+ DeviceShadowEnvironmentImpl.runOnService(
+ clientAddress,
+ newClientConnectionStateChangeRunnable(clientIf, false, serverAddress));
+
+ DeviceShadowEnvironmentImpl.runOnService(
+ serverAddress,
+ newServerConnectionStateChangeRunnable(serverIf, false, clientAddress));
+ }
+
+ @Override
+ public void discoverServices(int clientIf, String serverAddress) {
+ final IBluetoothGattCallback callback = localGattDelegate().getClientCallback(clientIf);
+ if (callback == null) {
+ return;
+ }
+ for (GattDelegate.Service service : remoteGattDelegate(serverAddress).getServices()) {
+ callback.onGetService(serverAddress, 0 /*srvcType*/, 0 /*srvcInstId*/,
+ service.getUuid());
+
+ for (GattDelegate.Characteristic characteristic : service.getCharacteristics()) {
+ callback.onGetCharacteristic(
+ serverAddress,
+ 0 /*srvcType*/,
+ 0 /*srvcInstId*/,
+ service.getUuid(),
+ 0 /*charInstId*/,
+ characteristic.getUuid(),
+ characteristic.getProperties());
+ for (GattDelegate.Descriptor descriptor : characteristic.getDescriptors()) {
+ callback.onGetDescriptor(
+ serverAddress,
+ 0 /*srvcType*/,
+ 0 /*srvcInstId*/,
+ service.getUuid(),
+ 0 /*charInstId*/,
+ characteristic.getUuid(),
+ 0 /*descrInstId*/,
+ descriptor.getUuid());
+ }
+ }
+ }
+
+ callback.onSearchComplete(serverAddress, BluetoothGatt.GATT_SUCCESS);
+ }
+
+ @Override
+ @SuppressWarnings("FutureReturnValueIgnored")
+ public void readCharacteristic(
+ final int clientIf,
+ final String serverAddress,
+ final int srvcType,
+ final int srvcInstId,
+ final ParcelUuid srvcId,
+ final int charInstId,
+ final ParcelUuid charId,
+ final int authReq) {
+ // TODO(b/200231384): implement authReq.
+ final String clientAddress = localAddress();
+ localGattDelegate()
+ .setLastRequest(
+ new ReadCharacteristicRequest(srvcType, srvcInstId, srvcId, charInstId,
+ charId));
+
+ NamedRunnable serverOnCharacteristicReadRequest =
+ NamedRunnable.create(
+ "ServerGatt.onCharacteristicReadRequest",
+ () -> {
+ int serverIf = localGattDelegate().getServerIf();
+ IBluetoothGattServerCallback callback =
+ localGattDelegate().getServerCallback(serverIf);
+ if (callback != null) {
+ callback.onCharacteristicReadRequest(
+ clientAddress,
+ 0 /*transId*/,
+ 0 /*offset*/,
+ false /*isLong*/,
+ 0 /*srvcType*/,
+ srvcInstId,
+ srvcId,
+ charInstId,
+ charId);
+ }
+ });
+
+ DeviceShadowEnvironmentImpl.runOnService(serverAddress, serverOnCharacteristicReadRequest);
+ }
+
+ @Override
+ @SuppressWarnings("FutureReturnValueIgnored")
+ public void writeCharacteristic(
+ final int clientIf,
+ final String serverAddress,
+ final int srvcType,
+ final int srvcInstId,
+ final ParcelUuid srvcId,
+ final int charInstId,
+ final ParcelUuid charId,
+ final int writeType,
+ final int authReq,
+ final byte[] value) {
+ // TODO(b/200231384): implement write with response needed.
+ remoteGattDelegate(serverAddress).getService(srvcId).getCharacteristic(charId)
+ .setValue(value);
+ final String clientAddress = localAddress();
+
+ NamedRunnable clientOnCharacteristicWrite =
+ NamedRunnable.create(
+ "ClientGatt.onCharacteristicWrite",
+ () -> {
+ IBluetoothGattCallback callback = localGattDelegate().getClientCallback(
+ clientIf);
+ if (callback != null) {
+ callback.onCharacteristicWrite(
+ serverAddress,
+ BluetoothGatt.GATT_SUCCESS,
+ 0 /*srvcType*/,
+ srvcInstId,
+ srvcId,
+ charInstId,
+ charId);
+ }
+ });
+
+ NamedRunnable onCharacteristicWriteRequest =
+ NamedRunnable.create(
+ "ServerGatt.onCharacteristicWriteRequest",
+ () -> {
+ int serverIf = localGattDelegate().getServerIf();
+ IBluetoothGattServerCallback callback =
+ localGattDelegate().getServerCallback(serverIf);
+ if (callback != null) {
+ callback.onCharacteristicWriteRequest(
+ clientAddress,
+ 0 /*transId*/,
+ 0 /*offset*/,
+ value.length,
+ false /*isPrep*/,
+ false /*needRsp*/,
+ 0 /*srvcType*/,
+ srvcInstId,
+ srvcId,
+ charInstId,
+ charId,
+ value);
+ }
+ });
+
+ DeviceShadowEnvironmentImpl.runOnService(clientAddress, clientOnCharacteristicWrite);
+
+ DeviceShadowEnvironmentImpl.runOnService(serverAddress, onCharacteristicWriteRequest);
+ }
+
+ @Override
+ @SuppressWarnings("FutureReturnValueIgnored")
+ public void readDescriptor(
+ final int clientIf,
+ final String serverAddress,
+ final int srvcType,
+ final int srvcInstId,
+ final ParcelUuid srvcId,
+ final int charInstId,
+ final ParcelUuid charId,
+ final int descrInstId,
+ final ParcelUuid descrId,
+ final int authReq) {
+ final String clientAddress = localAddress();
+ localGattDelegate()
+ .setLastRequest(
+ new ReadDescriptorRequest(
+ srvcType, srvcInstId, srvcId, charInstId, charId, descrInstId,
+ descrId));
+
+ NamedRunnable serverOnDescriptorReadRequest =
+ NamedRunnable.create(
+ "ServerGatt.onDescriptorReadRequest",
+ () -> {
+ int serverIf = localGattDelegate().getServerIf();
+ IBluetoothGattServerCallback callback =
+ localGattDelegate().getServerCallback(serverIf);
+ if (callback != null) {
+ callback.onDescriptorReadRequest(
+ clientAddress,
+ 0 /*transId*/,
+ 0 /*offset*/,
+ false /*isLong*/,
+ 0 /*srvcType*/,
+ srvcInstId,
+ srvcId,
+ charInstId,
+ charId,
+ descrId);
+ }
+ });
+
+ DeviceShadowEnvironmentImpl.runOnService(serverAddress, serverOnDescriptorReadRequest);
+ }
+
+ @Override
+ @SuppressWarnings("FutureReturnValueIgnored")
+ public void writeDescriptor(
+ final int clientIf,
+ final String serverAddress,
+ final int srvcType,
+ final int srvcInstId,
+ final ParcelUuid srvcId,
+ final int charInstId,
+ final ParcelUuid charId,
+ final int descrInstId,
+ final ParcelUuid descrId,
+ final int writeType,
+ final int authReq,
+ final byte[] value) {
+ // TODO(b/200231384): implement write with response needed.
+ remoteGattDelegate(serverAddress)
+ .getService(srvcId)
+ .getCharacteristic(charId)
+ .getDescriptor(descrId)
+ .setValue(value);
+ final String clientAddress = localAddress();
+
+ NamedRunnable serverOnDescriptorWriteRequest =
+ NamedRunnable.create(
+ "ServerGatt.onDescriptorWriteRequest",
+ () -> {
+ int serverIf = localGattDelegate().getServerIf();
+ IBluetoothGattServerCallback callback =
+ localGattDelegate().getServerCallback(serverIf);
+ if (callback != null) {
+ callback.onDescriptorWriteRequest(
+ clientAddress,
+ 0 /*transId*/,
+ 0 /*offset*/,
+ value.length,
+ false /*isPrep*/,
+ false /*needRsp*/,
+ 0 /*srvcType*/,
+ srvcInstId,
+ srvcId,
+ charInstId,
+ charId,
+ descrId,
+ value);
+ }
+ });
+
+ NamedRunnable clientOnDescriptorWrite =
+ NamedRunnable.create(
+ "ClientGatt.onDescriptorWrite",
+ () -> {
+ IBluetoothGattCallback callback = localGattDelegate().getClientCallback(
+ clientIf);
+ if (callback != null) {
+ callback.onDescriptorWrite(
+ serverAddress,
+ BluetoothGatt.GATT_SUCCESS,
+ 0 /*srvcType*/,
+ srvcInstId,
+ srvcId,
+ charInstId,
+ charId,
+ descrInstId,
+ descrId);
+ }
+ });
+
+ DeviceShadowEnvironmentImpl.runOnService(serverAddress, serverOnDescriptorWriteRequest);
+
+ DeviceShadowEnvironmentImpl.runOnService(clientAddress, clientOnDescriptorWrite);
+ }
+
+ @Override
+ public void registerForNotification(
+ int clientIf,
+ String remoteAddress,
+ int srvcType,
+ int srvcInstId,
+ ParcelUuid srvcId,
+ int charInstId,
+ ParcelUuid charId,
+ boolean enable) {
+ remoteGattDelegate(remoteAddress)
+ .getService(srvcId)
+ .getCharacteristic(charId)
+ .registerNotification(localAddress(), clientIf);
+ }
+
+ @Override
+ @SuppressWarnings("FutureReturnValueIgnored")
+ public void registerServer(ParcelUuid appId, final IBluetoothGattServerCallback callback) {
+ // TODO(b/200231384): support multiple serverIf.
+ final int serverIf = localGattDelegate().registerServer(callback);
+ NamedRunnable serverOnRegistered =
+ NamedRunnable.create(
+ "ServerGatt.onServerRegistered",
+ () -> {
+ callback.onServerRegistered(BluetoothGatt.GATT_SUCCESS, serverIf);
+ });
+
+ DeviceShadowEnvironmentImpl.runOnService(localAddress(), serverOnRegistered);
+ }
+
+ @Override
+ public void unregisterServer(int serverIf) {
+ localGattDelegate().unregisterServer(serverIf);
+ }
+
+ @Override
+ @SuppressWarnings("FutureReturnValueIgnored")
+ public void serverConnect(
+ final int serverIf, final String clientAddress, boolean isDirect, int transport) {
+ // TODO(b/200231384): implement isDirect and transport.
+ boolean success = localGattDelegate().connect(clientAddress);
+ final String serverAddress = localAddress();
+ if (!success) {
+ return;
+ }
+ int clientIf = remoteGattDelegate(clientAddress).getClientIf();
+
+ DeviceShadowEnvironmentImpl.runOnService(
+ serverAddress,
+ newServerConnectionStateChangeRunnable(serverIf, true, clientAddress));
+
+ DeviceShadowEnvironmentImpl.runOnService(
+ clientAddress,
+ newClientConnectionStateChangeRunnable(clientIf, true, serverAddress));
+ }
+
+ @Override
+ @SuppressWarnings("FutureReturnValueIgnored")
+ public void serverDisconnect(final int serverIf, final String clientAddress) {
+ localGattDelegate().disconnect(clientAddress);
+ String serverAddress = localAddress();
+ int clientIf = remoteGattDelegate(clientAddress).getClientIf();
+
+ DeviceShadowEnvironmentImpl.runOnService(
+ serverAddress,
+ newServerConnectionStateChangeRunnable(serverIf, false, clientAddress));
+
+ DeviceShadowEnvironmentImpl.runOnService(
+ clientAddress,
+ newClientConnectionStateChangeRunnable(clientIf, false, serverAddress));
+ }
+
+ @Override
+ public void beginServiceDeclaration(
+ int serverIf,
+ int srvcType,
+ int srvcInstId,
+ int minHandles,
+ ParcelUuid srvcId,
+ boolean advertisePreferred) {
+ // TODO(b/200231384): support different service type, instanceId, advertisePreferred.
+ mCurrentService = localGattDelegate().addService(srvcId);
+ }
+
+ @Override
+ public void addIncludedService(int serverIf, int srvcType, int srvcInstId, ParcelUuid srvcId) {
+ // TODO(b/200231384): implement this.
+ }
+
+ @Override
+ public void addCharacteristic(int serverIf, ParcelUuid charId, int properties,
+ int permissions) {
+ mCurrentCharacteristic = mCurrentService.addCharacteristic(charId, properties, permissions);
+ }
+
+ @Override
+ public void addDescriptor(int serverIf, ParcelUuid descId, int permissions) {
+ mCurrentCharacteristic.addDescriptor(descId, permissions);
+ }
+
+ @Override
+ public void endServiceDeclaration(int serverIf) {
+ // TODO(b/200231384): choose correct srvc type and inst id.
+ IBluetoothGattServerCallback callback = localGattDelegate().getServerCallback(serverIf);
+ if (callback != null) {
+ callback.onServiceAdded(
+ BluetoothGatt.GATT_SUCCESS, 0 /*srvcType*/, 0 /*srvcInstId*/,
+ mCurrentService.getUuid());
+ }
+ mCurrentService = null;
+ }
+
+ @Override
+ public void removeService(int serverIf, int srvcType, int srvcInstId, ParcelUuid srvcId) {
+ // TODO(b/200231384): implement remove service.
+ // localGattDelegate().removeService(srvcId);
+ }
+
+ @Override
+ public void clearServices(int serverIf) {
+ // TODO(b/200231384): support multiple serverIf.
+ // localGattDelegate().clearService();
+ }
+
+ @Override
+ @SuppressWarnings("FutureReturnValueIgnored")
+ public void sendResponse(
+ int serverIf, String clientAddress, int requestId, int status, int offset,
+ byte[] value) {
+ // TODO(b/200231384): implement more operations.
+ String serverAddress = localAddress();
+
+ DeviceShadowEnvironmentImpl.runOnService(
+ clientAddress,
+ NamedRunnable.create(
+ "ClientGatt.receiveResponse",
+ () -> {
+ IBluetoothGattCallback callback =
+ localGattDelegate().getClientCallback(
+ localGattDelegate().getClientIf());
+ if (callback != null) {
+ Request request = localGattDelegate().getLastRequest();
+ localGattDelegate().setLastRequest(null);
+ if (request != null) {
+ if (request instanceof ReadCharacteristicRequest) {
+ callback.onCharacteristicRead(
+ serverAddress,
+ status,
+ request.mSrvcType,
+ request.mSrvcInstId,
+ request.mSrvcId,
+ request.mCharInstId,
+ request.mCharId,
+ value);
+ } else if (request instanceof ReadDescriptorRequest) {
+ ReadDescriptorRequest readDescriptorRequest =
+ (ReadDescriptorRequest) request;
+ callback.onDescriptorRead(
+ serverAddress,
+ status,
+ readDescriptorRequest.mSrvcType,
+ readDescriptorRequest.mSrvcInstId,
+ readDescriptorRequest.mSrvcId,
+ readDescriptorRequest.mCharInstId,
+ readDescriptorRequest.mCharId,
+ readDescriptorRequest.mDescrInstId,
+ readDescriptorRequest.mDescrId,
+ value);
+ }
+ }
+ }
+ }));
+ }
+
+ @Override
+ @SuppressWarnings("FutureReturnValueIgnored")
+ public void sendNotification(
+ final int serverIf,
+ final String address,
+ final int srvcType,
+ final int srvcInstId,
+ final ParcelUuid srvcId,
+ final int charInstId,
+ final ParcelUuid charId,
+ boolean confirm,
+ final byte[] value) {
+ GattDelegate.Characteristic characteristic =
+ localGattDelegate().getService(srvcId).getCharacteristic(charId);
+ characteristic.setValue(value);
+ final String serverAddress = localAddress();
+ for (final String clientAddress : characteristic.getNotifyClients()) {
+ NamedRunnable clientOnNotify =
+ NamedRunnable.create(
+ "ClientGatt.onNotify",
+ () -> {
+ int clientIf = localGattDelegate().getClientIf();
+ IBluetoothGattCallback callback =
+ localGattDelegate().getClientCallback(clientIf);
+ if (callback != null) {
+ callback.onNotify(
+ serverAddress, srvcType, srvcInstId, srvcId, charInstId,
+ charId, value);
+ }
+ });
+
+ DeviceShadowEnvironmentImpl.runOnService(clientAddress, clientOnNotify);
+ }
+
+ NamedRunnable serverOnNotificationSent =
+ NamedRunnable.create(
+ "ServerGatt.onNotificationSent",
+ () -> {
+ IBluetoothGattServerCallback callback =
+ localGattDelegate().getServerCallback(serverIf);
+ if (callback != null) {
+ callback.onNotificationSent(address, BluetoothGatt.GATT_SUCCESS);
+ }
+ });
+
+ DeviceShadowEnvironmentImpl.runOnService(serverAddress, serverOnNotificationSent);
+ }
+
+ @Override
+ @SuppressWarnings("FutureReturnValueIgnored")
+ public void configureMTU(int clientIf, String address, int mtu) {
+ final String clientAddress = localAddress();
+
+ NamedRunnable clientSetMtu =
+ NamedRunnable.create(
+ "ClientGatt.setMtu",
+ () -> {
+ localGattDelegate().clientSetMtu(clientIf, mtu, address);
+ });
+ NamedRunnable serverSetMtu =
+ NamedRunnable.create(
+ "ServerGatt.setMtu",
+ () -> {
+ int serverIf = localGattDelegate().getServerIf();
+ localGattDelegate().serverSetMtu(serverIf, mtu, clientAddress);
+ });
+
+ DeviceShadowEnvironmentImpl.runOnService(clientAddress, clientSetMtu);
+
+ DeviceShadowEnvironmentImpl.runOnService(address, serverSetMtu);
+ }
+
+ @Override
+ public void connectionParameterUpdate(int clientIf, String address, int connectionPriority) {
+ // TODO(b/200231384): Implement.
+ }
+
+ @Override
+ public void disconnectAll() {
+ }
+
+ @Override
+ public List<BluetoothDevice> getDevicesMatchingConnectionStates(int[] states) {
+ return new ArrayList<>();
+ }
+
+ @VisibleForTesting
+ static GattDelegate remoteGattDelegate(String address) {
+ return DeviceShadowEnvironmentImpl.getBlueletImpl(address).getGattDelegate();
+ }
+
+ private static GattDelegate localGattDelegate() {
+ return DeviceShadowEnvironmentImpl.getLocalBlueletImpl().getGattDelegate();
+ }
+
+ private static String localAddress() {
+ return DeviceShadowEnvironmentImpl.getLocalBlueletImpl().address;
+ }
+
+ private static NamedRunnable newClientConnectionStateChangeRunnable(
+ final int clientIf, final boolean isConnected, final String serverAddress) {
+ return NamedRunnable.create(
+ "ClientGatt.clientConnectionStateChange",
+ () -> {
+ localGattDelegate()
+ .clientConnectionStateChange(
+ BluetoothGatt.GATT_SUCCESS, clientIf, isConnected,
+ serverAddress);
+ });
+ }
+
+ private static NamedRunnable newServerConnectionStateChangeRunnable(
+ final int serverIf, final boolean isConnected, final String clientAddress) {
+ return NamedRunnable.create(
+ "ServerGatt.serverConnectionStateChange",
+ () -> {
+ localGattDelegate()
+ .serverConnectionStateChange(
+ BluetoothGatt.GATT_SUCCESS, serverIf, isConnected,
+ clientAddress);
+ });
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/IBluetoothImpl.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/IBluetoothImpl.java
new file mode 100644
index 0000000..ccf0ac3
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/IBluetoothImpl.java
@@ -0,0 +1,428 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.internal.bluetooth;
+
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.IBluetooth;
+import android.bluetooth.OobData;
+import android.content.AttributionSource;
+import android.os.ParcelFileDescriptor;
+import android.os.ParcelUuid;
+
+import com.android.libraries.testing.deviceshadower.Bluelet.CreateBondOutcome;
+import com.android.libraries.testing.deviceshadower.Bluelet.FetchUuidsTiming;
+import com.android.libraries.testing.deviceshadower.Bluelet.IoCapabilities;
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.AdapterDelegate.State;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.BlueletImpl.PairingConfirmation;
+import com.android.libraries.testing.deviceshadower.internal.utils.Logger;
+
+import com.google.common.base.Preconditions;
+
+import java.util.Random;
+
+/**
+ * Implementation of IBluetooth interface.
+ */
+public class IBluetoothImpl implements IBluetooth {
+
+ private static final Logger LOGGER = Logger.create("BlueletImpl");
+
+ private enum PairingVariant {
+ JUST_WORKS,
+ /**
+ * AKA Passkey Confirmation.
+ */
+ NUMERIC_COMPARISON,
+ PASSKEY_INPUT,
+ CONSENT
+ }
+
+ /**
+ * User will be prompted to accept or deny the incoming pairing request.
+ */
+ private static final int PAIRING_VARIANT_CONSENT = 3;
+
+ /**
+ * User will be prompted to enter the passkey displayed on remote device. This is used for
+ * Bluetooth 2.1 pairing.
+ */
+ private static final int PAIRING_VARIANT_DISPLAY_PASSKEY = 4;
+
+ public IBluetoothImpl() {
+ }
+
+ @Override
+ public String getAddress() {
+ return localBlueletImpl().address;
+ }
+
+ @Override
+ public String getName() {
+ return localBlueletImpl().mName;
+ }
+
+ @Override
+ public boolean setName(String name) {
+ localBlueletImpl().mName = name;
+ return true;
+ }
+
+ @Override
+ public int getRemoteClass(BluetoothDevice device) {
+ return remoteBlueletImpl(device.getAddress()).getAdapterDelegate().getBluetoothClass();
+ }
+
+ @Override
+ public String getRemoteName(BluetoothDevice device) {
+ return remoteBlueletImpl(device.getAddress()).mName;
+ }
+
+ @Override
+ public int getRemoteType(BluetoothDevice device, AttributionSource attributionSource) {
+ return BluetoothDevice.DEVICE_TYPE_LE;
+ }
+
+ @Override
+ public ParcelUuid[] getRemoteUuids(BluetoothDevice device) {
+ return remoteBlueletImpl(device.getAddress()).mProfileUuids;
+ }
+
+ @Override
+ public boolean fetchRemoteUuids(BluetoothDevice device) {
+ localBlueletImpl().onFetchedUuids(device.getAddress(), getRemoteUuids(device));
+ return true;
+ }
+
+ @Override
+ public int getBondState(BluetoothDevice device, AttributionSource attributionSource) {
+ return localBlueletImpl().getBondState(device.getAddress());
+ }
+
+ @Override
+ public boolean createBond(BluetoothDevice device, int transport, OobData remoteP192Data,
+ OobData remoteP256Data, AttributionSource attributionSource) {
+ setBondState(device.getAddress(), BluetoothDevice.BOND_BONDING, BlueletImpl.REASON_SUCCESS);
+
+ BlueletImpl remoteBluelet = remoteBlueletImpl(device.getAddress());
+ BlueletImpl localBluelet = localBlueletImpl();
+
+ // Like the real Bluetooth stack, choose a pairing variant based on IO Capabilities.
+ // https://blog.bluetooth.com/bluetooth-pairing-part-2-key-generation-methods
+ PairingVariant variant = PairingVariant.JUST_WORKS;
+ if (localBluelet.getIoCapabilities() == IoCapabilities.DISPLAY_YES_NO) {
+ if (remoteBluelet.getIoCapabilities() == IoCapabilities.DISPLAY_YES_NO) {
+ variant = PairingVariant.NUMERIC_COMPARISON;
+ } else if (remoteBluelet.getIoCapabilities() == IoCapabilities.KEYBOARD_ONLY) {
+ variant = PairingVariant.PASSKEY_INPUT;
+ } else if (remoteBluelet.getIoCapabilities() == IoCapabilities.NO_INPUT_NO_OUTPUT
+ && localBluelet.getEnableCVE20192225()) {
+ // After CVE-2019-2225, Bluetooth decides to ask consent instead of JustWorks.
+ variant = PairingVariant.CONSENT;
+ }
+ }
+
+ // Bonding doesn't complete until the passkey is confirmed on both devices. The passkey is a
+ // positive 6-digit integer, generated by the Bluetooth stack.
+ int passkey = new Random().nextInt(999999) + 1;
+ switch (variant) {
+ case NUMERIC_COMPARISON:
+ localBluelet.onPairingRequest(
+ remoteBluelet.address, BluetoothDevice.PAIRING_VARIANT_PASSKEY_CONFIRMATION,
+ passkey);
+ remoteBluelet.onPairingRequest(
+ localBluelet.address, BluetoothDevice.PAIRING_VARIANT_PASSKEY_CONFIRMATION,
+ passkey);
+ break;
+ case JUST_WORKS:
+ // Bonding completes immediately, with no PAIRING_REQUEST broadcast.
+ finishBonding(device);
+ break;
+ case PASSKEY_INPUT:
+ localBluelet.onPairingRequest(
+ remoteBluelet.address, PAIRING_VARIANT_DISPLAY_PASSKEY, passkey);
+ localBluelet.mPassKey = passkey;
+ remoteBluelet.onPairingRequest(
+ localBluelet.address, PAIRING_VARIANT_DISPLAY_PASSKEY, passkey);
+ break;
+ case CONSENT:
+ localBluelet.onPairingRequest(remoteBluelet.address,
+ PAIRING_VARIANT_CONSENT, /* key= */ 0);
+ if (remoteBluelet.getIoCapabilities() == IoCapabilities.NO_INPUT_NO_OUTPUT) {
+ remoteBluelet.setPairingConfirmation(localBluelet.address,
+ PairingConfirmation.CONFIRMED);
+ } else {
+ remoteBluelet.onPairingRequest(
+ localBluelet.address, PAIRING_VARIANT_CONSENT, /* key= */ 0);
+ }
+ break;
+ }
+ return true;
+ }
+
+ private void finishBonding(BluetoothDevice device) {
+ BlueletImpl remoteBluelet = remoteBlueletImpl(device.getAddress());
+ finishBonding(
+ device, remoteBluelet.getCreateBondOutcome(),
+ remoteBluelet.getCreateBondFailureReason());
+ }
+
+ private void finishBonding(BluetoothDevice device, CreateBondOutcome outcome,
+ int failureReason) {
+ switch (outcome) {
+ case SUCCESS:
+ setBondState(device.getAddress(), BluetoothDevice.BOND_BONDED,
+ BlueletImpl.REASON_SUCCESS);
+ break;
+ case FAILURE:
+ setBondState(device.getAddress(), BluetoothDevice.BOND_NONE, failureReason);
+ break;
+ case TIMEOUT:
+ // Send nothing.
+ break;
+ }
+ }
+
+ @Override
+ public boolean setPairingConfirmation(BluetoothDevice device, boolean confirmed,
+ AttributionSource attributionSource) {
+ localBlueletImpl()
+ .setPairingConfirmation(
+ device.getAddress(),
+ confirmed ? PairingConfirmation.CONFIRMED : PairingConfirmation.DENIED);
+
+ PairingConfirmation remoteConfirmation =
+ remoteBlueletImpl(device.getAddress()).getPairingConfirmation(
+ localBlueletImpl().address);
+ if (confirmed && remoteConfirmation == PairingConfirmation.CONFIRMED) {
+ LOGGER.d(String.format("CONFIRMED"));
+ finishBonding(device);
+ } else if (!confirmed || remoteConfirmation == PairingConfirmation.DENIED) {
+ LOGGER.d(String.format("NOT CONFIRMED"));
+ finishBonding(device, CreateBondOutcome.FAILURE, BlueletImpl.UNBOND_REASON_AUTH_FAILED);
+ }
+ return true;
+ }
+
+ @Override
+ public boolean setPasskey(BluetoothDevice device, int passkey) {
+ BlueletImpl remoteBluelet = remoteBlueletImpl(device.getAddress());
+ if (passkey == remoteBluelet.mPassKey) {
+ finishBonding(device);
+ } else {
+ finishBonding(device, CreateBondOutcome.FAILURE, BlueletImpl.UNBOND_REASON_AUTH_FAILED);
+ }
+ return true;
+ }
+
+ @Override
+ public boolean cancelBondProcess(BluetoothDevice device) {
+ finishBonding(device, CreateBondOutcome.FAILURE, BlueletImpl.UNBOND_REASON_AUTH_CANCELED);
+ return true;
+ }
+
+ @Override
+ public boolean removeBond(BluetoothDevice device) {
+ setBondState(device.getAddress(), BluetoothDevice.BOND_NONE, BlueletImpl.REASON_SUCCESS);
+ return true;
+ }
+
+ @Override
+ public BluetoothDevice[] getBondedDevices() {
+ return localBlueletImpl().getBondedDevices();
+ }
+
+ @Override
+ public int getAdapterConnectionState() {
+ return localBlueletImpl().getAdapterConnectionState();
+ }
+
+ @Override
+ public int getProfileConnectionState(int profile) {
+ return localBlueletImpl().getProfileConnectionState(profile);
+ }
+
+ @Override
+ public int getPhonebookAccessPermission(BluetoothDevice device) {
+ return remoteBlueletImpl(device.getAddress()).mPhonebookAccessPermission;
+ }
+
+ @Override
+ public boolean setPhonebookAccessPermission(BluetoothDevice device, int value) {
+ remoteBlueletImpl(device.getAddress()).mPhonebookAccessPermission = value;
+ return true;
+ }
+
+ @Override
+ public int getMessageAccessPermission(BluetoothDevice device) {
+ return remoteBlueletImpl(device.getAddress()).mMessageAccessPermission;
+ }
+
+ @Override
+ public boolean setMessageAccessPermission(BluetoothDevice device, int value) {
+ remoteBlueletImpl(device.getAddress()).mMessageAccessPermission = value;
+ return true;
+ }
+
+ @Override
+ public int getSimAccessPermission(BluetoothDevice device) {
+ return remoteBlueletImpl(device.getAddress()).mSimAccessPermission;
+ }
+
+ @Override
+ public boolean setSimAccessPermission(BluetoothDevice device, int value) {
+ remoteBlueletImpl(device.getAddress()).mSimAccessPermission = value;
+ return true;
+ }
+
+ private static void setBondState(String remoteAddress, int state, int failureReason) {
+ BlueletImpl remoteBluelet = remoteBlueletImpl(remoteAddress);
+
+ if (remoteBluelet.getFetchUuidsTiming() == FetchUuidsTiming.BEFORE_BONDING) {
+ fetchUuidsOnBondedState(remoteAddress, state);
+ }
+
+ remoteBluelet.setBondState(localBlueletImpl().address, state, failureReason);
+ localBlueletImpl().setBondState(remoteAddress, state, failureReason);
+
+ if (remoteBluelet.getFetchUuidsTiming() == FetchUuidsTiming.AFTER_BONDING) {
+ fetchUuidsOnBondedState(remoteAddress, state);
+ }
+ }
+
+ private static void fetchUuidsOnBondedState(String remoteAddress, int state) {
+ if (state == BluetoothDevice.BOND_BONDED) {
+ remoteBlueletImpl(remoteAddress)
+ .onFetchedUuids(localBlueletImpl().address, localBlueletImpl().mProfileUuids);
+ localBlueletImpl()
+ .onFetchedUuids(remoteAddress, remoteBlueletImpl(remoteAddress).mProfileUuids);
+ }
+ }
+
+ @Override
+ public int getScanMode() {
+ return localBlueletImpl().getAdapterDelegate().getScanMode();
+ }
+
+ @Override
+ public boolean setScanMode(int mode, int duration) {
+ localBlueletImpl().getAdapterDelegate().setScanMode(mode);
+ return true;
+ }
+
+ @Override
+ public int getDiscoverableTimeout() {
+ return -1;
+ }
+
+ @Override
+ public boolean setDiscoverableTimeout(int timeout) {
+ return true;
+ }
+
+ @Override
+ public boolean startDiscovery() {
+ localBlueletImpl().getAdapterDelegate().startDiscovery();
+ return true;
+ }
+
+ @Override
+ public boolean cancelDiscovery() {
+ localBlueletImpl().getAdapterDelegate().cancelDiscovery();
+ return true;
+ }
+
+ @Override
+ public boolean isDiscovering() {
+ return localBlueletImpl().getAdapterDelegate().isDiscovering();
+
+ }
+
+ @Override
+ public boolean isEnabled() {
+ return localBlueletImpl().getAdapterDelegate().getState().equals(State.ON);
+ }
+
+ @Override
+ public int getState() {
+ return localBlueletImpl().getAdapterDelegate().getState().getValue();
+ }
+
+ @Override
+ public boolean enable() {
+ localBlueletImpl().enableAdapter();
+ return true;
+ }
+
+ @Override
+ public boolean disable() {
+ localBlueletImpl().disableAdapter();
+ return true;
+ }
+
+ @Override
+ public ParcelFileDescriptor connectSocket(BluetoothDevice device, int type, ParcelUuid uuid,
+ int port, int flag) {
+ Preconditions.checkArgument(
+ port == BluetoothConstants.SOCKET_CHANNEL_CONNECT_WITH_UUID,
+ "Connect to port is not supported.");
+ Preconditions.checkArgument(
+ type == BluetoothConstants.TYPE_RFCOMM,
+ "Only Rfcomm socket is supported.");
+ return localBlueletImpl().getRfcommDelegate()
+ .connectSocket(device.getAddress(), uuid.getUuid());
+ }
+
+ @Override
+ public ParcelFileDescriptor createSocketChannel(int type, String serviceName, ParcelUuid uuid,
+ int port, int flag) {
+ Preconditions.checkArgument(
+ port == BluetoothConstants.SERVER_SOCKET_CHANNEL_AUTO_ASSIGN,
+ "Listen on port is not supported.");
+ Preconditions.checkArgument(
+ type == BluetoothConstants.TYPE_RFCOMM,
+ "Only Rfcomm socket is supported.");
+ return localBlueletImpl().getRfcommDelegate().createSocketChannel(serviceName, uuid);
+ }
+
+ @Override
+ public boolean isMultiAdvertisementSupported() {
+ return maxAdvertiseInstances() > 1;
+ }
+
+ @Override
+ public boolean isPeripheralModeSupported() {
+ return maxAdvertiseInstances() > 0;
+ }
+
+ private int maxAdvertiseInstances() {
+ return localBlueletImpl().getGattDelegate().getMaxAdvertiseInstances();
+ }
+
+ @Override
+ public boolean isOffloadedFilteringSupported() {
+ return localBlueletImpl().getGattDelegate().isOffloadedFilteringSupported();
+ }
+
+ private static BlueletImpl localBlueletImpl() {
+ return DeviceShadowEnvironmentImpl.getLocalBlueletImpl();
+ }
+
+ private static BlueletImpl remoteBlueletImpl(String address) {
+ return DeviceShadowEnvironmentImpl.getBlueletImpl(address);
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/IBluetoothManagerImpl.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/IBluetoothManagerImpl.java
new file mode 100644
index 0000000..cb38a41
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/IBluetoothManagerImpl.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.internal.bluetooth;
+
+import android.bluetooth.IBluetooth;
+import android.bluetooth.IBluetoothGatt;
+import android.bluetooth.IBluetoothManager;
+import android.bluetooth.IBluetoothManagerCallback;
+
+/**
+ * Implementation of IBluetoothManager interface
+ */
+public class IBluetoothManagerImpl implements IBluetoothManager {
+
+ private final IBluetooth mFakeBluetoothService = new IBluetoothImpl();
+ private final IBluetoothGatt mFakeGattService = new IBluetoothGattImpl();
+
+ @Override
+ public String getAddress() {
+ return mFakeBluetoothService.getAddress();
+ }
+
+ @Override
+ public String getName() {
+ return mFakeBluetoothService.getName();
+ }
+
+ @Override
+ public IBluetooth registerAdapter(IBluetoothManagerCallback callback) {
+ return mFakeBluetoothService;
+ }
+
+ @Override
+ public IBluetoothGatt getBluetoothGatt() {
+ return mFakeGattService;
+ }
+
+ @Override
+ public boolean enable() {
+ mFakeBluetoothService.enable();
+ return true;
+ }
+
+ @Override
+ public boolean disable(boolean persist) {
+ mFakeBluetoothService.disable();
+ return true;
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/FileDescriptorFactory.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/FileDescriptorFactory.java
new file mode 100644
index 0000000..12fa587
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/FileDescriptorFactory.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.internal.bluetooth.connection;
+
+import java.io.FileDescriptor;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Factory which creates {@link FileDescriptor} given an MAC address. Each MAC address can have many
+ * FileDescriptor but each FileDescriptor only maps to one MAC address.
+ */
+public class FileDescriptorFactory {
+
+ private static FileDescriptorFactory sInstance = null;
+
+ public static synchronized FileDescriptorFactory getInstance() {
+ if (sInstance == null) {
+ sInstance = new FileDescriptorFactory();
+ }
+ return sInstance;
+ }
+
+ public static synchronized void reset() {
+ sInstance = null;
+ }
+
+ private final Map<FileDescriptor, String> mAddressMap;
+
+ private FileDescriptorFactory() {
+ mAddressMap = new ConcurrentHashMap<>();
+ }
+
+ public FileDescriptor createFileDescriptor(String address) {
+ FileDescriptor fd = new FileDescriptor();
+ mAddressMap.put(fd, address);
+ return fd;
+ }
+
+ public String getAddress(FileDescriptor fd) {
+ return mAddressMap.get(fd);
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/PageScanHandler.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/PageScanHandler.java
new file mode 100644
index 0000000..82b97ff
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/PageScanHandler.java
@@ -0,0 +1,214 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.internal.bluetooth.connection;
+
+import android.os.Build.VERSION;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.libraries.testing.deviceshadower.internal.utils.MacAddressGenerator;
+
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.Map;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.LinkedBlockingQueue;
+
+/**
+ * Encapsulate page scan operations -- handle connection establishment between Bluetooth devices.
+ */
+public class PageScanHandler {
+
+ private static final ConnectionRequest REQUEST_SERVER_SOCKET_CLOSE = new ConnectionRequest();
+
+ private static PageScanHandler sInstance = null;
+
+ public static synchronized PageScanHandler getInstance() {
+ if (sInstance == null) {
+ sInstance = new PageScanHandler();
+ }
+ return sInstance;
+ }
+
+ public static synchronized void reset() {
+ sInstance = null;
+ }
+
+ // use FileDescriptor to identify incoming data before socket is connected.
+ private final Map<FileDescriptor, BlockingQueue<Integer>> mIncomingDataMap;
+ // map a server socket fd to a connection request queue
+ private final Map<FileDescriptor, BlockingQueue<ConnectionRequest>> mConnectionRequests;
+ // map a fd on client side to a fd of BluetoothSocket(not BluetoothServerSocket) on server side
+ private final Map<FileDescriptor, FileDescriptor> mClientServerFdMap;
+ // map a client fd to a connection request so the client socket can finish the pending
+ // connection
+ private final Map<FileDescriptor, ConnectionRequest> mPendingConnections;
+
+ private PageScanHandler() {
+ mIncomingDataMap = new ConcurrentHashMap<>();
+ mConnectionRequests = new ConcurrentHashMap<>();
+ mClientServerFdMap = new ConcurrentHashMap<>();
+ mPendingConnections = new ConcurrentHashMap<>();
+ }
+
+ public void postConnectionRequest(FileDescriptor serverSocketFd, ConnectionRequest request)
+ throws InterruptedException {
+ // used by the returning socket on server-side
+ FileDescriptor fd = FileDescriptorFactory.getInstance()
+ .createFileDescriptor(request.mServerAddress);
+ mClientServerFdMap.put(request.mClientFd, fd);
+ BlockingQueue<ConnectionRequest> requests = mConnectionRequests.get(serverSocketFd);
+ requests.put(request);
+ mPendingConnections.put(request.mClientFd, request);
+ }
+
+ public void addServerSocket(FileDescriptor serverSocketFd) {
+ mConnectionRequests.put(serverSocketFd, new LinkedBlockingQueue<ConnectionRequest>());
+ }
+
+ public FileDescriptor getServerFd(FileDescriptor clientFd) {
+ return mClientServerFdMap.get(clientFd);
+ }
+
+ // TODO(b/79994182): see go/objecttostring-lsc
+ @SuppressWarnings("ObjectToString")
+ public FileDescriptor processNextConnectionRequest(FileDescriptor serverSocketFd)
+ throws IOException, InterruptedException {
+ ConnectionRequest request = mConnectionRequests.get(serverSocketFd).take();
+ if (request == REQUEST_SERVER_SOCKET_CLOSE) {
+ // TODO(b/79994182): FileDescriptor does not implement toString() in serverSocketFd
+ throw new IOException("Server socket is closed. fd: " + serverSocketFd);
+ }
+ writeInitialConnectionInfo(serverSocketFd, request.mClientAddress, request.mPort);
+ return request.mClientFd;
+ }
+
+ public void waitForConnectionEstablished(FileDescriptor clientFd) throws InterruptedException {
+ ConnectionRequest request = mPendingConnections.get(clientFd);
+ if (request != null) {
+ request.mCountDownLatch.await();
+ }
+ }
+
+ public void finishPendingConnection(FileDescriptor clientFd) {
+ ConnectionRequest request = mPendingConnections.get(clientFd);
+ if (request != null) {
+ request.mCountDownLatch.countDown();
+ }
+ }
+
+ public void cancelServerSocket(FileDescriptor serverSocketFd) throws InterruptedException {
+ mConnectionRequests.get(serverSocketFd).put(REQUEST_SERVER_SOCKET_CLOSE);
+ }
+
+ public void writeInitialConnectionInfo(FileDescriptor fd, String address, int port)
+ throws InterruptedException {
+ for (byte b : initialConnectionInfo(address, port)) {
+ write(fd, Integer.valueOf(b));
+ }
+ }
+
+ public void writePort(FileDescriptor fd, int port) throws InterruptedException {
+ byte[] bytes = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(port).array();
+ for (byte b : bytes) {
+ write(fd, Integer.valueOf(b));
+ }
+ }
+
+ public void write(FileDescriptor fd, int data) throws InterruptedException {
+ BlockingQueue<Integer> incomingData = mIncomingDataMap.get(fd);
+ if (incomingData == null) {
+ synchronized (mIncomingDataMap) {
+ incomingData = mIncomingDataMap.get(fd);
+ if (incomingData == null) {
+ incomingData = new LinkedBlockingQueue<Integer>();
+ mIncomingDataMap.put(fd, incomingData);
+ }
+ }
+ }
+ incomingData.put(data);
+ }
+
+ public int read(FileDescriptor fd) throws InterruptedException {
+ return mIncomingDataMap.get(fd).take();
+ }
+
+ /**
+ * A connection request from a {@link android.bluetooth.BluetoothSocket}.
+ */
+ @VisibleForTesting
+ public static class ConnectionRequest {
+
+ final FileDescriptor mClientFd;
+ final String mClientAddress;
+ final String mServerAddress;
+ final int mPort;
+ final CountDownLatch mCountDownLatch; // block server socket until connection established
+
+ public ConnectionRequest(FileDescriptor fd, String clientAddress, String serverAddress,
+ int port) {
+ mClientFd = fd;
+ this.mClientAddress = clientAddress;
+ this.mServerAddress = serverAddress;
+ this.mPort = port;
+ mCountDownLatch = new CountDownLatch(1);
+ }
+
+ private ConnectionRequest() {
+ mClientFd = null;
+ mClientAddress = null;
+ mServerAddress = null;
+ mPort = -1;
+ mCountDownLatch = new CountDownLatch(0);
+ }
+ }
+
+ private static byte[] initialConnectionInfo(String addr, int port) {
+ byte[] mac = MacAddressGenerator.convertStringMacAddress(addr);
+ int channel = port;
+ int status = 0;
+
+ if (VERSION.SDK_INT < 23) {
+ byte[] signal = new byte[16];
+ short signalSize = 16;
+ ByteBuffer buffer = ByteBuffer.wrap(signal);
+ buffer.order(ByteOrder.LITTLE_ENDIAN)
+ .putShort(signalSize)
+ .put(mac)
+ .putInt(channel)
+ .putInt(status);
+ return buffer.array();
+ } else {
+ byte[] signal = new byte[20];
+ short signalSize = 20;
+ short maxTxPacketSize = 10000;
+ short maxRxPacketSize = 10000;
+ ByteBuffer buffer = ByteBuffer.wrap(signal);
+ buffer.order(ByteOrder.LITTLE_ENDIAN)
+ .putShort(signalSize)
+ .put(mac)
+ .putInt(channel)
+ .putInt(status)
+ .putShort(maxTxPacketSize)
+ .putShort(maxRxPacketSize);
+ return buffer.array();
+ }
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/PhysicalLink.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/PhysicalLink.java
new file mode 100644
index 0000000..e474c69
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/PhysicalLink.java
@@ -0,0 +1,180 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.internal.bluetooth.connection;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.utils.Logger;
+
+import com.google.common.collect.Sets;
+
+import java.io.FileDescriptor;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * A class represents a physical link for communications between two Bluetooth devices.
+ */
+public class PhysicalLink {
+
+ // Intended to use RfcommDelegate
+ private static final Logger LOGGER = Logger.create("RfcommDelegate");
+
+ private final Object mLock;
+ // Every socket has unique FileDescriptor, so use it as socket identifier during communication
+ private final Map<FileDescriptor, RfcommSocketConnection> mConnectionLookup;
+ // Map fd of a socket to the fd of the other socket it connects to
+ private final Map<FileDescriptor, FileDescriptor> mFdMap;
+ private final Set<RfcommSocketConnection> mConnections;
+ private final AtomicBoolean mIsEncrypted;
+ private final Map<String, RfcommDelegate.Callback> mCallbacks = new HashMap<>();
+
+ public PhysicalLink(String address1, String address2) {
+ this(address1,
+ DeviceShadowEnvironmentImpl.getBlueletImpl(address1).getRfcommDelegate().mCallback,
+ address2,
+ DeviceShadowEnvironmentImpl.getBlueletImpl(address2).getRfcommDelegate().mCallback,
+ new ConcurrentHashMap<FileDescriptor, RfcommSocketConnection>(),
+ new ConcurrentHashMap<FileDescriptor, FileDescriptor>(),
+ Sets.<RfcommSocketConnection>newConcurrentHashSet());
+ }
+
+ @VisibleForTesting
+ PhysicalLink(String address1, RfcommDelegate.Callback callback1,
+ String address2, RfcommDelegate.Callback callback2,
+ Map<FileDescriptor, RfcommSocketConnection> connectionLookup,
+ Map<FileDescriptor, FileDescriptor> fdMap,
+ Set<RfcommSocketConnection> connections) {
+ mLock = new Object();
+ mCallbacks.put(address1, callback1);
+ mCallbacks.put(address2, callback2);
+ this.mConnectionLookup = connectionLookup;
+ this.mFdMap = fdMap;
+ this.mConnections = connections;
+ mIsEncrypted = new AtomicBoolean(false);
+ }
+
+ public void addConnection(FileDescriptor fd1, FileDescriptor fd2) {
+ synchronized (mLock) {
+ int oldSize = mConnections.size();
+ RfcommSocketConnection connection = new RfcommSocketConnection(
+ FileDescriptorFactory.getInstance().getAddress(fd1),
+ FileDescriptorFactory.getInstance().getAddress(fd2)
+ );
+ mConnections.add(connection);
+ mConnectionLookup.put(fd1, connection);
+ mConnectionLookup.put(fd2, connection);
+ mFdMap.put(fd1, fd2);
+ mFdMap.put(fd2, fd1);
+ if (oldSize == 0) {
+ onConnectionStateChange(true);
+ }
+ }
+ }
+
+ // TODO(b/79994182): see go/objecttostring-lsc
+ @SuppressWarnings("ObjectToString")
+ public void closeConnection(FileDescriptor fd) {
+ // check for early return without locking
+ if (!mConnectionLookup.containsKey(fd)) {
+ // TODO(b/79994182): FileDescriptor does not implement toString() in fd
+ LOGGER.d("Connection doesn't exist, FileDescriptor: " + fd);
+ return;
+ }
+ synchronized (mLock) {
+ RfcommSocketConnection connection = mConnectionLookup.get(fd);
+ if (connection == null) {
+ // TODO(b/79994182): FileDescriptor does not implement toString() in fd
+ LOGGER.d("Connection doesn't exist, FileDescriptor: " + fd);
+ return;
+ }
+ int oldSize = mConnections.size();
+ FileDescriptor connectingFd = mFdMap.get(fd);
+ mConnectionLookup.remove(fd);
+ mConnectionLookup.remove(connectingFd);
+ mFdMap.remove(fd);
+ mFdMap.remove(connectingFd);
+ mConnections.remove(connection);
+ if (oldSize == 1) {
+ onConnectionStateChange(false);
+ }
+ }
+ }
+
+ public RfcommSocketConnection getConnection(FileDescriptor fd) {
+ return mConnectionLookup.get(fd);
+ }
+
+ public void encrypt() {
+ mIsEncrypted.set(true);
+ }
+
+ public boolean isEncrypted() {
+ return mIsEncrypted.get();
+ }
+
+ public boolean isConnected() {
+ return !mConnections.isEmpty();
+ }
+
+ private void onConnectionStateChange(boolean isConnected) {
+ for (Entry<String, RfcommDelegate.Callback> entry : mCallbacks.entrySet()) {
+ RfcommDelegate.Callback callback = entry.getValue();
+ String localAddress = entry.getKey();
+ callback.onConnectionStateChange(getRemoteAddress(localAddress), isConnected);
+ }
+ }
+
+ private String getRemoteAddress(String address) {
+ String remoteAddress = null;
+ for (String addr : mCallbacks.keySet()) {
+ if (!addr.equals(address)) {
+ remoteAddress = addr;
+ break;
+ }
+ }
+ return remoteAddress;
+ }
+
+ /**
+ * Represents a Rfcomm socket connection between two {@link android.bluetooth.BluetoothSocket}.
+ */
+ public static class RfcommSocketConnection {
+
+ final Map<String, BlockingQueue<Integer>> mIncomingDataMap; // address : incomingData
+
+ public RfcommSocketConnection(String address1, String address2) {
+ mIncomingDataMap = new ConcurrentHashMap<>();
+ mIncomingDataMap.put(address1, new LinkedBlockingQueue<Integer>());
+ mIncomingDataMap.put(address2, new LinkedBlockingQueue<Integer>());
+ }
+
+ public void write(String address, int b) throws InterruptedException {
+ mIncomingDataMap.get(address).put(b);
+ }
+
+ public int read(String address) throws InterruptedException {
+ return mIncomingDataMap.get(address).take();
+ }
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/RfcommDelegate.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/RfcommDelegate.java
new file mode 100644
index 0000000..3a4fdf6
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/RfcommDelegate.java
@@ -0,0 +1,367 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.internal.bluetooth.connection;
+
+import android.os.ParcelFileDescriptor;
+import android.os.ParcelUuid;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.BlueletImpl;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.BluetoothConstants;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.connection.PageScanHandler.ConnectionRequest;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.connection.PhysicalLink.RfcommSocketConnection;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.connection.SdpHandler.ServiceRecord;
+import com.android.libraries.testing.deviceshadower.internal.common.Interrupter;
+import com.android.libraries.testing.deviceshadower.internal.utils.Logger;
+
+import com.google.errorprone.annotations.FormatMethod;
+
+import org.robolectric.util.ReflectionHelpers;
+
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Delegate for Bluetooth Rfcommon operations, including creating service record, establishing
+ * connection, and data communications.
+ * <p>Socket connection with uuid is supported. Listen on port and connect to port are not
+ * supported.</p>
+ */
+public class RfcommDelegate {
+
+ private static final Logger LOGGER = Logger.create("RfcommDelegate");
+ private static final Object LOCK = new Object();
+
+ /**
+ * Callback for Rfcomm operations
+ */
+ public interface Callback {
+
+ void onConnectionStateChange(String remoteAddress, boolean isConnected);
+ }
+
+ public static void reset() {
+ PageScanHandler.reset();
+ FileDescriptorFactory.reset();
+ }
+
+ final Callback mCallback;
+ private final String mAddress;
+ private final Interrupter mInterrupter;
+ private final SdpHandler mSdpHandler;
+ private final PageScanHandler mPageScanHandler;
+ private final Map<String, PhysicalLink> mConnectionMap; // remoteAddress : physicalLink
+
+ public RfcommDelegate(String address, Callback callback, Interrupter interrupter) {
+ this.mAddress = address;
+ this.mCallback = callback;
+ this.mInterrupter = interrupter;
+ mSdpHandler = new SdpHandler(address);
+ mPageScanHandler = PageScanHandler.getInstance();
+ mConnectionMap = new ConcurrentHashMap<>();
+ }
+
+ @SuppressWarnings("ObjectToString")
+ public ParcelFileDescriptor createSocketChannel(String serviceName, ParcelUuid uuid) {
+ ServiceRecord record = mSdpHandler.createServiceRecord(uuid.getUuid(), serviceName);
+ if (record == null) {
+ LOGGER.e(
+ String.format("Address %s: failed to create socket channel, uuid: %s", mAddress,
+ uuid));
+ return null;
+ }
+ try {
+ mPageScanHandler.writePort(record.mServerSocketFd, record.mPort);
+ } catch (InterruptedException e) {
+ LOGGER.e(String.format("Address %s: failed to write port to incoming data, fd: %s",
+ mAddress,
+ record.mServerSocketFd), e);
+ return null;
+ }
+ return parcelFileDescriptor(record.mServerSocketFd);
+ }
+
+ @SuppressWarnings("ObjectToString")
+ public ParcelFileDescriptor connectSocket(String remoteAddress, UUID uuid) {
+ BlueletImpl remote = DeviceShadowEnvironmentImpl.getBlueletImpl(remoteAddress);
+ if (remote == null) {
+ LOGGER.e(String.format("Device %s is not defined.", remoteAddress));
+ return null;
+ }
+ ServiceRecord record = remote.getRfcommDelegate().mSdpHandler.lookupChannel(uuid);
+ if (record == null) {
+ LOGGER.e(String.format("Address %s: failed to connect socket, uuid: %s", mAddress,
+ uuid));
+ return null;
+ }
+ FileDescriptor fd = FileDescriptorFactory.getInstance().createFileDescriptor(mAddress);
+ try {
+ mPageScanHandler.writePort(fd, record.mPort);
+ } catch (InterruptedException e) {
+ LOGGER.e(String.format("Address %s: failed to write port to incoming data, fd: %s",
+ mAddress,
+ fd), e);
+ return null;
+ }
+
+ // establish connection
+ try {
+ initiateConnectToServer(fd, record, remoteAddress);
+ } catch (IOException e) {
+ LOGGER.e(
+ String.format("Address %s: fail to initiate connection to server, clientFd: %s",
+ mAddress, fd), e);
+ return null;
+ }
+ return parcelFileDescriptor(fd);
+ }
+
+ /**
+ * Creates connection and unblocks server socket.
+ * <p>ShadowBluetoothSocket calls the method at the end of connect().</p>
+ */
+ public void finishPendingConnection(
+ String serverAddress, FileDescriptor clientFd, boolean isEncrypted) {
+ // update states
+ PhysicalLink physicalChannel = mConnectionMap.get(serverAddress);
+ if (physicalChannel == null) {
+ // use class level lock to ensure two RfcommDelegate hold reference to the same Physical
+ // Link
+ synchronized (LOCK) {
+ physicalChannel = mConnectionMap.get(serverAddress);
+ if (physicalChannel == null) {
+ physicalChannel = new PhysicalLink(
+ serverAddress,
+ FileDescriptorFactory.getInstance().getAddress(clientFd));
+ addPhysicalChannel(serverAddress, physicalChannel);
+ BlueletImpl remote = DeviceShadowEnvironmentImpl.getBlueletImpl(serverAddress);
+ remote.getRfcommDelegate().addPhysicalChannel(mAddress, physicalChannel);
+ }
+ }
+ }
+ physicalChannel.addConnection(clientFd, mPageScanHandler.getServerFd(clientFd));
+
+ if (isEncrypted) {
+ physicalChannel.encrypt();
+ }
+ mPageScanHandler.finishPendingConnection(clientFd);
+ }
+
+ /**
+ * Process the next {@link ConnectionRequest} to {@link android.bluetooth.BluetoothServerSocket}
+ * identified by serverSocketFd. This call will block until next connection request is
+ * available.
+ */
+ @SuppressWarnings("ObjectToString")
+ public FileDescriptor processNextConnectionRequest(FileDescriptor serverSocketFd)
+ throws IOException {
+ try {
+ return mPageScanHandler.processNextConnectionRequest(serverSocketFd);
+ } catch (InterruptedException e) {
+ throw new IOException(
+ logError(e, "failed to process next connection request, serverSocketFd: %s",
+ serverSocketFd),
+ e);
+ }
+ }
+
+ /**
+ * Waits for a connection established.
+ * <p>ShadowBluetoothServerSocket calls the method at the end of accept(). Ensure that a
+ * connection is established when accept() returns.</p>
+ */
+ @SuppressWarnings("ObjectToString")
+ public void waitForConnectionEstablished(FileDescriptor clientFd) throws IOException {
+ try {
+ mPageScanHandler.waitForConnectionEstablished(clientFd);
+ } catch (InterruptedException e) {
+ throw new IOException(
+ logError(e, "failed to wait for connection established. clientFd: %s",
+ clientFd), e);
+ }
+ }
+
+ @SuppressWarnings("ObjectToString")
+ public void write(String remoteAddress, FileDescriptor localFd, int b)
+ throws IOException {
+ checkInterrupt();
+ RfcommSocketConnection connection =
+ mConnectionMap.get(remoteAddress).getConnection(localFd);
+ if (connection == null) {
+ throw new IOException("closed");
+ }
+ try {
+ connection.write(remoteAddress, b);
+ } catch (InterruptedException e) {
+ throw new IOException(
+ logError(e, "failed to write to target %s, fd: %s", remoteAddress,
+ localFd), e);
+ }
+ }
+
+ @SuppressWarnings("ObjectToString")
+ public int read(String remoteAddress, FileDescriptor localFd) throws IOException {
+ checkInterrupt();
+ // remoteAddress is null: 1. server socket, 2. client socket before connected
+ try {
+ if (remoteAddress == null) {
+ return mPageScanHandler.read(localFd);
+ }
+ } catch (InterruptedException e) {
+ throw new IOException(logError(e, "failed to read, fd: %s", localFd), e);
+ }
+
+ RfcommSocketConnection connection =
+ mConnectionMap.get(remoteAddress).getConnection(localFd);
+ if (connection == null) {
+ throw new IOException("closed");
+ }
+ try {
+ return connection.read(mAddress);
+ } catch (InterruptedException e) {
+ throw new IOException(logError(e, "failed to read, fd: %s", localFd), e);
+ }
+ }
+
+ @SuppressWarnings("ObjectToString")
+ public void shutdownInput(String remoteAddress, FileDescriptor localFd)
+ throws IOException {
+ // remoteAddress is null: 1. server socket, 2. client socket before connected
+ try {
+ if (remoteAddress == null) {
+ mPageScanHandler.write(localFd, BluetoothConstants.SOCKET_CLOSE);
+ return;
+ }
+ } catch (InterruptedException e) {
+ throw new IOException(logError(e, "failed to shutdown input. fd: %s", localFd), e);
+ }
+
+ RfcommSocketConnection connection =
+ mConnectionMap.get(remoteAddress).getConnection(localFd);
+ if (connection == null) {
+ LOGGER.d(String.format("Address %s: Connection already closed. fd: %s.", mAddress,
+ localFd));
+ return;
+ }
+ try {
+ connection.write(mAddress, BluetoothConstants.SOCKET_CLOSE);
+ } catch (InterruptedException e) {
+ throw new IOException(logError(e, "failed to shutdown input. fd: %s", localFd), e);
+ }
+ }
+
+ @SuppressWarnings("ObjectToString")
+ public void shutdownOutput(String remoteAddress, FileDescriptor localFd)
+ throws IOException {
+ RfcommSocketConnection connection =
+ mConnectionMap.get(remoteAddress).getConnection(localFd);
+ if (connection == null) {
+ LOGGER.d(String.format("Address %s: Connection already closed. fd: %s.", mAddress,
+ localFd));
+ return;
+ }
+ try {
+ connection.write(remoteAddress, BluetoothConstants.SOCKET_CLOSE);
+ } catch (InterruptedException e) {
+ throw new IOException(logError(e, "failed to shutdown output. fd: %s", localFd), e);
+ }
+ }
+
+ @SuppressWarnings("ObjectToString")
+ public void closeServerSocket(FileDescriptor serverSocketFd) throws IOException {
+ // remove service record
+ UUID uuid = mSdpHandler.getUuid(serverSocketFd);
+ mSdpHandler.removeServiceRecord(uuid);
+ // unblock accept()
+ try {
+ mPageScanHandler.cancelServerSocket(serverSocketFd);
+ } catch (InterruptedException e) {
+ throw new IOException(
+ logError(e, "failed to cancel server socket, serverSocketFd: %s",
+ serverSocketFd),
+ e);
+ }
+ }
+
+ public FileDescriptor getServerFd(FileDescriptor clientFd) {
+ return mPageScanHandler.getServerFd(clientFd);
+ }
+
+ @VisibleForTesting
+ public void addPhysicalChannel(String remoteAddress, PhysicalLink channel) {
+ mConnectionMap.put(remoteAddress, channel);
+ }
+
+ @SuppressWarnings("ObjectToString")
+ public void initiateConnectToClient(FileDescriptor clientFd, int port)
+ throws IOException {
+ checkInterrupt();
+ String clientAddress = FileDescriptorFactory.getInstance().getAddress(clientFd);
+ LOGGER.d(String.format("Address %s: init connection to %s, clientFd: %s",
+ mAddress, clientAddress, clientFd));
+ try {
+ mPageScanHandler.writeInitialConnectionInfo(clientFd, mAddress, port);
+ } catch (InterruptedException e) {
+ throw new IOException(
+ logError(e,
+ "failed to write initial connection info to %s, clientFd: %s",
+ clientAddress, clientFd),
+ e);
+ }
+ }
+
+ @SuppressWarnings("ObjectToString")
+ private void initiateConnectToServer(FileDescriptor clientFd, ServiceRecord serviceRecord,
+ String serverAddress) throws IOException {
+ checkInterrupt();
+ LOGGER.d(
+ String.format("Address %s: init connection to %s, serverSocketFd: %s, clientFd: %s",
+ mAddress, serverAddress, serviceRecord.mServerSocketFd, clientFd));
+ try {
+ ConnectionRequest request = new ConnectionRequest(clientFd, mAddress, serverAddress,
+ serviceRecord.mPort);
+ mPageScanHandler.postConnectionRequest(serviceRecord.mServerSocketFd, request);
+ } catch (InterruptedException e) {
+ throw new IOException(
+ logError(e,
+ "failed to post connection request, serverSocketFd: %s, "
+ + "clientFd: %s",
+ serviceRecord.mServerSocketFd, clientFd),
+ e);
+ }
+ }
+
+ public void checkInterrupt() throws IOException {
+ mInterrupter.checkInterrupt();
+ }
+
+ private ParcelFileDescriptor parcelFileDescriptor(FileDescriptor fd) {
+ return ReflectionHelpers.callConstructor(ParcelFileDescriptor.class,
+ ReflectionHelpers.ClassParameter.from(FileDescriptor.class, fd));
+ }
+
+ @FormatMethod
+ private String logError(Exception e, String msgTmpl, Object... args) {
+ String errMsg = String.format("Address %s: ", mAddress) + String.format(msgTmpl, args);
+ LOGGER.e(errMsg, e);
+ return errMsg;
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/SdpHandler.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/SdpHandler.java
new file mode 100644
index 0000000..dbe8651
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/bluetooth/connection/SdpHandler.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.internal.bluetooth.connection;
+
+import com.android.libraries.testing.deviceshadower.internal.utils.Logger;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.Sets;
+
+import java.io.FileDescriptor;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Encapsulates SDP operations including creating service record and allocating channel.
+ * <p>Listen on port and connect on port are not supported. </p>
+ */
+public class SdpHandler {
+
+ // intended to use "RfcommDelegate"
+ private static final Logger LOGGER = Logger.create("RfcommDelegate");
+
+ private final Object mLock;
+ private final String mAddress;
+ private final Map<UUID, ServiceRecord> mServiceRecords;
+ private final Map<FileDescriptor, UUID> mFdUuidMap;
+ private final Set<Integer> mAvailablePortPool;
+ private final Set<Integer> mInUsePortPool;
+
+ public SdpHandler(String address) {
+ mLock = new Object();
+ this.mAddress = address;
+ mServiceRecords = new ConcurrentHashMap<>();
+ mFdUuidMap = new ConcurrentHashMap<>();
+ mAvailablePortPool = Sets.newConcurrentHashSet();
+ mInUsePortPool = Sets.newConcurrentHashSet();
+ // 1 to 30 are valid RFCOMM port
+ for (int i = 1; i <= 30; i++) {
+ mAvailablePortPool.add(i);
+ }
+ }
+
+ public ServiceRecord createServiceRecord(UUID uuid, String serviceName) {
+ Preconditions.checkNotNull(uuid);
+ LOGGER.d(String.format("Address %s: createServiceRecord with uuid %s", mAddress, uuid));
+ if (isUuidRegistered(uuid) || !checkChannelAvailability()) {
+ return null;
+ }
+ synchronized (mLock) {
+ // ensure uuid is not registered and there's available channel
+ if (isUuidRegistered(uuid) || !checkChannelAvailability()) {
+ return null;
+ }
+ Iterator<Integer> available = mAvailablePortPool.iterator();
+ int port = available.next();
+ mAvailablePortPool.remove(port);
+ mInUsePortPool.add(port);
+ ServiceRecord record = new ServiceRecord(mAddress, serviceName, port);
+ mServiceRecords.put(uuid, record);
+ mFdUuidMap.put(record.mServerSocketFd, uuid);
+ PageScanHandler.getInstance().addServerSocket(record.mServerSocketFd);
+ return record;
+ }
+ }
+
+ public void removeServiceRecord(UUID uuid) {
+ Preconditions.checkNotNull(uuid);
+ LOGGER.d(String.format("Address %s: removeServiceRecord with uuid %s", mAddress, uuid));
+ if (!isUuidRegistered(uuid)) {
+ return;
+ }
+ synchronized (mLock) {
+ if (!isUuidRegistered(uuid)) {
+ return;
+ }
+ ServiceRecord record = mServiceRecords.get(uuid);
+ mServiceRecords.remove(uuid);
+ mInUsePortPool.remove(record.mPort);
+ mAvailablePortPool.add(record.mPort);
+ mFdUuidMap.remove(record.mServerSocketFd);
+ }
+ }
+
+ public ServiceRecord lookupChannel(UUID uuid) {
+ ServiceRecord record = mServiceRecords.get(uuid);
+ if (record == null) {
+ LOGGER.e(String.format("Address %s: uuid %s not registered.", mAddress, uuid));
+ }
+ return record;
+ }
+
+ public UUID getUuid(FileDescriptor serverSocketFd) {
+ return mFdUuidMap.get(serverSocketFd);
+ }
+
+ private boolean isUuidRegistered(UUID uuid) {
+ if (mServiceRecords.containsKey(uuid)) {
+ LOGGER.d(String.format("Address %s: Uuid %s in use.", mAddress, uuid));
+ return true;
+ }
+ LOGGER.d(String.format("Address %s: Uuid %s not registered.", mAddress, uuid));
+ return false;
+ }
+
+ private boolean checkChannelAvailability() {
+ if (mAvailablePortPool.isEmpty()) {
+ LOGGER.e(String.format("Address %s: No available channel.", mAddress));
+ return false;
+ }
+ return true;
+ }
+
+ static class ServiceRecord {
+
+ final FileDescriptor mServerSocketFd;
+ final String mServiceName;
+ final int mPort;
+
+ ServiceRecord(String address, String serviceName, int port) {
+ mServerSocketFd = FileDescriptorFactory.getInstance().createFileDescriptor(address);
+ this.mServiceName = serviceName;
+ this.mPort = port;
+ }
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/BroadcastManager.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/BroadcastManager.java
new file mode 100644
index 0000000..b148b73
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/BroadcastManager.java
@@ -0,0 +1,526 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.internal.common;
+
+import android.content.BroadcastReceiver;
+import android.content.BroadcastReceiver.PendingResult;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Build.VERSION;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.IBinder;
+
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.libraries.testing.deviceshadower.internal.utils.Logger;
+
+import com.google.common.base.Function;
+import com.google.common.base.Preconditions;
+import com.google.common.util.concurrent.AsyncFunction;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+
+import org.robolectric.Shadows;
+import org.robolectric.shadows.ShadowApplication;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.GuardedBy;
+
+/**
+ * Manager for broadcasting of one virtual Device Shadower device.
+ *
+ * <p>Inspired by {@link ShadowApplication} and {@link LocalBroadcastManager}.
+ * <li>Broadcast permission is not supported until manifest is supported.
+ * <li>Send Broadcast is asynchronous.
+ */
+public class BroadcastManager {
+
+ private static final Logger LOGGER = Logger.create("BroadcastManager");
+
+ private static final Comparator<ReceiverRecord> RECEIVER_RECORD_COMPARATOR =
+ new Comparator<ReceiverRecord>() {
+ @Override
+ public int compare(ReceiverRecord o1, ReceiverRecord o2) {
+ return o2.mIntentFilter.getPriority() - o1.mIntentFilter.getPriority();
+ }
+ };
+
+ private final Scheduler mScheduler;
+ private final Map<String, Intent> mStickyIntents;
+
+ @GuardedBy("mRegisteredReceivers")
+ private final Map<BroadcastReceiver, Set<String>> mRegisteredReceivers;
+
+ @GuardedBy("mRegisteredReceivers")
+ private final Map<String, List<ReceiverRecord>> mActions;
+
+ public BroadcastManager(Scheduler scheduler) {
+ this(
+ scheduler,
+ new HashMap<String, Intent>(),
+ new HashMap<BroadcastReceiver, Set<String>>(),
+ new HashMap<String, List<ReceiverRecord>>());
+ }
+
+ @VisibleForTesting
+ BroadcastManager(
+ Scheduler scheduler,
+ Map<String, Intent> stickyIntents,
+ Map<BroadcastReceiver, Set<String>> registeredReceivers,
+ Map<String, List<ReceiverRecord>> actions) {
+ this.mScheduler = scheduler;
+ this.mStickyIntents = stickyIntents;
+ this.mRegisteredReceivers = registeredReceivers;
+ this.mActions = actions;
+ }
+
+ /**
+ * Registers a {@link BroadcastReceiver} with given {@link Context}.
+ *
+ * @see Context#registerReceiver(BroadcastReceiver, IntentFilter, String, Handler)
+ */
+ @Nullable
+ public Intent registerReceiver(
+ @Nullable BroadcastReceiver receiver,
+ IntentFilter filter,
+ @Nullable String broadcastPermission,
+ @Nullable Handler handler,
+ Context context) {
+ // Ignore broadcastPermission before fully supporting manifest
+ Preconditions.checkNotNull(filter);
+ Preconditions.checkNotNull(context);
+ if (receiver != null) {
+ synchronized (mRegisteredReceivers) {
+ ReceiverRecord receiverRecord = new ReceiverRecord(receiver, filter, context,
+ handler);
+ Set<String> actionSet = mRegisteredReceivers.get(receiver);
+ if (actionSet == null) {
+ actionSet = new HashSet<>();
+ mRegisteredReceivers.put(receiver, actionSet);
+ }
+ for (int i = 0; i < filter.countActions(); i++) {
+ String action = filter.getAction(i);
+ actionSet.add(action);
+ List<ReceiverRecord> receiverRecords = mActions.get(action);
+ if (receiverRecords == null) {
+ receiverRecords = new ArrayList<>();
+ mActions.put(action, receiverRecords);
+ }
+ receiverRecords.add(receiverRecord);
+ }
+ }
+ }
+ return processStickyIntents(receiver, filter, context);
+ }
+
+ // Broadcast all sticky intents matching the given IntentFilter.
+ @SuppressWarnings("FutureReturnValueIgnored")
+ @Nullable
+ private Intent processStickyIntents(
+ @Nullable final BroadcastReceiver receiver,
+ IntentFilter intentFilter,
+ final Context context) {
+ Intent result = null;
+ final List<Intent> matchedIntents = new ArrayList<>();
+ for (Intent intent : mStickyIntents.values()) {
+ if (match(intentFilter, intent)) {
+ if (result == null) {
+ result = intent;
+ }
+ if (receiver == null) {
+ return result;
+ }
+ matchedIntents.add(intent);
+ }
+ }
+ if (!matchedIntents.isEmpty()) {
+ mScheduler.post(
+ NamedRunnable.create(
+ "Broadcast.processStickyIntents",
+ () -> {
+ for (Intent intent : matchedIntents) {
+ receiver.onReceive(context, intent);
+ }
+ }));
+ }
+ return result;
+ }
+
+ /**
+ * Unregisters a {@link BroadcastReceiver}.
+ *
+ * @see Context#unregisterReceiver(BroadcastReceiver)
+ */
+ public void unregisterReceiver(BroadcastReceiver broadcastReceiver) {
+ synchronized (mRegisteredReceivers) {
+ if (!mRegisteredReceivers.containsKey(broadcastReceiver)) {
+ LOGGER.w("Receiver not registered: " + broadcastReceiver);
+ return;
+ }
+ Set<String> actionSet = mRegisteredReceivers.remove(broadcastReceiver);
+ for (String action : actionSet) {
+ List<ReceiverRecord> receiverRecords = mActions.get(action);
+ Iterator<ReceiverRecord> iterator = receiverRecords.iterator();
+ while (iterator.hasNext()) {
+ if (iterator.next().mBroadcastReceiver == broadcastReceiver) {
+ iterator.remove();
+ }
+ }
+ if (receiverRecords.isEmpty()) {
+ mActions.remove(action);
+ }
+ }
+ }
+ }
+
+ /**
+ * Sends sticky broadcast with given {@link Intent}. This call is asynchronous.
+ *
+ * @see Context#sendStickyBroadcast(Intent)
+ */
+ public void sendStickyBroadcast(Intent intent) {
+ mStickyIntents.put(intent.getAction(), intent);
+ sendBroadcast(intent, null /* broadcastPermission */);
+ }
+
+ /**
+ * Sends broadcast with given {@link Intent}. Receiver permission is not supported. This call is
+ * asynchronous.
+ *
+ * @see Context#sendBroadcast(Intent, String)
+ */
+ @SuppressWarnings("FutureReturnValueIgnored")
+ public void sendBroadcast(final Intent intent, @Nullable String receiverPermission) {
+ // Ignore permission matching before fully supporting manifest
+ final List<ReceiverRecord> receivers =
+ getMatchingReceivers(intent, false /* isOrdered */);
+ if (receivers.isEmpty()) {
+ return;
+ }
+ mScheduler.post(
+ NamedRunnable.create(
+ "Broadcast.sendBroadcast",
+ () -> {
+ for (ReceiverRecord receiverRecord : receivers) {
+ // Hacky: Call the shadow method, otherwise abort() NPEs after
+ // calling onReceive().
+ // TODO(b/200231384): Sending these, via context.sendBroadcast(),
+ // won't NPE...but it may not be possible on each simulated
+ // "device"'s main thread. Check if possible.
+ BroadcastReceiver broadcastReceiver =
+ receiverRecord.mBroadcastReceiver;
+ Shadows.shadowOf(broadcastReceiver)
+ .onReceive(receiverRecord.mContext, intent, /*abort=*/
+ new AtomicBoolean(false));
+ }
+ }));
+ }
+
+ /**
+ * Sends ordered broadcast with given {@link Intent}. Receiver permission is not supported. This
+ * call is asynchronous.
+ *
+ * @see Context#sendOrderedBroadcast(Intent, String)
+ */
+ public void sendOrderedBroadcast(Intent intent, @Nullable String receiverPermission) {
+ sendOrderedBroadcast(
+ intent,
+ receiverPermission,
+ null /* resultReceiver */,
+ null /* handler */,
+ 0 /* initialCode */,
+ null /* initialData */,
+ null /* initialExtras */,
+ null /* context */);
+ }
+
+ /**
+ * Sends ordered broadcast with given {@link Intent} and result {@link BroadcastReceiver}.
+ * Receiver permission is not supported. This call is asynchronous.
+ *
+ * @see Context#sendOrderedBroadcast(Intent, String, BroadcastReceiver, Handler, int, String,
+ * Bundle)
+ */
+ @SuppressWarnings("FutureReturnValueIgnored")
+ public void sendOrderedBroadcast(
+ final Intent intent,
+ @Nullable String receiverPermission,
+ @Nullable BroadcastReceiver resultReceiver,
+ @Nullable Handler handler,
+ int initialCode,
+ @Nullable String initialData,
+ @Nullable Bundle initialExtras,
+ @Nullable Context context) {
+ // Ignore permission matching before fully supporting manifest
+ final List<ReceiverRecord> receivers =
+ getMatchingReceivers(intent, true /* isOrdered */);
+ if (receivers.isEmpty()) {
+ return;
+ }
+ if (resultReceiver != null) {
+ receivers.add(
+ new ReceiverRecord(
+ resultReceiver, null /* intentFilter */, context, handler));
+ }
+ mScheduler.post(
+ NamedRunnable.create(
+ "Broadcast.sendOrderedBroadcast",
+ () -> {
+ postOrderedIntent(
+ receivers,
+ intent,
+ 0 /* initialCode */,
+ null /* initialData */,
+ null /* initialExtras */);
+ }));
+ }
+
+ @VisibleForTesting
+ void postOrderedIntent(
+ List<ReceiverRecord> receivers,
+ final Intent intent,
+ int initialCode,
+ @Nullable String initialData,
+ @Nullable Bundle initialExtras) {
+ final AtomicBoolean abort = new AtomicBoolean(false);
+ ListenableFuture<BroadcastResult> resultFuture =
+ Futures.immediateFuture(
+ new BroadcastResult(initialCode, initialData, initialExtras));
+
+ for (ReceiverRecord receiverRecord : receivers) {
+ final BroadcastReceiver receiver = receiverRecord.mBroadcastReceiver;
+ final Context context = receiverRecord.mContext;
+ resultFuture =
+ Futures.transformAsync(
+ resultFuture,
+ new AsyncFunction<BroadcastResult, BroadcastResult>() {
+ @Override
+ public ListenableFuture<BroadcastResult> apply(
+ BroadcastResult input) {
+ PendingResult result = newPendingResult(
+ input.mCode, input.mData, input.mExtras,
+ true /* isOrdered */);
+ ReflectionHelpers.callInstanceMethod(
+ receiver, "setPendingResult",
+ ClassParameter.from(PendingResult.class, result));
+ Shadows.shadowOf(receiver).onReceive(context, intent, abort);
+ return BroadcastResult.transfrom(result);
+ }
+ },
+ MoreExecutors.directExecutor());
+ }
+ Futures.addCallback(
+ resultFuture,
+ new FutureCallback<BroadcastResult>() {
+ @Override
+ public void onSuccess(BroadcastResult result) {
+ return;
+ }
+
+ @Override
+ public void onFailure(Throwable t) {
+ throw new RuntimeException(t);
+ }
+ },
+ MoreExecutors.directExecutor());
+ }
+
+ private List<ReceiverRecord> getMatchingReceivers(Intent intent, boolean isOrdered) {
+ synchronized (mRegisteredReceivers) {
+ List<ReceiverRecord> result = new ArrayList<>();
+ if (!mActions.containsKey(intent.getAction())) {
+ return result;
+ }
+ Iterator<ReceiverRecord> iterator = mActions.get(intent.getAction()).iterator();
+ while (iterator.hasNext()) {
+ ReceiverRecord next = iterator.next();
+ if (match(next.mIntentFilter, intent)) {
+ result.add(next);
+ }
+ }
+ if (isOrdered) {
+ Collections.sort(result, RECEIVER_RECORD_COMPARATOR);
+ }
+ return result;
+ }
+ }
+
+ private boolean match(IntentFilter intentFilter, Intent intent) {
+ // Action test
+ if (!intentFilter.matchAction(intent.getAction())) {
+ return false;
+ }
+ // Category test
+ if (intentFilter.matchCategories(intent.getCategories()) != null) {
+ return false;
+ }
+ // Data test
+ int matchResult =
+ intentFilter.matchData(intent.getType(), intent.getScheme(), intent.getData());
+ return matchResult != IntentFilter.NO_MATCH_TYPE
+ && matchResult != IntentFilter.NO_MATCH_DATA;
+ }
+
+ private static PendingResult newPendingResult(
+ int resultCode, String resultData, Bundle resultExtras, boolean isOrdered) {
+ ClassParameter<?>[] parameters;
+ // PendingResult constructor takes different parameters in different SDK levels.
+ if (VERSION.SDK_INT < 17) {
+ parameters =
+ ClassParameter.fromComponentLists(
+ new Class<?>[]{
+ int.class,
+ String.class,
+ Bundle.class,
+ int.class,
+ boolean.class,
+ boolean.class,
+ IBinder.class
+ },
+ new Object[]{
+ resultCode,
+ resultData,
+ resultExtras,
+ 0 /* type */,
+ isOrdered,
+ false /* sticky */,
+ null /* IBinder */
+ });
+ } else if (VERSION.SDK_INT < 23) {
+ parameters =
+ ClassParameter.fromComponentLists(
+ new Class<?>[]{
+ int.class,
+ String.class,
+ Bundle.class,
+ int.class,
+ boolean.class,
+ boolean.class,
+ IBinder.class,
+ int.class
+ },
+ new Object[]{
+ resultCode,
+ resultData,
+ resultExtras,
+ 0 /* type */,
+ isOrdered,
+ false /* sticky */,
+ null /* IBinder */,
+ 0 /* userId */
+ });
+ } else {
+ parameters =
+ ClassParameter.fromComponentLists(
+ new Class<?>[]{
+ int.class,
+ String.class,
+ Bundle.class,
+ int.class,
+ boolean.class,
+ boolean.class,
+ IBinder.class,
+ int.class,
+ int.class
+ },
+ new Object[]{
+ resultCode,
+ resultData,
+ resultExtras,
+ 0 /* type */,
+ isOrdered,
+ false /* sticky */,
+ null /* IBinder */,
+ 0 /* userId */,
+ 0 /* flags */
+ });
+ }
+ return ReflectionHelpers.callConstructor(PendingResult.class, parameters);
+ }
+
+ /**
+ * Holder of broadcast result from previous receiver.
+ */
+ private static final class BroadcastResult {
+
+ private final int mCode;
+ private final String mData;
+ private final Bundle mExtras;
+
+ BroadcastResult(int code, String data, Bundle extras) {
+ this.mCode = code;
+ this.mData = data;
+ this.mExtras = extras;
+ }
+
+ private static ListenableFuture<BroadcastResult> transfrom(PendingResult result) {
+ return Futures.transform(
+ Shadows.shadowOf(result).getFuture(),
+ new Function<PendingResult, BroadcastResult>() {
+ @Override
+ public BroadcastResult apply(PendingResult input) {
+ return new BroadcastResult(
+ input.getResultCode(), input.getResultData(),
+ input.getResultExtras(false));
+ }
+ },
+ MoreExecutors.directExecutor());
+ }
+ }
+
+ /**
+ * Information of a registered BroadcastReceiver.
+ */
+ @VisibleForTesting
+ static final class ReceiverRecord {
+
+ final BroadcastReceiver mBroadcastReceiver;
+ final IntentFilter mIntentFilter;
+ final Context mContext;
+ final Handler mHandler;
+
+ @VisibleForTesting
+ ReceiverRecord(
+ BroadcastReceiver broadcastReceiver,
+ IntentFilter intentFilter,
+ Context context,
+ Handler handler) {
+ this.mBroadcastReceiver = broadcastReceiver;
+ this.mIntentFilter = intentFilter;
+ this.mContext = context;
+ this.mHandler = handler;
+ }
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/ContentDatabase.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/ContentDatabase.java
new file mode 100644
index 0000000..1f4d778
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/ContentDatabase.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.internal.common;
+
+import android.database.Cursor;
+
+import org.robolectric.fakes.RoboCursor;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Simulate Sqlite database for Android content provider.
+ */
+public class ContentDatabase {
+
+ private final List<String> mColumnNames;
+ private final List<List<Object>> mData;
+
+ public ContentDatabase(String... names) {
+ mColumnNames = Arrays.asList(names);
+ mData = new ArrayList<>();
+ }
+
+ public void addData(Object... items) {
+ mData.add(Arrays.asList(items));
+ }
+
+ public Cursor getCursor() {
+ RoboCursor cursor = new RoboCursor();
+ cursor.setColumnNames(mColumnNames);
+ Object[][] dataArr = new Object[mData.size()][mColumnNames.size()];
+ for (int i = 0; i < mData.size(); i++) {
+ dataArr[i] = new Object[mColumnNames.size()];
+ mData.get(i).toArray(dataArr[i]);
+ }
+ cursor.setResults(dataArr);
+ return cursor;
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/Interrupter.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/Interrupter.java
new file mode 100644
index 0000000..66e9cb0
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/Interrupter.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.internal.common;
+
+import com.android.libraries.testing.deviceshadower.Enums;
+
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Interrupter sets and checks interruptible point, and interrupt operation by throwing
+ * IOException.
+ */
+public class Interrupter {
+
+ private final InheritableThreadLocal<Integer> mCurrentIdentifier;
+ private int mInterruptIdentifier;
+
+ private final Set<Enums.Operation> mInterruptOperations = new HashSet<>();
+
+ public Interrupter() {
+ mCurrentIdentifier = new InheritableThreadLocal<Integer>() {
+ @Override
+ protected Integer initialValue() {
+ return -1;
+ }
+ };
+ }
+
+ public void checkInterrupt() throws IOException {
+ if (mCurrentIdentifier.get() == mInterruptIdentifier) {
+ throw new IOException(
+ "Bluetooth interrupted at identifier: " + mCurrentIdentifier.get());
+ }
+ }
+
+ public void setInterruptible(int identifier) {
+ mCurrentIdentifier.set(identifier);
+ }
+
+ public void interrupt(int identifier) {
+ mInterruptIdentifier = identifier;
+ }
+
+ public void addInterruptOperation(Enums.Operation operation) {
+ mInterruptOperations.add(operation);
+ }
+
+ public boolean shouldInterrupt(Enums.Operation operation) {
+ return mInterruptOperations.contains(operation);
+ }
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/NamedRunnable.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/NamedRunnable.java
new file mode 100644
index 0000000..4e84d71
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/NamedRunnable.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.internal.common;
+
+/**
+ * Runnable with a name defined.
+ */
+public abstract class NamedRunnable implements Runnable {
+
+ private final String mName;
+
+ private NamedRunnable(String name) {
+ this.mName = name;
+ }
+
+ public static NamedRunnable create(String name, Runnable runnable) {
+ return new NamedRunnable(name) {
+ @Override
+ public void run() {
+ runnable.run();
+ }
+ };
+ }
+
+ @Override
+ public String toString() {
+ return mName;
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/Scheduler.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/Scheduler.java
new file mode 100644
index 0000000..96e9b15
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/common/Scheduler.java
@@ -0,0 +1,211 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.internal.common;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.utils.Logger;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import javax.annotation.concurrent.GuardedBy;
+
+/**
+ * Scheduler to post runnables to a single thread.
+ */
+public class Scheduler {
+
+ private static final Logger LOGGER = Logger.create("Scheduler");
+
+ @GuardedBy("Scheduler.class")
+ private static int sTotalRunnables = 0;
+
+ private static CountDownLatch sCompleteLatch;
+
+ public Scheduler() {
+ this(null);
+ }
+
+ public Scheduler(String name) {
+ mExecutor =
+ Executors.newSingleThreadExecutor(
+ r -> {
+ Thread thread = Executors.defaultThreadFactory().newThread(r);
+ if (name != null) {
+ thread.setName(name);
+ }
+ return thread;
+ });
+ }
+
+ public static boolean await(long timeoutMillis) throws InterruptedException {
+
+ synchronized (Scheduler.class) {
+ if (isComplete()) {
+ return true;
+ }
+ if (sCompleteLatch == null) {
+ sCompleteLatch = new CountDownLatch(1);
+ }
+ }
+
+ // TODO(b/200231384): solve potential NPE caused by race condition.
+ boolean result = sCompleteLatch.await(timeoutMillis, TimeUnit.MILLISECONDS);
+ synchronized (Scheduler.class) {
+ sCompleteLatch = null;
+ }
+ return result;
+ }
+
+ private final ExecutorService mExecutor;
+
+ @GuardedBy("this")
+ private final List<ScheduledRunnable> mRunnables = new ArrayList<>();
+
+ @GuardedBy("this")
+ private long mCurrentTimeMillis = 0;
+
+ @GuardedBy("this")
+ private List<ScheduledRunnable> mRunningRunnables = new ArrayList<>();
+
+ /**
+ * Post a {@link NamedRunnable} to scheduler.
+ *
+ * <p>Return value can be ignored because exception will be handled by {@link
+ * DeviceShadowEnvironmentImpl#catchInternalException}.
+ */
+ // @CanIgnoreReturnValue
+ public synchronized Future<?> post(NamedRunnable r) {
+ synchronized (Scheduler.class) {
+ sTotalRunnables++;
+ }
+ advance(0);
+ return mExecutor.submit(new ScheduledRunnable(r, mCurrentTimeMillis).mRunnable);
+ }
+
+ public synchronized void post(NamedRunnable r, long delayMillis) {
+ synchronized (Scheduler.class) {
+ sTotalRunnables++;
+ }
+ addRunnables(new ScheduledRunnable(r, mCurrentTimeMillis + delayMillis));
+ advance(0);
+ }
+
+ public synchronized void shutdown() {
+ mExecutor.shutdown();
+ }
+
+ @VisibleForTesting
+ synchronized void advance(long durationMillis) {
+ mCurrentTimeMillis += durationMillis;
+ while (mRunnables.size() > 0) {
+ ScheduledRunnable r = mRunnables.get(0);
+ if (r.mTimeMillis <= mCurrentTimeMillis) {
+ mRunnables.remove(0);
+ mExecutor.execute(r.mRunnable);
+ } else {
+ break;
+ }
+ }
+ }
+
+ private synchronized void addRunnables(ScheduledRunnable r) {
+ int index = 0;
+ while (index < mRunnables.size() && mRunnables.get(index).mTimeMillis <= r.mTimeMillis) {
+ index++;
+ }
+ mRunnables.add(index, r);
+ }
+
+ @VisibleForTesting
+ static synchronized boolean isComplete() {
+ return sTotalRunnables == 0;
+ }
+
+ // Can only be called by DeviceShadowEnvironmentImpl when reset.
+ public static synchronized void clear() {
+ sTotalRunnables = 0;
+ }
+
+ class ScheduledRunnable {
+
+ final NamedRunnable mRunnable;
+ final long mTimeMillis;
+
+ ScheduledRunnable(final NamedRunnable r, long timeMillis) {
+ this.mTimeMillis = timeMillis;
+ this.mRunnable =
+ NamedRunnable.create(
+ r.toString(),
+ () -> {
+ synchronized (Scheduler.this) {
+ Scheduler.this.mRunningRunnables.add(ScheduledRunnable.this);
+ }
+
+ try {
+ r.run();
+ } catch (Exception e) {
+ LOGGER.e("Error in scheduler runnable " + r, e);
+ DeviceShadowEnvironmentImpl.catchInternalException(e);
+ }
+
+ synchronized (Scheduler.this) {
+ // Remove the last one.
+ Scheduler.this.mRunningRunnables.remove(
+ Scheduler.this.mRunningRunnables.size() - 1);
+ }
+
+ // If this is last runnable,
+ // When this section runs before await:
+ // totalRunnable will be 0, await will return directly.
+ // When this section runs after await:
+ // latch will not be null, count down will terminate await.
+
+ // TODO(b/200231384): when there are two threads running at same
+ // time, there will be a case when totalRunnable is 0, but another
+ // thread pending to acquire Scheduler.class lock to post a
+ // runnable. Hence, await here might not be correct in this case.
+ synchronized (Scheduler.class) {
+ sTotalRunnables--;
+ if (isComplete()) {
+ if (sCompleteLatch != null) {
+ sCompleteLatch.countDown();
+ }
+ }
+ }
+ });
+ }
+
+ @Override
+ public String toString() {
+ return mRunnable.toString();
+ }
+ }
+
+ @Override
+ public synchronized String toString() {
+ return String.format(
+ "\t%d scheduled runnables %s\n\t%d still running or aborted %s",
+ mRunnables.size(), mRunnables, mRunningRunnables.size(), mRunningRunnables);
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/nfc/INfcAdapterImpl.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/nfc/INfcAdapterImpl.java
new file mode 100644
index 0000000..01dcac2
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/nfc/INfcAdapterImpl.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.internal.nfc;
+
+import android.nfc.IAppCallback;
+import android.nfc.INfcAdapter;
+
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+
+/**
+ * Implementation of INfcAdapter
+ */
+public class INfcAdapterImpl implements INfcAdapter {
+
+ public INfcAdapterImpl() {
+ }
+
+ @Override
+ public void setAppCallback(IAppCallback callback) {
+ DeviceShadowEnvironmentImpl.getLocalNfcletImpl().mAppCallback = callback;
+ }
+
+ @Override
+ public boolean enable() {
+ return DeviceShadowEnvironmentImpl.getLocalNfcletImpl().enable();
+ }
+
+ @Override
+ public boolean disable(boolean saveState) {
+ // We do not need to save state because test only run once.
+ return DeviceShadowEnvironmentImpl.getLocalNfcletImpl().disable();
+ }
+
+ @Override
+ public int getState() {
+ return DeviceShadowEnvironmentImpl.getLocalNfcletImpl().getState();
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/nfc/NfcletImpl.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/nfc/NfcletImpl.java
new file mode 100644
index 0000000..137f6b8
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/nfc/NfcletImpl.java
@@ -0,0 +1,127 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.internal.nfc;
+
+import android.content.Intent;
+import android.nfc.BeamShareData;
+import android.nfc.IAppCallback;
+import android.nfc.NdefMessage;
+import android.nfc.NfcAdapter;
+
+import com.android.libraries.testing.deviceshadower.Enums.NfcOperation;
+import com.android.libraries.testing.deviceshadower.Nfclet;
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.common.Interrupter;
+import com.android.libraries.testing.deviceshadower.internal.utils.Logger;
+
+import javax.annotation.concurrent.GuardedBy;
+
+/**
+ * Implementation of Nfclet.
+ */
+public class NfcletImpl implements Nfclet {
+
+ private static final Logger LOGGER = Logger.create("NfcletImpl");
+
+ IAppCallback mAppCallback;
+ private final Interrupter mInterrupter;
+
+ @GuardedBy("this")
+ private int mCurrentState;
+
+ public NfcletImpl() {
+ mInterrupter = new Interrupter();
+ mCurrentState = NfcAdapter.STATE_OFF;
+ }
+
+ public void onNear(NfcletImpl remote) {
+ if (remote.mAppCallback != null) {
+ LOGGER.v("NFC receiver get beam share data from remote");
+ BeamShareData data = remote.mAppCallback.createBeamShareData();
+ DeviceShadowEnvironmentImpl.getLocalDeviceletImpl().getBroadcastManager()
+ .sendBroadcast(createNdefDiscoveredIntent(data), null);
+ }
+ if (mAppCallback != null) {
+ LOGGER.v("NFC sender onNdefPushComplete");
+ mAppCallback.onNdefPushComplete();
+ }
+ }
+
+ public synchronized int getState() {
+ return mCurrentState;
+ }
+
+ public boolean enable() {
+ if (shouldInterrupt(NfcOperation.ENABLE)) {
+ return false;
+ }
+ LOGGER.v("Enable NFC Adapter");
+ updateState(NfcAdapter.STATE_TURNING_ON);
+ updateState(NfcAdapter.STATE_ON);
+ return true;
+ }
+
+ public boolean disable() {
+ if (shouldInterrupt(NfcOperation.DISABLE)) {
+ return false;
+ }
+ LOGGER.v("Disable NFC Adapter");
+ updateState(NfcAdapter.STATE_TURNING_OFF);
+ updateState(NfcAdapter.STATE_OFF);
+ return true;
+ }
+
+ @Override
+ public synchronized Nfclet setInitialState(int state) {
+ mCurrentState = state;
+ return this;
+ }
+
+ @Override
+ public Nfclet setInterruptOperation(NfcOperation operation) {
+ mInterrupter.addInterruptOperation(operation);
+ return this;
+ }
+
+ public boolean shouldInterrupt(NfcOperation operation) {
+ return mInterrupter.shouldInterrupt(operation);
+ }
+
+ private synchronized void updateState(int state) {
+ if (mCurrentState != state) {
+ mCurrentState = state;
+ DeviceShadowEnvironmentImpl.getLocalDeviceletImpl().getBroadcastManager()
+ .sendBroadcast(createAdapterStateChangedIntent(state), null);
+ }
+ }
+
+ private Intent createAdapterStateChangedIntent(int state) {
+ Intent intent = new Intent(NfcAdapter.ACTION_ADAPTER_STATE_CHANGED);
+ intent.putExtra(NfcAdapter.EXTRA_ADAPTER_STATE, state);
+ return intent;
+ }
+
+ private Intent createNdefDiscoveredIntent(BeamShareData data) {
+ Intent intent = new Intent();
+ intent.setAction(NfcAdapter.ACTION_NDEF_DISCOVERED);
+ intent.putExtra(NfcAdapter.EXTRA_NDEF_MESSAGES, new NdefMessage[]{data.ndefMessage});
+ // TODO(b/200231384): uncomment when uri and mime type implemented.
+ // ndefUri = message.getRecords()[0].toUri();
+ // ndefMimeType = message.getRecords()[0].toMimeType();
+ return intent;
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/sms/SmsContentProvider.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/sms/SmsContentProvider.java
new file mode 100644
index 0000000..6bc535b
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/sms/SmsContentProvider.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.internal.sms;
+
+import android.content.ContentProvider;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+
+/**
+ * Content provider for SMS query.
+ */
+public class SmsContentProvider extends ContentProvider {
+
+ public SmsContentProvider() {
+ }
+
+ @Override
+ public String getType(Uri uri) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public int delete(Uri uri, String selection, String[] selectionArgs) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Uri insert(Uri uri, ContentValues values) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean onCreate() {
+ return true;
+ }
+
+ @Override
+ public Cursor query(
+ Uri uri, String[] projection, String selection, String[] selectionArgs,
+ String sortOrder) {
+ return DeviceShadowEnvironmentImpl.getLocalSmsletImpl().getCursor(uri);
+ }
+
+ @Override
+ public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+ throw new UnsupportedOperationException();
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/sms/SmsletImpl.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/sms/SmsletImpl.java
new file mode 100644
index 0000000..00a581e
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/sms/SmsletImpl.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.internal.sms;
+
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.Telephony;
+
+import com.android.libraries.testing.deviceshadower.Smslet;
+import com.android.libraries.testing.deviceshadower.internal.common.ContentDatabase;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Implementation of SMS functionality.
+ */
+public class SmsletImpl implements Smslet {
+
+ private final Map<Uri, ContentDatabase> mUriToDataMap;
+
+ public SmsletImpl() {
+ mUriToDataMap = new HashMap<>();
+ mUriToDataMap.put(
+ Telephony.Sms.Inbox.CONTENT_URI, new ContentDatabase(Telephony.Sms.Inbox.BODY));
+ mUriToDataMap.put(Telephony.Sms.Sent.CONTENT_URI,
+ new ContentDatabase(Telephony.Sms.Inbox.BODY));
+ // TODO(b/200231384): implement Outbox, Intents, Conversations.
+ }
+
+ @Override
+ public Smslet addSms(Uri contentUri, String body) {
+ mUriToDataMap.get(contentUri).addData(body);
+ return this;
+ }
+
+ public Cursor getCursor(Uri uri) {
+ return mUriToDataMap.get(uri).getCursor();
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/utils/GattHelper.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/utils/GattHelper.java
new file mode 100644
index 0000000..f45b125
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/utils/GattHelper.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.internal.utils;
+
+import android.bluetooth.le.AdvertiseData;
+import android.os.ParcelUuid;
+import android.util.SparseArray;
+
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.BluetoothConstants;
+
+import com.google.common.io.ByteArrayDataOutput;
+import com.google.common.io.ByteStreams;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.Charset;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.UUID;
+
+/**
+ * Helper class for Gatt functionality.
+ */
+public class GattHelper {
+
+ public static byte[] convertAdvertiseData(
+ AdvertiseData data, int txPowerLevel, String localName, boolean isConnectable) {
+ if (data == null) {
+ return new byte[0];
+ }
+ ByteArrayDataOutput result = ByteStreams.newDataOutput();
+ if (isConnectable) {
+ writeDataUnit(
+ result,
+ BluetoothConstants.DATA_TYPE_FLAGS,
+ new byte[]{BluetoothConstants.FLAGS_IN_CONNECTABLE_PACKETS});
+ }
+ // tx power level is signed 8-bit int, range -100 to 20.
+ if (data.getIncludeTxPowerLevel()) {
+ writeDataUnit(
+ result,
+ BluetoothConstants.DATA_TYPE_TX_POWER_LEVEL,
+ new byte[]{(byte) txPowerLevel});
+ }
+ // Local name
+ if (data.getIncludeDeviceName()) {
+ writeDataUnit(
+ result,
+ BluetoothConstants.DATA_TYPE_LOCAL_NAME_COMPLETE,
+ localName.getBytes(Charset.defaultCharset()));
+ }
+ // Manufacturer data
+ SparseArray<byte[]> manufacturerData = data.getManufacturerSpecificData();
+ for (int i = 0; i < manufacturerData.size(); i++) {
+ int manufacturerId = manufacturerData.keyAt(i);
+ writeDataUnit(
+ result,
+ BluetoothConstants.DATA_TYPE_MANUFACTURER_SPECIFIC_DATA,
+ parseManufacturerData(manufacturerId, manufacturerData.get(manufacturerId))
+ );
+ }
+ // Service data
+ Map<ParcelUuid, byte[]> serviceData = data.getServiceData();
+ for (Entry<ParcelUuid, byte[]> entry : serviceData.entrySet()) {
+ writeDataUnit(
+ result,
+ BluetoothConstants.DATA_TYPE_SERVICE_DATA,
+ parseServiceData(entry.getKey().getUuid(), entry.getValue())
+ );
+ }
+ // Service UUID, 128-bit UUID in little endian
+ if (data.getServiceUuids() != null && !data.getServiceUuids().isEmpty()) {
+ ByteBuffer uuidBytes =
+ ByteBuffer.allocate(data.getServiceUuids().size() * 16)
+ .order(ByteOrder.LITTLE_ENDIAN);
+ for (ParcelUuid parcelUuid : data.getServiceUuids()) {
+ UUID uuid = parcelUuid.getUuid();
+ uuidBytes.putLong(uuid.getLeastSignificantBits())
+ .putLong(uuid.getMostSignificantBits());
+ }
+ writeDataUnit(
+ result,
+ BluetoothConstants.DATA_TYPE_SERVICE_UUIDS_128_BIT_COMPLETE,
+ uuidBytes.array()
+ );
+ }
+ return result.toByteArray();
+ }
+
+ private static byte[] parseServiceData(UUID uuid, byte[] serviceData) {
+ // First two bytes of the data are data UUID in little endian
+ int length = 2 + serviceData.length;
+ byte[] result = new byte[length];
+ // extract 16-bit UUID value
+ int uuidValue = (int) ((uuid.getMostSignificantBits() & 0x0000FFFF00000000L) >>> 32);
+ result[0] = (byte) (uuidValue & 0xFF);
+ result[1] = (byte) ((uuidValue >> 8) & 0xFF);
+ System.arraycopy(serviceData, 0, result, 2, serviceData.length);
+ return result;
+
+ }
+
+ private static byte[] parseManufacturerData(int manufacturerId, byte[] manufacturerData) {
+ // First two bytes are manufacturer id in little endian.
+ int length = 2 + manufacturerData.length;
+ byte[] result = new byte[length];
+ result[0] = (byte) (manufacturerId & 0xFF);
+ result[1] = (byte) ((manufacturerId >> 8) & 0xFF);
+ System.arraycopy(manufacturerData, 0, result, 2, manufacturerData.length);
+ return result;
+ }
+
+ private static void writeDataUnit(ByteArrayDataOutput output, int type, byte[] data) {
+ // Length includes the length of the field type, which is 1 byte.
+ int length = 1 + data.length;
+ // Length and type are unsigned 8-bit int. Assume the values are valid.
+ output.write(length);
+ output.write(type);
+ output.write(data);
+ }
+
+ private GattHelper() {
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/utils/Logger.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/utils/Logger.java
new file mode 100644
index 0000000..31f7202
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/utils/Logger.java
@@ -0,0 +1,158 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.internal.utils;
+
+import android.text.TextUtils;
+import android.util.Log;
+
+/**
+ * Logger class to provide formatted log for Device Shadower.
+ *
+ * <p>Log is formatted as "[TAG] [Keyword1, Keyword2 ...] Log Message Body".</p>
+ */
+public class Logger {
+
+ private static final String TAG = "DeviceShadower";
+
+ private final String mTag;
+ private final String mPrefix;
+
+ public Logger(String tag, String... keywords) {
+ mTag = tag;
+ mPrefix = buildPrefix(keywords);
+ }
+
+ public static Logger create(String... keywords) {
+ return new Logger(TAG, keywords);
+ }
+
+ private static String buildPrefix(String... keywords) {
+ if (keywords.length == 0) {
+ return "";
+ }
+ return String.format(" [%s] ", TextUtils.join(", ", keywords));
+ }
+
+ /**
+ * @see Log#e(String, String)
+ */
+ public void e(String msg) {
+ Log.e(mTag, format(msg));
+ }
+
+ /**
+ * @see Log#e(String, String, Throwable)
+ */
+ public void e(String msg, Throwable throwable) {
+ Log.e(mTag, format(msg), throwable);
+ }
+
+ /**
+ * @see Log#d(String, String)
+ */
+ public void d(String msg) {
+ Log.d(mTag, format(msg));
+ }
+
+ /**
+ * @see Log#d(String, String, Throwable)
+ */
+ public void d(String msg, Throwable throwable) {
+ Log.d(mTag, format(msg), throwable);
+ }
+
+ /**
+ * @see Log#i(String, String)
+ */
+ public void i(String msg) {
+ Log.i(mTag, format(msg));
+ }
+
+ /**
+ * @see Log#i(String, String, Throwable)
+ */
+ public void i(String msg, Throwable throwable) {
+ Log.i(mTag, format(msg), throwable);
+ }
+
+ /**
+ * @see Log#v(String, String)
+ */
+ public void v(String msg) {
+ Log.v(mTag, format(msg));
+ }
+
+ /**
+ * @see Log#v(String, String, Throwable)
+ */
+ public void v(String msg, Throwable throwable) {
+ Log.v(mTag, format(msg), throwable);
+ }
+
+ /**
+ * @see Log#w(String, String)
+ */
+ public void w(String msg) {
+ Log.w(mTag, format(msg));
+ }
+
+ /**
+ * @see Log#w(String, Throwable)
+ */
+ public void w(Throwable throwable) {
+ Log.w(mTag, null, throwable);
+ }
+
+ /**
+ * @see Log#w(String, String, Throwable)
+ */
+ public void w(String msg, Throwable throwable) {
+ Log.w(mTag, format(msg), throwable);
+ }
+
+ /**
+ * @see Log#wtf(String, String)
+ */
+ public void wtf(String msg) {
+ Log.wtf(mTag, format(msg));
+ }
+
+ /**
+ * @see Log#wtf(String, String, Throwable)
+ */
+ public void wtf(String msg, Throwable throwable) {
+ Log.wtf(mTag, format(msg), throwable);
+ }
+
+ /**
+ * @see Log#isLoggable(String, int)
+ */
+ public boolean isLoggable(int level) {
+ return Log.isLoggable(mTag, level);
+ }
+
+ /**
+ * @see Log#println(int, String, String)
+ */
+ public int println(int priority, String msg) {
+ return Log.println(priority, mTag, format(msg));
+ }
+
+ private String format(String msg) {
+ return mPrefix + msg;
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/utils/MacAddressGenerator.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/utils/MacAddressGenerator.java
new file mode 100644
index 0000000..f8d3193
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/internal/utils/MacAddressGenerator.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.internal.utils;
+
+import android.bluetooth.BluetoothAdapter;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.util.Locale;
+
+import javax.annotation.concurrent.GuardedBy;
+
+/**
+ * A class which generates and converts valid Bluetooth MAC addresses.
+ */
+public class MacAddressGenerator {
+
+ @GuardedBy("MacAddressGenerator.class")
+ private static MacAddressGenerator sInstance = new MacAddressGenerator();
+
+ @VisibleForTesting
+ public static synchronized void setInstanceForTest(MacAddressGenerator generator) {
+ sInstance = generator;
+ }
+
+ public static synchronized MacAddressGenerator get() {
+ return sInstance;
+ }
+
+ private long mLastAddress = 0x0L;
+
+ private MacAddressGenerator() {
+ }
+
+ public String generateMacAddress() {
+ byte[] bytes = generateMacAddressBytes();
+ return convertByteMacAddress(bytes);
+ }
+
+ public byte[] generateMacAddressBytes() {
+ long addr = mLastAddress++;
+ byte[] bytes = new byte[6];
+ for (int i = 5; i >= 0; i--) {
+ bytes[i] = (byte) (addr & 0xFF);
+ addr = addr >> 8;
+ }
+ return bytes;
+ }
+
+ public static byte[] convertStringMacAddress(String address) {
+ if (!BluetoothAdapter.checkBluetoothAddress(address)) {
+ throw new IllegalArgumentException("Not a valid bluetooth mac hex string: " + address);
+ }
+ byte[] bytes = new byte[6];
+ String[] macValues = address.split(":");
+ for (int i = 0; i < bytes.length; i++) {
+ bytes[i] = Integer.decode("0x" + macValues[i]).byteValue();
+ }
+ return bytes;
+ }
+
+ public static String convertByteMacAddress(byte[] address) {
+ if (address == null || address.length != 6) {
+ throw new IllegalArgumentException("Bluetooth address must have 6 bytes");
+ }
+ return String.format(Locale.US, "%02X:%02X:%02X:%02X:%02X:%02X",
+ address[0], address[1], address[2], address[3], address[4], address[5]);
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothA2dp.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothA2dp.java
new file mode 100644
index 0000000..344103b
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothA2dp.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.shadows.bluetooth;
+
+import static com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl.getBlueletImpl;
+import static com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl.getLocalBlueletImpl;
+
+import android.bluetooth.BluetoothA2dp;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothProfile;
+import android.bluetooth.BluetoothProfile.ServiceListener;
+import android.content.Context;
+import android.content.Intent;
+
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.BlueletImpl;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Shadow of the Bluetooth A2DP service.
+ */
+@Implements(BluetoothA2dp.class)
+public class ShadowBluetoothA2dp {
+
+ /**
+ * Hidden in {@link BluetoothProfile}.
+ */
+ public static final int A2DP_SINK = 11;
+
+ private final Map<BluetoothDevice, Integer> mDeviceToConnectionState = new HashMap<>();
+ private Context mContext;
+ @RealObject
+ private BluetoothA2dp mRealObject;
+
+ public void __constructor__(Context context, ServiceListener l) {
+ this.mContext = context;
+ l.onServiceConnected(BluetoothProfile.A2DP, mRealObject);
+ }
+
+ @Implementation
+ public List<BluetoothDevice> getConnectedDevices() {
+ List<BluetoothDevice> result = new ArrayList<>();
+ for (BluetoothDevice device : mDeviceToConnectionState.keySet()) {
+ if (getConnectionState(device) == BluetoothProfile.STATE_CONNECTED) {
+ result.add(device);
+ }
+ }
+ return result;
+ }
+
+ @Implementation
+ public int getConnectionState(BluetoothDevice device) {
+ return mDeviceToConnectionState.containsKey(device)
+ ? mDeviceToConnectionState.get(device)
+ : BluetoothProfile.STATE_DISCONNECTED;
+ }
+
+ @Implementation
+ public boolean connect(BluetoothDevice device) {
+ setConnectionState(BluetoothProfile.STATE_CONNECTING, device);
+ // Only successfully connect if the device is in the environment (i.e. nearby) and accepts
+ // connections.
+ BlueletImpl blueLet = getBlueletImpl(device.getAddress());
+ if (blueLet != null && !blueLet.getRefuseConnections()) {
+ setConnectionState(BluetoothProfile.STATE_CONNECTED, device);
+ } else {
+ // If the device isn't in the environment, still return true (no immediate failure, i.e.
+ // we're trying to connect) but send CONNECTING -> DISCONNECTED (like the OS does).
+ setConnectionState(BluetoothProfile.STATE_DISCONNECTED, device);
+ }
+ return true;
+ }
+
+ @Implementation
+ public void close() {
+ }
+
+ private void setConnectionState(int state, BluetoothDevice device) {
+ int previousState = getConnectionState(device);
+ mDeviceToConnectionState.put(device, state);
+
+ getLocalBlueletImpl()
+ .setProfileConnectionState(BluetoothProfile.A2DP, state, device.getAddress());
+ BlueletImpl remoteDevice = getBlueletImpl(device.getAddress());
+ if (remoteDevice != null) {
+ remoteDevice.setProfileConnectionState(A2DP_SINK, state, getLocalBlueletImpl().address);
+ }
+
+ mContext.sendBroadcast(
+ new Intent(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED)
+ .putExtra(BluetoothProfile.EXTRA_PREVIOUS_STATE, previousState)
+ .putExtra(BluetoothProfile.EXTRA_STATE, state)
+ .putExtra(BluetoothDevice.EXTRA_DEVICE, device));
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothAdapter.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothAdapter.java
new file mode 100644
index 0000000..c9f83a6
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothAdapter.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.shadows.bluetooth;
+
+import android.bluetooth.BluetoothAdapter;
+
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.BlueletImpl;
+import com.android.libraries.testing.deviceshadower.internal.utils.MacAddressGenerator;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+
+/**
+ * Shadow of {@link BluetoothAdapter} to be used with Device Shadower in Robolectric test.
+ */
+@Implements(BluetoothAdapter.class)
+public class ShadowBluetoothAdapter {
+
+ @RealObject
+ BluetoothAdapter mRealAdapter;
+
+ public ShadowBluetoothAdapter() {
+ }
+
+ @Implementation
+ public static synchronized BluetoothAdapter getDefaultAdapter() {
+ // Add a device and set local devicelet in case no local bluelet set
+ if (!DeviceShadowEnvironmentImpl.hasLocalDeviceletImpl()) {
+ String address = MacAddressGenerator.get().generateMacAddress();
+ DeviceShadowEnvironmentImpl.addDevice(address);
+ DeviceShadowEnvironmentImpl.setLocalDevice(address);
+ }
+ BlueletImpl localBluelet = DeviceShadowEnvironmentImpl.getLocalBlueletImpl();
+ return localBluelet.getAdapter();
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothDevice.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothDevice.java
new file mode 100644
index 0000000..247f46e
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothDevice.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.shadows.bluetooth;
+
+import android.bluetooth.BluetoothDevice;
+
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.IBluetoothImpl;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Placeholder for BluetoothDevice improvements
+ */
+@Implements(BluetoothDevice.class)
+public class ShadowBluetoothDevice {
+
+ @RealObject
+ private BluetoothDevice mBluetoothDevice;
+ private static final Map<String, Integer> sBondTransport = new HashMap<>();
+ private static Map<String, Boolean> sPairingConfirmation = new HashMap<>();
+
+ public ShadowBluetoothDevice() {
+ }
+
+ @Implementation
+ public boolean setPasskey(int passkey) {
+ return new IBluetoothImpl().setPasskey(mBluetoothDevice, passkey);
+ }
+
+ @Implementation
+ public boolean createBond(int transport) {
+ sBondTransport.put(mBluetoothDevice.getAddress(), transport);
+ return Shadow.directlyOn(
+ mBluetoothDevice,
+ BluetoothDevice.class,
+ "createBond",
+ ClassParameter.from(int.class, transport));
+ }
+
+ public static int getBondTransport(String address) {
+ return sBondTransport.containsKey(address)
+ ? sBondTransport.get(address)
+ : BluetoothDevice.TRANSPORT_AUTO;
+ }
+
+ @Implementation
+ public boolean setPairingConfirmation(boolean confirm) {
+ sPairingConfirmation.put(mBluetoothDevice.getAddress(), confirm);
+ return Shadow.directlyOn(
+ mBluetoothDevice,
+ BluetoothDevice.class,
+ "setPairingConfirmation",
+ ClassParameter.from(boolean.class, confirm));
+ }
+
+ /**
+ * Gets the confirmation value previously set with a call to {@link
+ * BluetoothDevice#setPairingConfirmation(boolean)}. Default is false.
+ */
+ public static boolean getPairingConfirmation(String address) {
+ return sPairingConfirmation.containsKey(address) && sPairingConfirmation.get(address);
+ }
+
+ /**
+ * Resets the confirmation values.
+ */
+ public static void resetPairingConfirmation() {
+ sPairingConfirmation = new HashMap<>();
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothLeScanner.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothLeScanner.java
new file mode 100644
index 0000000..1f7da14
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothLeScanner.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.shadows.bluetooth;
+
+import android.bluetooth.le.BluetoothLeScanner;
+
+import org.robolectric.annotation.Implements;
+
+/**
+ * Shadow of {@link BluetoothLeScanner} to be used with Device Shadower in Robolectric test.
+ */
+@Implements(BluetoothLeScanner.class)
+public class ShadowBluetoothLeScanner {
+
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothServerSocket.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothServerSocket.java
new file mode 100644
index 0000000..bffcf32
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothServerSocket.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.shadows.bluetooth;
+
+import android.bluetooth.BluetoothServerSocket;
+import android.bluetooth.BluetoothSocket;
+import android.net.LocalSocket;
+import android.os.ParcelFileDescriptor;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.connection.RfcommDelegate;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+import java.io.FileDescriptor;
+import java.io.IOException;
+
+/**
+ * Placeholder for BluetoothServerSocket updates
+ */
+@Implements(BluetoothServerSocket.class)
+public class ShadowBluetoothServerSocket {
+
+ @RealObject
+ BluetoothServerSocket mRealServerSocket;
+
+ public ShadowBluetoothServerSocket() {
+ }
+
+ @Implementation
+ public BluetoothSocket accept(int timeout) throws IOException {
+ FileDescriptor serverSocketFd = getServerSocketFileDescriptor();
+ if (serverSocketFd == null) {
+ throw new IOException("socket is closed.");
+ }
+ RfcommDelegate local = getLocalRfcommDelegate();
+ local.checkInterrupt();
+ FileDescriptor clientFd = local.processNextConnectionRequest(serverSocketFd);
+ // configure the LocalSocket of the BluetoothServerSocket
+ BluetoothSocket internalSocket = ReflectionHelpers.getField(mRealServerSocket, "mSocket");
+ ShadowLocalSocket internalLocalSocket = getLocalSocketShadow(internalSocket);
+ internalLocalSocket.setAncillaryFd(local.getServerFd(clientFd));
+
+ // call original method
+ BluetoothSocket socket = Shadow.directlyOn(mRealServerSocket, BluetoothServerSocket.class,
+ "accept", ClassParameter.from(int.class, timeout));
+
+ // setup local socket of the returned BluetoothSocket
+ String remoteAddress = socket.getRemoteDevice().getAddress();
+ ShadowLocalSocket shadowLocalSocket = getLocalSocketShadow(socket);
+ shadowLocalSocket.setRemoteAddress(remoteAddress);
+ // init connection to client
+ local.initiateConnectToClient(clientFd, getPort());
+ local.waitForConnectionEstablished(clientFd);
+ return socket;
+ }
+
+ @Implementation
+ public void close() throws IOException {
+ getLocalRfcommDelegate().closeServerSocket(getServerSocketFileDescriptor());
+ Shadow.directlyOn(mRealServerSocket, BluetoothServerSocket.class, "close");
+ }
+
+ @VisibleForTesting
+ FileDescriptor getServerSocketFileDescriptor() {
+ BluetoothSocket socket = ReflectionHelpers.getField(mRealServerSocket, "mSocket");
+ ParcelFileDescriptor pfd = ReflectionHelpers.getField(socket, "mPfd");
+ if (pfd == null) {
+ return null;
+ }
+ return pfd.getFileDescriptor();
+ }
+
+ @VisibleForTesting
+ int getPort() {
+ BluetoothSocket socket = ReflectionHelpers.getField(mRealServerSocket, "mSocket");
+ return ReflectionHelpers.getField(socket, "mPort");
+ }
+
+ private ShadowLocalSocket getLocalSocketShadow(BluetoothSocket socket) {
+ LocalSocket localSocket = ReflectionHelpers.getField(socket, "mSocket");
+ return (ShadowLocalSocket) Shadow.extract(localSocket);
+ }
+
+ private RfcommDelegate getLocalRfcommDelegate() {
+ return DeviceShadowEnvironmentImpl.getLocalBlueletImpl().getRfcommDelegate();
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothSocket.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothSocket.java
new file mode 100644
index 0000000..5d417cf
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowBluetoothSocket.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.shadows.bluetooth;
+
+import android.bluetooth.BluetoothSocket;
+import android.os.ParcelFileDescriptor;
+
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.connection.RfcommDelegate;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
+
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * Shadow implementation of a Bluetooth Socket
+ */
+@Implements(BluetoothSocket.class)
+public class ShadowBluetoothSocket {
+
+ @RealObject
+ BluetoothSocket mRealSocket;
+
+ public ShadowBluetoothSocket() {
+ }
+
+ @Implementation
+ public void connect() throws IOException {
+ Shadow.directlyOn(mRealSocket, BluetoothSocket.class, "connect");
+
+ boolean isEncrypted = ReflectionHelpers.getField(mRealSocket, "mEncrypt");
+ FileDescriptor localFd =
+ ((ParcelFileDescriptor) ReflectionHelpers.getField(mRealSocket,
+ "mPfd")).getFileDescriptor();
+ RfcommDelegate local = DeviceShadowEnvironmentImpl.getLocalBlueletImpl()
+ .getRfcommDelegate();
+ String remoteAddress = mRealSocket.getRemoteDevice().getAddress();
+ local.finishPendingConnection(remoteAddress, localFd, isEncrypted);
+
+ ShadowLocalSocket shadowLocalSocket = getLocalSocketShadow();
+ shadowLocalSocket.setRemoteAddress(remoteAddress);
+ }
+
+ @Implementation
+ public InputStream getInputStream() throws IOException {
+ ShadowLocalSocket socket = getLocalSocketShadow();
+ return socket.getInputStream();
+ }
+
+ @Implementation
+ public OutputStream getOutputStream() throws IOException {
+ ShadowLocalSocket socket = getLocalSocketShadow();
+ return socket.getOutputStream();
+ }
+
+ private ShadowLocalSocket getLocalSocketShadow() throws IOException {
+ try {
+ return (ShadowLocalSocket) Shadow.extract(
+ ReflectionHelpers.getField(mRealSocket, "mSocket"));
+ } catch (NullPointerException e) {
+ throw new IOException(e);
+ }
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowLocalSocket.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowLocalSocket.java
new file mode 100644
index 0000000..5189330
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowLocalSocket.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.shadows.bluetooth;
+
+import android.net.LocalSocket;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.BluetoothConstants;
+import com.android.libraries.testing.deviceshadower.internal.bluetooth.connection.RfcommDelegate;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * Shadow implementation of a LocalSocket to make bluetooth connections function.
+ */
+@Implements(LocalSocket.class)
+public class ShadowLocalSocket {
+
+ private String mRemoteAddress;
+ private FileDescriptor mFd;
+ private FileDescriptor mAncillaryFd;
+
+ public ShadowLocalSocket() {
+ }
+
+ public void __constructor__(FileDescriptor fd) {
+ this.mFd = fd;
+ }
+
+ @Implementation
+ public FileDescriptor[] getAncillaryFileDescriptors() throws IOException {
+ return new FileDescriptor[]{mAncillaryFd};
+ }
+
+ @Implementation
+ @SuppressWarnings("InputStreamSlowMultibyteRead")
+ public InputStream getInputStream() throws IOException {
+ final RfcommDelegate local = getLocalRfcommDelegate();
+ return new InputStream() {
+ @Override
+ public int read() throws IOException {
+ int res = local.read(mRemoteAddress, mFd);
+ if (res == BluetoothConstants.SOCKET_CLOSE) {
+ throw new IOException("closed");
+ }
+ return res & 0xFF;
+ }
+ };
+ }
+
+ @Implementation
+ public OutputStream getOutputStream() throws IOException {
+ final RfcommDelegate local = getLocalRfcommDelegate();
+ return new OutputStream() {
+ @Override
+ public void write(int b) throws IOException {
+ local.write(mRemoteAddress, mFd, b);
+ }
+ };
+ }
+
+ @Implementation
+ public void setSoTimeout(int n) throws IOException {
+ // Nothing
+ }
+
+ @Implementation
+ public void shutdownInput() throws IOException {
+ getLocalRfcommDelegate().shutdownInput(mRemoteAddress, mFd);
+ }
+
+ @Implementation
+ public void shutdownOutput() throws IOException {
+ if (mRemoteAddress == null) {
+ return;
+ }
+ getLocalRfcommDelegate().shutdownOutput(mRemoteAddress, mFd);
+ }
+
+ void setAncillaryFd(FileDescriptor fd) {
+ mAncillaryFd = fd;
+ }
+
+ void setRemoteAddress(String address) {
+ mRemoteAddress = address;
+ }
+
+ @VisibleForTesting
+ void setFileDescriptorForTest(FileDescriptor fd) {
+ this.mFd = fd;
+ }
+
+ private RfcommDelegate getLocalRfcommDelegate() {
+ return DeviceShadowEnvironmentImpl.getLocalBlueletImpl().getRfcommDelegate();
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowParcelFileDescriptor.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowParcelFileDescriptor.java
new file mode 100644
index 0000000..585939b
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/bluetooth/ShadowParcelFileDescriptor.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.shadows.bluetooth;
+
+import android.os.ParcelFileDescriptor;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+import java.io.FileDescriptor;
+import java.io.IOException;
+
+/**
+ * Inert implementation of a ParcelFileDescriptor to make bluetooth connections function.
+ */
+@Implements(ParcelFileDescriptor.class)
+public class ShadowParcelFileDescriptor {
+
+ private FileDescriptor mFd;
+
+ public ShadowParcelFileDescriptor() {
+ }
+
+ public void __constructor__(FileDescriptor fd) {
+ this.mFd = fd;
+ }
+
+ @Implementation
+ public FileDescriptor getFileDescriptor() {
+ return mFd;
+ }
+
+ @Implementation
+ public void close() throws IOException {
+ // Nothing
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/common/DeviceShadowContextImpl.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/common/DeviceShadowContextImpl.java
new file mode 100644
index 0000000..9bbcee7
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/common/DeviceShadowContextImpl.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.shadows.common;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.os.Handler;
+import android.util.Log;
+
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.DeviceletImpl;
+import com.android.libraries.testing.deviceshadower.internal.common.BroadcastManager;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadows.ShadowContextImpl;
+
+import javax.annotation.Nullable;
+
+/**
+ * Extends {@link ShadowContextImpl} to achieve automatic method redirection to correct virtual
+ * device.
+ *
+ * <p>Supports:
+ * <li>Broadcasting</li>
+ * Includes send regular, regular sticky, ordered broadcast, and register/unregister receiver.
+ * </p>
+ */
+@Implements(className = "android.app.ContextImpl")
+public class DeviceShadowContextImpl extends ShadowContextImpl {
+
+ private static final String TAG = "DeviceShadowContextImpl";
+
+ @RealObject
+ private Context mContextImpl;
+
+ @Override
+ @Implementation
+ @Nullable
+ public Intent registerReceiver(BroadcastReceiver receiver, IntentFilter filter) {
+ if (receiver == null) {
+ return null;
+ }
+ BroadcastManager manager = getLocalBroadcastManager();
+ if (manager == null) {
+ Log.w(TAG, "Receiver registered before any devices added: " + receiver);
+ return null;
+ }
+ return manager.registerReceiver(
+ receiver, filter, null /* permission */, null /* handler */, mContextImpl);
+ }
+
+ @Override
+ @Implementation
+ @Nullable
+ public Intent registerReceiver(BroadcastReceiver receiver, IntentFilter filter,
+ @Nullable String broadcastPermission, @Nullable Handler scheduler) {
+ return getLocalBroadcastManager().registerReceiver(
+ receiver, filter, broadcastPermission, scheduler, mContextImpl);
+ }
+
+ @Override
+ @Implementation
+ public void unregisterReceiver(BroadcastReceiver broadcastReceiver) {
+ getLocalBroadcastManager().unregisterReceiver(broadcastReceiver);
+ }
+
+ @Override
+ @Implementation
+ public void sendBroadcast(Intent intent) {
+ getLocalBroadcastManager().sendBroadcast(intent, null /* permission */);
+ }
+
+ @Override
+ @Implementation
+ public void sendBroadcast(Intent intent, @Nullable String receiverPermission) {
+ getLocalBroadcastManager().sendBroadcast(intent, receiverPermission);
+ }
+
+ @Override
+ @Implementation
+ public void sendOrderedBroadcast(Intent intent, @Nullable String receiverPermission) {
+ getLocalBroadcastManager().sendOrderedBroadcast(intent, receiverPermission);
+ }
+
+ @Override
+ @Implementation
+ public void sendOrderedBroadcast(Intent intent, @Nullable String receiverPermission,
+ @Nullable BroadcastReceiver resultReceiver, @Nullable Handler scheduler,
+ int initialCode, @Nullable String initialData, @Nullable Bundle initialExtras) {
+ getLocalBroadcastManager().sendOrderedBroadcast(intent, receiverPermission, resultReceiver,
+ scheduler, initialCode, initialData, initialExtras, mContextImpl);
+ }
+
+ @Override
+ @Implementation
+ public void sendStickyBroadcast(Intent intent) {
+ getLocalBroadcastManager().sendStickyBroadcast(intent);
+ }
+
+ private BroadcastManager getLocalBroadcastManager() {
+ DeviceletImpl devicelet = DeviceShadowEnvironmentImpl.getLocalDeviceletImpl();
+ if (devicelet == null) {
+ return null;
+ }
+ return devicelet.getBroadcastManager();
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/nfc/ShadowNfcAdapter.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/nfc/ShadowNfcAdapter.java
new file mode 100644
index 0000000..e7112fb
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/shadows/nfc/ShadowNfcAdapter.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.shadows.nfc;
+
+import static org.robolectric.util.ReflectionHelpers.callConstructor;
+
+import android.content.Context;
+import android.nfc.NfcAdapter;
+
+import com.android.libraries.testing.deviceshadower.Enums.NfcOperation;
+import com.android.libraries.testing.deviceshadower.internal.DeviceShadowEnvironmentImpl;
+import com.android.libraries.testing.deviceshadower.internal.nfc.INfcAdapterImpl;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.util.ReflectionHelpers;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/**
+ * Shadow implementation of Nfc Adapter.
+ */
+@Implements(NfcAdapter.class)
+public class ShadowNfcAdapter {
+
+ @Implementation
+ public static NfcAdapter getDefaultAdapter(Context context) {
+ if (DeviceShadowEnvironmentImpl.getLocalNfcletImpl()
+ .shouldInterrupt(NfcOperation.GET_ADAPTER)) {
+ return null;
+ }
+ ReflectionHelpers.setStaticField(NfcAdapter.class, "sService", new INfcAdapterImpl());
+ return callConstructor(NfcAdapter.class, ClassParameter.from(Context.class, context));
+ }
+
+ // TODO(b/200231384): support state change.
+ public ShadowNfcAdapter() {
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/BaseTestCase.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/BaseTestCase.java
new file mode 100644
index 0000000..8a3c0e7
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/BaseTestCase.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.testcases;
+
+import android.app.Application;
+
+import com.android.libraries.testing.deviceshadower.DeviceShadowEnvironment;
+import com.android.libraries.testing.deviceshadower.shadows.bluetooth.ShadowLocalSocket;
+import com.android.libraries.testing.deviceshadower.shadows.bluetooth.ShadowParcelFileDescriptor;
+import com.android.libraries.testing.deviceshadower.shadows.common.DeviceShadowContextImpl;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.internal.AssumptionViolatedException;
+import org.junit.rules.TestWatcher;
+import org.junit.runner.Description;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+/**
+ * Base class for all DeviceShadower client.
+ */
+@Config(
+ // sdk = 21,
+ shadows = {
+ DeviceShadowContextImpl.class,
+ ShadowParcelFileDescriptor.class,
+ ShadowLocalSocket.class
+ })
+public class BaseTestCase {
+
+ protected Application mContext = RuntimeEnvironment.application;
+
+ /**
+ * Test Watcher which logs test starting and finishing so log messages are easier to read.
+ */
+ @Rule
+ public TestWatcher watcher = new TestWatcher() {
+ @Override
+ protected void succeeded(Description description) {
+ super.succeeded(description);
+ logMessage(
+ String.format("Test %s finished successfully.", description.getDisplayName()));
+ }
+
+ @Override
+ protected void failed(Throwable e, Description description) {
+ super.failed(e, description);
+ logMessage(String.format("Test %s failed.", description.getDisplayName()));
+ }
+
+ @Override
+ protected void skipped(AssumptionViolatedException e, Description description) {
+ super.skipped(e, description);
+ logMessage(String.format("Test %s is skipped.", description.getDisplayName()));
+ }
+
+ @Override
+ protected void starting(Description description) {
+ super.starting(description);
+ logMessage(String.format("Test %s started.", description.getDisplayName()));
+ }
+
+ @Override
+ protected void finished(Description description) {
+ super.finished(description);
+ }
+
+ private void logMessage(String message) {
+ System.out.println("\n*** " + message);
+ }
+ };
+
+ @Before
+ public void setUp() throws Exception {
+ DeviceShadowEnvironment.init();
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ DeviceShadowEnvironment.reset();
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/BluetoothTestCase.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/BluetoothTestCase.java
new file mode 100644
index 0000000..cddc6fe
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/BluetoothTestCase.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.testcases;
+
+import static org.robolectric.Shadows.shadowOf;
+import static org.robolectric.util.ReflectionHelpers.callConstructor;
+
+import android.bluetooth.BluetoothManager;
+import android.content.Context;
+
+import com.android.libraries.testing.deviceshadower.shadows.bluetooth.ShadowBluetoothA2dp;
+import com.android.libraries.testing.deviceshadower.shadows.bluetooth.ShadowBluetoothAdapter;
+import com.android.libraries.testing.deviceshadower.shadows.bluetooth.ShadowBluetoothDevice;
+import com.android.libraries.testing.deviceshadower.shadows.bluetooth.ShadowBluetoothLeScanner;
+import com.android.libraries.testing.deviceshadower.shadows.bluetooth.ShadowBluetoothServerSocket;
+import com.android.libraries.testing.deviceshadower.shadows.bluetooth.ShadowBluetoothSocket;
+
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+import org.robolectric.util.ReflectionHelpers.ClassParameter;
+
+/**
+ * Base class for Bluetooth Test
+ */
+@Config(
+ shadows = {
+ ShadowBluetoothAdapter.class,
+ ShadowBluetoothDevice.class,
+ ShadowBluetoothLeScanner.class,
+ ShadowBluetoothSocket.class,
+ ShadowBluetoothServerSocket.class,
+ ShadowBluetoothA2dp.class
+ })
+public class BluetoothTestCase extends BaseTestCase {
+
+ @Override
+ public void setUp() throws Exception {
+ super.setUp();
+ // TODO(b/28087747): Get bluetooth Manager from robolectric framework.
+ shadowOf(RuntimeEnvironment.application)
+ .setSystemService(
+ Context.BLUETOOTH_SERVICE,
+ callConstructor(BluetoothManager.class,
+ ClassParameter.from(Context.class, mContext)));
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/Matchers.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/Matchers.java
new file mode 100644
index 0000000..3bfe43b
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/Matchers.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.testcases;
+
+import static org.mockito.ArgumentMatchers.argThat;
+
+import android.bluetooth.BluetoothSocket;
+
+import org.mockito.ArgumentMatcher;
+
+/**
+ * Convenient methods to create mockito matchers.
+ */
+public class Matchers {
+
+ private Matchers() {
+ }
+
+ public static <T extends Exception> T exception(final Class<T> clazz, final String... msgs) {
+ return argThat(
+ new ArgumentMatcher<T>() {
+ @Override
+ public boolean matches(T obj) {
+ if (!clazz.isInstance(obj)) {
+ return false;
+ }
+ Throwable exception = clazz.cast(obj);
+ for (String msg : msgs) {
+ if (exception == null || !exception.getMessage().contains(msg)) {
+ return false;
+ }
+ exception = exception.getCause();
+ }
+ return true;
+ }
+ });
+ }
+
+ public static BluetoothSocket socket(final String addr) {
+ return argThat(
+ new ArgumentMatcher<BluetoothSocket>() {
+ @Override
+ public boolean matches(BluetoothSocket obj) {
+ return ((BluetoothSocket) obj)
+ .getRemoteDevice()
+ .getAddress()
+ .toUpperCase()
+ .equals(addr.toUpperCase());
+ }
+ });
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/NfcTestCase.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/NfcTestCase.java
new file mode 100644
index 0000000..a80164b
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/NfcTestCase.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.testcases;
+
+import com.android.libraries.testing.deviceshadower.shadows.nfc.ShadowNfcAdapter;
+
+import org.robolectric.annotation.Config;
+
+/**
+ * Base class for NFC Test
+ */
+@Config(shadows = {ShadowNfcAdapter.class})
+public class NfcTestCase extends BaseTestCase {
+
+}
+
diff --git a/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/SmsTestCase.java b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/SmsTestCase.java
new file mode 100644
index 0000000..edfcc6d
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/libraries/testing/deviceshadower/testcases/SmsTestCase.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2021 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 com.android.libraries.testing.deviceshadower.testcases;
+
+import android.content.pm.ProviderInfo;
+import android.provider.Telephony;
+
+import com.android.libraries.testing.deviceshadower.DeviceShadowEnvironmentInternal;
+
+import org.robolectric.Robolectric;
+
+/**
+ * Base class for SMS Test
+ */
+public class SmsTestCase extends BaseTestCase {
+
+ @Override
+ public void setUp() throws Exception {
+ super.setUp();
+ ProviderInfo info = new ProviderInfo();
+ info.authority = Telephony.Sms.CONTENT_URI.getAuthority();
+ Robolectric.buildContentProvider(
+ DeviceShadowEnvironmentInternal.getSmsContentProviderClass())
+ .create(info);
+ }
+}
diff --git a/nearby/tests/robotests/src/com/android/server/nearby/common/bluetooth/fastpair/BluetoothClassicPairerTest.java b/nearby/tests/robotests/src/com/android/server/nearby/common/bluetooth/fastpair/BluetoothClassicPairerTest.java
new file mode 100644
index 0000000..5d12758
--- /dev/null
+++ b/nearby/tests/robotests/src/com/android/server/nearby/common/bluetooth/fastpair/BluetoothClassicPairerTest.java
@@ -0,0 +1,173 @@
+/*
+ * Copyright 2021 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 com.android.server.nearby.common.bluetooth.fastpair;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.Manifest.permission;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+
+import com.android.libraries.testing.deviceshadower.Bluelet.IoCapabilities;
+import com.android.libraries.testing.deviceshadower.DeviceShadowEnvironment;
+import com.android.libraries.testing.deviceshadower.shadows.bluetooth.ShadowBluetoothDevice;
+import com.android.libraries.testing.deviceshadower.testcases.BluetoothTestCase;
+
+import com.google.common.base.VerifyException;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Tests for {@link BluetoothClassicPairer}.
+ */
+@RunWith(RobolectricTestRunner.class)
+public class BluetoothClassicPairerTest extends BluetoothTestCase {
+
+ private static final String LOCAL_DEVICE_ADDRESS = "AA:AA:AA:AA:AA:01";
+
+ /**
+ * The remote device's Bluetooth Classic address.
+ */
+ private static final String REMOTE_DEVICE_PUBLIC_ADDRESS = "BB:BB:BB:BB:BB:0C";
+
+ private Preferences.Builder mPrefsBuilder;
+
+ @Before
+ @Override
+ public void setUp() throws Exception {
+ super.setUp();
+ mPrefsBuilder = Preferences.builder().setCreateBondTimeoutSeconds(10);
+
+ ShadowBluetoothDevice.resetPairingConfirmation();
+ shadowOf(mContext)
+ .grantPermissions(
+ permission.BLUETOOTH, permission.BLUETOOTH_ADMIN,
+ permission.BLUETOOTH_PRIVILEGED);
+
+ DeviceShadowEnvironment.addDevice(LOCAL_DEVICE_ADDRESS)
+ .bluetooth()
+ .setAdapterInitialState(BluetoothAdapter.STATE_ON)
+ .setIoCapabilities(IoCapabilities.DISPLAY_YES_NO);
+ DeviceShadowEnvironment.addDevice(REMOTE_DEVICE_PUBLIC_ADDRESS)
+ .bluetooth()
+ .setAdapterInitialState(BluetoothAdapter.STATE_ON)
+ .setScanMode(BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE)
+ .setIoCapabilities(IoCapabilities.DISPLAY_YES_NO);
+
+ // By default, code runs as if it's on this virtual "device".
+ DeviceShadowEnvironment.setLocalDevice(LOCAL_DEVICE_ADDRESS);
+ }
+
+ @Test
+ public void pair_setPairingConfirmationTrue_deviceBonded() throws Exception {
+ AtomicReference<BluetoothDevice> targetRemoteDevice = new AtomicReference<>();
+ BluetoothClassicPairer bluetoothClassicPairer =
+ new BluetoothClassicPairer(
+ mContext,
+ BluetoothAdapter.getDefaultAdapter()
+ .getRemoteDevice(REMOTE_DEVICE_PUBLIC_ADDRESS),
+ mPrefsBuilder.build(),
+ (BluetoothDevice remoteDevice, int key) -> {
+ targetRemoteDevice.set(remoteDevice);
+ // Confirms at remote device to pair with local one.
+ setPairingConfirmationAtRemoteDevice(true);
+
+ // Confirms to pair with remote device.
+ remoteDevice.setPairingConfirmation(true);
+ });
+
+ bluetoothClassicPairer.pair();
+
+ assertThat(targetRemoteDevice.get()).isNotNull();
+ assertThat(targetRemoteDevice.get().getAddress()).isEqualTo(REMOTE_DEVICE_PUBLIC_ADDRESS);
+ assertThat(targetRemoteDevice.get().getBondState()).isEqualTo(BluetoothDevice.BOND_BONDED);
+ assertThat(bluetoothClassicPairer.isPaired()).isTrue();
+ }
+
+ @Test
+ public void pair_setPairingConfirmationFalse_throwsExceptionDeviceNotBonded() throws Exception {
+ AtomicReference<BluetoothDevice> targetRemoteDevice = new AtomicReference<>();
+ BluetoothClassicPairer bluetoothClassicPairer =
+ new BluetoothClassicPairer(
+ mContext,
+ BluetoothAdapter.getDefaultAdapter()
+ .getRemoteDevice(REMOTE_DEVICE_PUBLIC_ADDRESS),
+ mPrefsBuilder.build(),
+ (BluetoothDevice remoteDevice, int key) -> {
+ targetRemoteDevice.set(remoteDevice);
+ // Confirms at remote device to pair with local one.
+ setPairingConfirmationAtRemoteDevice(true);
+
+ // Confirms NOT to pair with remote device.
+ remoteDevice.setPairingConfirmation(false);
+ });
+
+ assertThrows(PairingException.class, bluetoothClassicPairer::pair);
+
+ assertThat(targetRemoteDevice.get()).isNotNull();
+ assertThat(targetRemoteDevice.get().getAddress()).isEqualTo(REMOTE_DEVICE_PUBLIC_ADDRESS);
+ assertThat(targetRemoteDevice.get().getBondState()).isNotEqualTo(
+ BluetoothDevice.BOND_BONDED);
+ assertThat(bluetoothClassicPairer.isPaired()).isFalse();
+ }
+
+ @Test
+ public void pair_setPairingConfirmationIgnored_throwsExceptionDeviceNotBonded()
+ throws Exception {
+ AtomicReference<BluetoothDevice> targetRemoteDevice = new AtomicReference<>();
+ BluetoothClassicPairer bluetoothClassicPairer =
+ new BluetoothClassicPairer(
+ mContext,
+ BluetoothAdapter.getDefaultAdapter()
+ .getRemoteDevice(REMOTE_DEVICE_PUBLIC_ADDRESS),
+ mPrefsBuilder.build(),
+ (BluetoothDevice remoteDevice, int key) -> {
+ targetRemoteDevice.set(remoteDevice);
+ // Confirms at remote device to pair with local one.
+ setPairingConfirmationAtRemoteDevice(true);
+
+ // Ignores the setPairingConfirmation.
+ });
+
+ assertThrows(PairingException.class, bluetoothClassicPairer::pair);
+ assertThat(targetRemoteDevice.get()).isNotNull();
+ assertThat(targetRemoteDevice.get().getAddress()).isEqualTo(REMOTE_DEVICE_PUBLIC_ADDRESS);
+ assertThat(targetRemoteDevice.get().getBondState()).isNotEqualTo(
+ BluetoothDevice.BOND_BONDED);
+ assertThat(bluetoothClassicPairer.isPaired()).isFalse();
+ }
+
+ private static void setPairingConfirmationAtRemoteDevice(boolean confirm) {
+ try {
+ DeviceShadowEnvironment.run(REMOTE_DEVICE_PUBLIC_ADDRESS,
+ () -> BluetoothAdapter.getDefaultAdapter()
+ .getRemoteDevice(LOCAL_DEVICE_ADDRESS)
+ .setPairingConfirmation(confirm)).get();
+ } catch (InterruptedException | ExecutionException e) {
+ throw new VerifyException("failed to set pairing confirmation at remote device", e);
+ }
+ }
+}