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);
+        }
+    }
+}