Create a Mobly snippet for Nearby Mainline Fast Pair multidevice tests.

Design doc: go/nearby-mainline-snippet
Test: atest --host NearbyMultiDevicesClientsRoboTest
Ignore-AOSP-First: Nearby is not yet in AOSP.
Bug: 214015364
Change-Id: Id8f551db9cbbe60f4ba0303851de66a644f55c4c
diff --git a/nearby/tests/multidevices/OWNERS b/nearby/tests/multidevices/OWNERS
new file mode 100644
index 0000000..f4dbde2
--- /dev/null
+++ b/nearby/tests/multidevices/OWNERS
@@ -0,0 +1,4 @@
+# Bug component: 1092133
+
+ericth@google.com
+ryancllin@google.com
\ No newline at end of file
diff --git a/nearby/tests/multidevices/clients/Android.bp b/nearby/tests/multidevices/clients/Android.bp
new file mode 100644
index 0000000..72b752b
--- /dev/null
+++ b/nearby/tests/multidevices/clients/Android.bp
@@ -0,0 +1,48 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_library {
+    name: "NearbyMultiDevicesClientsLib",
+    srcs: [
+        "src/**/*.java",
+        "src/**/*.kt",
+    ],
+    sdk_version: "test_current",
+    static_libs: [
+        "NearbyMultiDevicesClientsFastPairLiteProtos",
+        "androidx.test.core",
+        "error_prone_annotations",
+        "fast-pair-lite-protos",
+        "framework-annotations-lib",
+        "kotlin-stdlib",
+        "mobly-snippet-lib",
+        "service-nearby",
+    ],
+}
+
+android_app {
+    name: "NearbyMultiDevicesClientsSnippets",
+    sdk_version: "test_current",
+    certificate: "platform",
+    static_libs: ["NearbyMultiDevicesClientsLib"],
+    optimize: {
+        enabled: true,
+        shrink: true,
+        proguard_flags_files: ["proguard.flags"],
+    },
+}
diff --git a/nearby/tests/multidevices/clients/AndroidManifest.xml b/nearby/tests/multidevices/clients/AndroidManifest.xml
new file mode 100644
index 0000000..b6dc5e8
--- /dev/null
+++ b/nearby/tests/multidevices/clients/AndroidManifest.xml
@@ -0,0 +1,69 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2022 The Android Open Source Project
+  ~
+  ~ Licensed under the Apache License, Version 2.0 (the "License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~      http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="android.nearby.multidevices">
+
+    <uses-feature android:name="android.hardware.bluetooth" />
+    <uses-feature android:name="android.hardware.bluetooth_le" />
+    <uses-permission android:name="android.permission.BLUETOOTH" />
+    <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
+    <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
+    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
+    <uses-permission android:name="android.permission.BLUETOOTH_PRIVILEGED" />
+    <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
+    <uses-permission android:name="android.permission.INTERNET"/>
+    <uses-permission android:name="android.permission.GET_ACCOUNTS"/>
+
+    <application>
+        <meta-data
+            android:name="mobly-log-tag"
+            android:value="NearbyMainlineSnippet" />
+        <meta-data
+            android:name="mobly-snippets"
+            android:value="android.nearby.multidevices.fastpair.seeker.FastPairSeekerSnippet,
+                           android.nearby.multidevices.fastpair.provider.FastPairProviderSimulatorSnippet" />
+
+        <!-- Fast Pair Data Provider Service which acts as an "overlay" to the
+             framework Fast Pair Data Provider. Only supported on Android T and later.
+             All overlays are protected from non-system access via WRITE_SECURE_SETTINGS.
+             Must stay in the same process as Nearby Discovery Service.
+        -->
+        <service
+            android:name=".fastpair.seeker.FastPairTestDataProviderService"
+            android:exported="true"
+            android:permission="android.permission.WRITE_SECURE_SETTINGS"
+            android:visibleToInstantApps="true">
+            <intent-filter>
+                <action android:name="android.nearby.action.FAST_PAIR_DATA_PROVIDER" />
+            </intent-filter>
+
+            <meta-data
+                android:name="instantapps.clients.allowed"
+                android:value="true" />
+            <meta-data
+                android:name="serviceVersion"
+                android:value="1" />
+        </service>
+    </application>
+
+    <instrumentation
+        android:name="com.google.android.mobly.snippet.SnippetRunner"
+        android:label="Nearby Mainline Module Mobly Snippet"
+        android:targetPackage="android.nearby.multidevices" />
+
+</manifest>
diff --git a/nearby/tests/multidevices/clients/proguard.flags b/nearby/tests/multidevices/clients/proguard.flags
new file mode 100644
index 0000000..ec8f526
--- /dev/null
+++ b/nearby/tests/multidevices/clients/proguard.flags
@@ -0,0 +1,24 @@
+# Keep all snippet classes.
+-keep class android.nearby.multidevices.** {
+     *;
+}
+
+# Do not touch Mobly.
+-keep class com.google.android.mobly.** {
+  *;
+}
+
+# Keep names for easy debugging.
+-dontobfuscate
+
+# Necessary to allow debugging.
+-keepattributes *
+
+# By default, proguard leaves all classes in their original package, which
+# needlessly repeats com.google.android.apps.etc.
+-repackageclasses ""
+
+# Allows proguard to make private and protected methods and fields public as
+# part of optimization. This lets proguard inline trivial getter/setter
+# methods.
+-allowaccessmodification
\ No newline at end of file
diff --git a/nearby/tests/multidevices/clients/proto/Android.bp b/nearby/tests/multidevices/clients/proto/Android.bp
new file mode 100644
index 0000000..80e09b4
--- /dev/null
+++ b/nearby/tests/multidevices/clients/proto/Android.bp
@@ -0,0 +1,30 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+java_library {
+    name: "NearbyMultiDevicesClientsFastPairLiteProtos",
+    proto: {
+        type: "lite",
+        canonical_path_from_root: false,
+    },
+    sdk_version: "system_current",
+    min_sdk_version: "30",
+    srcs: ["src/*/*.proto"],
+}
+
+
diff --git a/nearby/tests/multidevices/clients/proto/src/fastpair/event_stream_protocol.proto b/nearby/tests/multidevices/clients/proto/src/fastpair/event_stream_protocol.proto
new file mode 100644
index 0000000..69ed1ea
--- /dev/null
+++ b/nearby/tests/multidevices/clients/proto/src/fastpair/event_stream_protocol.proto
@@ -0,0 +1,85 @@
+syntax = "proto2";
+
+package android.nearby.multidevices.fastpair;
+
+option java_package = "android.nearby.multidevices.fastpair";
+option java_outer_classname = "EventStreamProtocol";
+
+enum EventGroup {
+  UNSPECIFIED = 0;
+  BLUETOOTH = 1;
+  LOGGING = 2;
+  DEVICE = 3;
+  DEVICE_ACTION = 4;
+  DEVICE_CONFIGURATION = 5;
+  DEVICE_CAPABILITY_SYNC = 6;
+  SMART_AUDIO_SOURCE_SWITCHING = 7;
+  ACKNOWLEDGEMENT = 255;
+}
+
+enum BluetoothEventCode {
+  BLUETOOTH_UNSPECIFIED = 0;
+  BLUETOOTH_ENABLE_SILENCE_MODE = 1;
+  BLUETOOTH_DISABLE_SILENCE_MODE = 2;
+}
+
+enum LoggingEventCode {
+  LOG_UNSPECIFIED = 0;
+  LOG_FULL = 1;
+  LOG_SAVE_TO_BUFFER = 2;
+}
+
+enum DeviceEventCode {
+  DEVICE_UNSPECIFIED = 0;
+  DEVICE_MODEL_ID = 1;
+  DEVICE_BLE_ADDRESS = 2;
+  DEVICE_BATTERY_INFO = 3;
+  ACTIVE_COMPONENTS_REQUEST = 5;
+  ACTIVE_COMPONENTS_RESPONSE = 6;
+  DEVICE_CAPABILITY = 7;
+  PLATFORM_TYPE = 8;
+  FIRMWARE_VERSION = 9;
+  SECTION_NONCE = 10;
+}
+
+enum DeviceActionEventCode {
+  DEVICE_ACTION_UNSPECIFIED = 0;
+  DEVICE_ACTION_RING = 1;
+}
+
+enum DeviceConfigurationEventCode {
+  CONFIGURATION_UNSPECIFIED = 0;
+  CONFIGURATION_BUFFER_SIZE = 1;
+}
+
+enum DeviceCapabilitySyncEventCode {
+  REQUEST_UNSPECIFIED = 0;
+  REQUEST_CAPABILITY_UPDATE = 1;
+  CONFIGURABLE_BUFFER_SIZE_RANGE = 2;
+}
+
+enum AcknowledgementEventCode {
+  ACKNOWLEDGEMENT_UNSPECIFIED = 0;
+  ACKNOWLEDGEMENT_ACK = 1;
+  ACKNOWLEDGEMENT_NAK = 2;
+}
+
+enum PlatformType {
+  PLATFORM_TYPE_UNKNOWN = 0;
+  ANDROID = 1;
+}
+
+enum SassEventCode {
+  EVENT_UNSPECIFIED = 0;
+  EVENT_GET_CAPABILITY_OF_SASS = 0x10;
+  EVENT_NOTIFY_CAPABILITY_OF_SASS = 0x11;
+  EVENT_SET_MULTI_POINT_STATE = 0x12;
+  EVENT_SWITCH_AUDIO_SOURCE_BETWEEN_CONNECTED_DEVICES = 0x30;
+  EVENT_SWITCH_BACK = 0x31;
+  EVENT_NOTIFY_MULTIPOINT_SWITCH_EVENT = 0x32;
+  EVENT_GET_CONNECTION_STATUS = 0x33;
+  EVENT_NOTIFY_CONNECTION_STATUS = 0x34;
+  EVENT_SASS_INITIATED_CONNECTION = 0x40;
+  EVENT_INDICATE_IN_USE_ACCOUNT_KEY = 0x41;
+  EVENT_SET_CUSTOM_DATA = 0x42;
+}
diff --git a/nearby/tests/multidevices/clients/src/android/nearby/multidevices/common/SnippetEventHelper.kt b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/common/SnippetEventHelper.kt
new file mode 100644
index 0000000..c4816fb
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/common/SnippetEventHelper.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.nearby.multidevices.common
+
+import android.os.Bundle
+import com.google.android.mobly.snippet.event.EventCache
+import com.google.android.mobly.snippet.event.SnippetEvent
+
+/**
+ * Posts an {@link SnippetEvent} to the event cache with data bundle [fill] by the given function.
+ *
+ * This is a helper function to make your client side codes more concise. Sample usage:
+ * ```
+ *   postSnippetEvent(callbackId, "onReceiverFound") {
+ *     putLong("discoveryTimeMs", discoveryTimeMs)
+ *     putBoolean("isKnown", isKnown)
+ *   }
+ * ```
+ *
+ * @param callbackId the callbackId passed to the {@link
+ * com.google.android.mobly.snippet.rpc.AsyncRpc} method.
+ * @param eventName the name of the event.
+ * @param fill the function to fill the data bundle.
+ */
+fun postSnippetEvent(callbackId: String, eventName: String, fill: Bundle.() -> Unit) {
+  val eventData = Bundle().apply(fill)
+  val snippetEvent = SnippetEvent(callbackId, eventName).apply { data.putAll(eventData) }
+  EventCache.getInstance().postEvent(snippetEvent)
+}
diff --git a/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/provider/BluetoothA2dpSinkService.kt b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/provider/BluetoothA2dpSinkService.kt
new file mode 100644
index 0000000..f65dfab
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/provider/BluetoothA2dpSinkService.kt
@@ -0,0 +1,112 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.nearby.multidevices.fastpair.provider
+
+import android.Manifest.permission.BLUETOOTH_CONNECT
+import android.Manifest.permission.BLUETOOTH_SCAN
+import android.annotation.TargetApi
+import android.bluetooth.BluetoothClass
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothManager
+import android.bluetooth.BluetoothProfile
+import android.content.Context
+import android.os.Build
+import androidx.annotation.RequiresPermission
+import androidx.annotation.VisibleForTesting
+
+/** Maintains an environment for Bluetooth A2DP sink profile. */
+@TargetApi(Build.VERSION_CODES.LOLLIPOP)
+class BluetoothA2dpSinkService(private val context: Context) {
+    private val bluetoothAdapter =
+        (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter!!
+    private var a2dpSinkProxy: BluetoothProfile? = null
+
+    /**
+     * Starts the Bluetooth A2DP sink profile proxy.
+     *
+     * @param onServiceConnected the callback for the first time onServiceConnected.
+     */
+    fun start(onServiceConnected: () -> Unit) {
+        // Get the A2DP proxy before continuing with initialization.
+        bluetoothAdapter.getProfileProxy(
+            context,
+            object : BluetoothProfile.ServiceListener {
+                override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
+                    // When Bluetooth turns off and then on again, this is called again. But we only care
+                    // the first time. There doesn't seem to be a way to unregister our listener.
+                    if (a2dpSinkProxy == null) {
+                        a2dpSinkProxy = proxy
+                        onServiceConnected()
+                    }
+                }
+
+                override fun onServiceDisconnected(profile: Int) {}
+            },
+            BLUETOOTH_PROFILE_A2DP_SINK
+        )
+    }
+
+    /**
+     * Checks the device is paired or not.
+     *
+     * @param remoteBluetoothDevice the device to check is paired or not.
+     */
+    @RequiresPermission(BLUETOOTH_CONNECT)
+    fun isPaired(remoteBluetoothDevice: BluetoothDevice?): Boolean =
+        bluetoothAdapter.bondedDevices.contains(remoteBluetoothDevice)
+
+    /**
+     * Gets the current Bluetooth scan mode of the local Bluetooth adapter.
+     */
+    @RequiresPermission(BLUETOOTH_SCAN)
+    fun getScanMode(): Int = bluetoothAdapter.scanMode
+
+    /**
+     * Clears the bounded devices.
+     *
+     * @param removeBondDevice the callback to remove bounded devices.
+     */
+    @RequiresPermission(BLUETOOTH_CONNECT)
+    fun clearBoundedDevices(removeBondDevice: (BluetoothDevice) -> Unit) {
+        for (device in bluetoothAdapter.bondedDevices) {
+            if (device.bluetoothClass.majorDeviceClass == BluetoothClass.Device.Major.PHONE) {
+                removeBondDevice(device)
+            }
+        }
+    }
+
+    /**
+     * Clears the connected but unbounded devices.
+     *
+     * Sometimes a device will still be connected even though it's not bonded. :( Clear that too.
+     *
+     * @param disconnectDevice the callback to clear connected but unbounded devices.
+     */
+    fun clearConnectedUnboundedDevices(
+        disconnectDevice: (BluetoothProfile, BluetoothDevice) -> Unit,
+    ) {
+        for (device in a2dpSinkProxy!!.connectedDevices) {
+            disconnectDevice(a2dpSinkProxy!!, device)
+        }
+    }
+
+    companion object {
+        /** Hidden SystemApi field in [android.bluetooth.BluetoothProfile] interface. */
+        @VisibleForTesting
+        const val BLUETOOTH_PROFILE_A2DP_SINK = 11
+    }
+}
diff --git a/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/provider/BluetoothStateChangeReceiver.kt b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/provider/BluetoothStateChangeReceiver.kt
new file mode 100644
index 0000000..25cc637
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/provider/BluetoothStateChangeReceiver.kt
@@ -0,0 +1,132 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.nearby.multidevices.fastpair.provider
+
+import android.Manifest.permission.BLUETOOTH
+import android.Manifest.permission.BLUETOOTH_CONNECT
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothAdapter.SCAN_MODE_CONNECTABLE
+import android.bluetooth.BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE
+import android.bluetooth.BluetoothAdapter.SCAN_MODE_NONE
+import android.bluetooth.BluetoothDevice
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.util.Log
+import androidx.annotation.RequiresPermission
+import androidx.annotation.VisibleForTesting
+
+/** Processes the state of the local Bluetooth adapter. */
+class BluetoothStateChangeReceiver(private val context: Context) : BroadcastReceiver() {
+    @VisibleForTesting
+    var listener: EventListener? = null
+
+    /**
+     * Registers this Bluetooth state change receiver.
+     *
+     * @param listener the listener for Bluetooth state events.
+     */
+    fun register(listener: EventListener) {
+        this.listener = listener
+        val bondStateFilter =
+            IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED).apply {
+                addAction(BluetoothAdapter.ACTION_CONNECTION_STATE_CHANGED)
+                addAction(BluetoothAdapter.ACTION_SCAN_MODE_CHANGED)
+            }
+        context.registerReceiver(
+            this,
+            bondStateFilter,
+            /* broadcastPermission= */ null,
+            /* scheduler= */ null
+        )
+    }
+
+    /** Unregisters this Bluetooth state change receiver. */
+    fun unregister() {
+        context.unregisterReceiver(this)
+        this.listener = null
+    }
+
+    /**
+     * Callback method for receiving Intent broadcast for Bluetooth state.
+     *
+     * See [android.content.BroadcastReceiver#onReceive].
+     *
+     * @param context the Context in which the receiver is running.
+     * @param intent the Intent being received.
+     */
+    @RequiresPermission(allOf = [BLUETOOTH, BLUETOOTH_CONNECT])
+    override fun onReceive(context: Context, intent: Intent) {
+        Log.i(TAG, "BluetoothStateChangeReceiver received intent, action=${intent.action}")
+
+        when (intent.action) {
+            BluetoothAdapter.ACTION_SCAN_MODE_CHANGED -> {
+                val scanMode =
+                    intent.getIntExtra(BluetoothAdapter.EXTRA_SCAN_MODE, BluetoothAdapter.SCAN_MODE_NONE)
+                val scanModeStr = scanModeToString(scanMode)
+                Log.i(TAG, "ACTION_SCAN_MODE_CHANGED, the new scanMode: $scanModeStr")
+                listener?.onScanModeChange(scanModeStr)
+            }
+            BluetoothDevice.ACTION_BOND_STATE_CHANGED -> {
+                val remoteDevice = intent.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE)
+                val remoteDeviceString =
+                    if (remoteDevice != null) "${remoteDevice.name}-${remoteDevice.address}" else "none"
+                var boundStateString = "ERROR"
+                when (intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.ERROR)) {
+                    BluetoothDevice.BOND_NONE -> {
+                        boundStateString = "BOND_NONE"
+                    }
+                    BluetoothDevice.BOND_BONDING -> {
+                        boundStateString = "BOND_BONDING"
+                    }
+                    BluetoothDevice.BOND_BONDED -> {
+                        boundStateString = "BOND_BONDED"
+                    }
+                }
+                Log.i(
+                    TAG,
+                    "The bound state of the remote device ($remoteDeviceString) change to $boundStateString."
+                )
+            }
+            else -> {}
+        }
+    }
+
+    private fun scanModeToString(scanMode: Int): String {
+        return when (scanMode) {
+            SCAN_MODE_CONNECTABLE_DISCOVERABLE -> "DISCOVERABLE"
+            SCAN_MODE_CONNECTABLE -> "CONNECTABLE"
+            SCAN_MODE_NONE -> "NOT CONNECTABLE"
+            else -> "UNKNOWN($scanMode)"
+        }
+    }
+
+    /** Interface for listening the events from Bluetooth adapter. */
+    interface EventListener {
+        /**
+         * Reports the current scan mode of the local Adapter.
+         *
+         * @param mode the current scan mode in string.
+         */
+        fun onScanModeChange(mode: String)
+    }
+
+    companion object {
+        private const val TAG = "BluetoothStateReceiver"
+    }
+}
diff --git a/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/provider/FastPairProviderSimulatorSnippet.kt b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/provider/FastPairProviderSimulatorSnippet.kt
new file mode 100644
index 0000000..a2f50b4
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/provider/FastPairProviderSimulatorSnippet.kt
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.nearby.multidevices.fastpair.provider
+
+import android.annotation.TargetApi
+import android.bluetooth.le.AdvertiseSettings
+import android.content.Context
+import android.os.Build
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.server.nearby.common.bluetooth.fastpair.testing.FastPairSimulator
+import com.google.android.mobly.snippet.Snippet
+import com.google.android.mobly.snippet.rpc.AsyncRpc
+import com.google.android.mobly.snippet.rpc.Rpc
+import com.google.android.mobly.snippet.util.Log
+import com.google.common.io.BaseEncoding.base64
+
+/** Expose Mobly RPC methods for Python side to simulate fast pair provider role. */
+@TargetApi(Build.VERSION_CODES.LOLLIPOP)
+class FastPairProviderSimulatorSnippet : Snippet {
+    private val context: Context = InstrumentationRegistry.getInstrumentation().context
+    private val bluetoothA2dpSinkService = BluetoothA2dpSinkService(context)
+    private val bluetoothStateChangeReceiver = BluetoothStateChangeReceiver(context)
+    private lateinit var fastPairSimulator: FastPairSimulator
+    private lateinit var providerStatusEvents: ProviderStatusEvents
+
+    /**
+     * Starts the Fast Pair provider simulator.
+     *
+     * @param callbackId the callback ID corresponding to the
+     * [FastPairProviderSimulatorSnippet#startProviderSimulator] call that started the scanning.
+     * @param modelId a 3-byte hex string for seeker side to recognize the device (ex: 0x00000C).
+     * @param antiSpoofingKeyString a public key for registered headsets.
+     */
+    @AsyncRpc(description = "Starts FP provider simulator for seekers to discover.")
+    fun startProviderSimulator(callbackId: String, modelId: String, antiSpoofingKeyString: String) {
+        providerStatusEvents = ProviderStatusEvents(callbackId)
+        bluetoothStateChangeReceiver.register(listener = providerStatusEvents)
+        bluetoothA2dpSinkService.start { createFastPairSimulator(modelId, antiSpoofingKeyString) }
+    }
+
+    /** Stops the Fast Pair provider simulator. */
+    @Rpc(description = "Stops FP provider simulator.")
+    fun stopProviderSimulator() {
+        fastPairSimulator.destroy()
+        bluetoothStateChangeReceiver.unregister()
+    }
+
+    /** Gets BLE mac address of the Fast Pair provider simulator. */
+    @Rpc(description = "Gets BLE mac address of the Fast Pair provider simulator.")
+    fun getBluetoothLeAddress(): String {
+        return fastPairSimulator.bleAddress!!
+    }
+
+    private fun createFastPairSimulator(modelId: String, antiSpoofingKeyString: String) {
+        val antiSpoofingKey = base64().decode(antiSpoofingKeyString)
+        fastPairSimulator =
+            FastPairSimulator(
+                context,
+                FastPairSimulator.Options.builder(modelId)
+                    .setAdvertisingModelId(modelId)
+                    .setBluetoothAddress(null)
+                    .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
+                    .setCallback {
+                        val isAdvertising = fastPairSimulator.isAdvertising
+                        Log.i("FastPairSimulator callback(), isAdvertising: $isAdvertising")
+                        providerStatusEvents.onAdvertisingChange(isAdvertising)
+                    }
+                    .setAntiSpoofingPrivateKey(antiSpoofingKey)
+                    .setUseRandomSaltForAccountKeyRotation(false)
+                    .setDataOnlyConnection(false)
+                    .setIsMemoryTest(false)
+                    .setShowsPasskeyConfirmation(false)
+                    .setRemoveAllDevicesDuringPairing(true)
+                    .build()
+            )
+    }
+}
diff --git a/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/provider/ProviderStatusEvents.kt b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/provider/ProviderStatusEvents.kt
new file mode 100644
index 0000000..20c8e85
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/provider/ProviderStatusEvents.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.nearby.multidevices.fastpair.provider
+
+import android.nearby.multidevices.common.postSnippetEvent
+
+/** The Mobly snippet events to report to the Python side. */
+class ProviderStatusEvents(private val callbackId: String) :
+  BluetoothStateChangeReceiver.EventListener {
+
+  /**
+   * Indicates the Bluetooth scan mode of the Fast Pair provider simulator has changed.
+   *
+   * @param mode the current scan mode in String mapping by [FastPairSimulator#scanModeToString].
+   */
+  override fun onScanModeChange(mode: String) {
+    postSnippetEvent(callbackId, "onScanModeChange") { putString("mode", mode) }
+  }
+
+  /**
+   * Indicates the advertising state of the Fast Pair provider simulator has changed.
+   *
+   * @param isAdvertising the current advertising state, true if advertising otherwise false.
+   */
+  fun onAdvertisingChange(isAdvertising: Boolean) {
+    postSnippetEvent(callbackId, "onAdvertisingChange") {
+      putBoolean("isAdvertising", isAdvertising)
+    }
+  }
+}
diff --git a/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/CompanionAppUtils.kt b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/CompanionAppUtils.kt
new file mode 100644
index 0000000..7ed4372
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/CompanionAppUtils.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.nearby.multidevices.fastpair.seeker
+
+fun generateCompanionAppLaunchIntentUri(
+        companionAppPackageName: String? = null,
+        activityName: String? = null,
+        action: String? = null
+): String {
+    if (companionAppPackageName.isNullOrEmpty() || activityName.isNullOrEmpty()) {
+        return ""
+    }
+    var intentUriString = "intent:#Intent;"
+    if (!action.isNullOrEmpty()) {
+        intentUriString += "action=$action;"
+    }
+    intentUriString += "package=$companionAppPackageName;"
+    intentUriString += "component=$companionAppPackageName/$activityName;"
+    return "${intentUriString}end"
+}
diff --git a/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/FastPairSeekerSnippet.kt b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/FastPairSeekerSnippet.kt
new file mode 100644
index 0000000..add0bc3
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/FastPairSeekerSnippet.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.nearby.multidevices.fastpair.seeker
+
+import android.content.Context
+import android.content.Intent
+import android.nearby.NearbyManager
+import android.nearby.ScanCallback
+import android.nearby.ScanRequest
+import androidx.test.core.app.ApplicationProvider
+import com.google.android.mobly.snippet.Snippet
+import com.google.android.mobly.snippet.rpc.AsyncRpc
+import com.google.android.mobly.snippet.rpc.Rpc
+import com.google.android.mobly.snippet.util.Log
+
+/** Expose Mobly RPC methods for Python side to test fast pair seeker role. */
+class FastPairSeekerSnippet : Snippet {
+    private val appContext = ApplicationProvider.getApplicationContext<Context>()
+    private val nearbyManager = appContext.getSystemService(Context.NEARBY_SERVICE) as NearbyManager
+    private lateinit var scanCallback: ScanCallback
+
+    /**
+     * Starts scanning as a Fast Pair seeker to find Fast Pair provider devices.
+     *
+     * @param callbackId the callback ID corresponding to the {@link FastPairSeekerSnippet#startScan}
+     * call that started the scanning.
+     */
+    @AsyncRpc(description = "Starts scanning as Fast Pair seeker to find Fast Pair provider devices.")
+    fun startScan(callbackId: String) {
+        val scanRequest = ScanRequest.Builder()
+                .setScanMode(ScanRequest.SCAN_MODE_LOW_LATENCY)
+                .setScanType(ScanRequest.SCAN_TYPE_FAST_PAIR)
+                .setEnableBle(true)
+                .build()
+        scanCallback = ScanCallbackEvents(callbackId)
+
+        Log.i("Start Fast Pair scanning via BLE...")
+        nearbyManager.startScan(scanRequest, /* executor */ { it.run() }, scanCallback)
+    }
+
+    /** Stops the Fast Pair seeker scanning. */
+    @Rpc(description = "Stops the Fast Pair seeker scanning.")
+    fun stopScan() {
+        Log.i("Stop Fast Pair scanning.")
+        nearbyManager.stopScan(scanCallback)
+    }
+
+    /** Starts the Fast Pair seeker pairing. */
+    @Rpc(description = "Starts the Fast Pair seeker pairing.")
+    fun startPairing(modelId: String, address: String) {
+        Log.i("Starts the Fast Pair seeker pairing.")
+
+        val scanIntent = Intent().apply {
+            action = FAST_PAIR_MANAGER_ACTION_START_PAIRING
+            putExtra(FAST_PAIR_MANAGER_EXTRA_MODEL_ID, modelId.toByteArray())
+            putExtra(FAST_PAIR_MANAGER_EXTRA_ADDRESS, address)
+        }
+        appContext.sendBroadcast(scanIntent)
+    }
+
+    companion object {
+        private const val FAST_PAIR_MANAGER_ACTION_START_PAIRING = "NEARBY_START_PAIRING"
+        private const val FAST_PAIR_MANAGER_EXTRA_MODEL_ID = "MODELID"
+        private const val FAST_PAIR_MANAGER_EXTRA_ADDRESS = "ADDRESS"
+    }
+}
\ No newline at end of file
diff --git a/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/FastPairTestDataProvider.kt b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/FastPairTestDataProvider.kt
new file mode 100644
index 0000000..5a30fcf
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/FastPairTestDataProvider.kt
@@ -0,0 +1,281 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.nearby.multidevices.fastpair.seeker
+
+import android.accounts.Account
+import android.nearby.*
+import android.util.Log
+import service.proto.Cache
+import service.proto.Rpcs.DeviceType
+import java.util.*
+
+class FastPairTestDataProvider : FastPairDataProviderBase(TAG) {
+    private val fastPairDeviceMetadata = FastPairDeviceMetadata.Builder()
+        .setAssistantSetupHalfSheet(ASSISTANT_SETUP_HALF_SHEET_TEST_CONSTANT)
+        .setAssistantSetupNotification(ASSISTANT_SETUP_NOTIFICATION_TEST_CONSTANT)
+        .setBleTxPower(BLE_TX_POWER_TEST_CONSTANT)
+        .setConfirmPinDescription(CONFIRM_PIN_DESCRIPTION_TEST_CONSTANT)
+        .setConfirmPinTitle(CONFIRM_PIN_TITLE_TEST_CONSTANT)
+        .setConnectSuccessCompanionAppInstalled(CONNECT_SUCCESS_COMPANION_APP_INSTALLED_TEST_CONSTANT)
+        .setConnectSuccessCompanionAppNotInstalled(CONNECT_SUCCESS_COMPANION_APP_NOT_INSTALLED_TEST_CONSTANT)
+        .setDeviceType(DEVICE_TYPE_HEAD_PHONES_TEST_CONSTANT)
+        .setDownloadCompanionAppDescription(DOWNLOAD_COMPANION_APP_DESCRIPTION_TEST_CONSTANT)
+        .setFailConnectGoToSettingsDescription(FAIL_CONNECT_GOTO_SETTINGS_DESCRIPTION_TEST_CONSTANT)
+        .setFastPairTvConnectDeviceNoAccountDescription(TV_CONNECT_DEVICE_NO_ACCOUNT_DESCRIPTION_TEST_CONSTANT)
+        .setInitialNotificationDescription(INITIAL_NOTIFICATION_DESCRIPTION_TEST_CONSTANT)
+        .setInitialNotificationDescriptionNoAccount(INITIAL_NOTIFICATION_DESCRIPTION_NO_ACCOUNT_TEST_CONSTANT)
+        .setInitialPairingDescription(INITIAL_PAIRING_DESCRIPTION_TEST_CONSTANT)
+        .setLocale(LOCALE_US_LANGUAGE_TEST_CONSTANT)
+        .setImage(IMAGE_BYTE_ARRAY_FAKE_TEST_CONSTANT)
+        .setImageUrl(IMAGE_URL_TEST_CONSTANT)
+        .setIntentUri(
+            generateCompanionAppLaunchIntentUri(
+                companionAppPackageName = COMPANION_APP_PACKAGE_TEST_CONSTANT,
+                activityName = COMPANION_APP_ACTIVITY_TEST_CONSTANT,
+            )
+        )
+        .setOpenCompanionAppDescription(OPEN_COMPANION_APP_DESCRIPTION_TEST_CONSTANT)
+        .setRetroactivePairingDescription(RETRO_ACTIVE_PAIRING_DESCRIPTION_TEST_CONSTANT)
+        .setSubsequentPairingDescription(SUBSEQUENT_PAIRING_DESCRIPTION_TEST_CONSTANT)
+        .setSyncContactsDescription(SYNC_CONTACT_DESCRIPTION_TEST_CONSTANT)
+        .setSyncContactsTitle(SYNC_CONTACTS_TITLE_TEST_CONSTANT)
+        .setSyncSmsDescription(SYNC_SMS_DESCRIPTION_TEST_CONSTANT)
+        .setSyncSmsTitle(SYNC_SMS_TITLE_TEST_CONSTANT)
+        .setTriggerDistance(TRIGGER_DISTANCE_TEST_CONSTANT)
+        .setTrueWirelessImageUrlCase(TRUE_WIRELESS_IMAGE_URL_CASE_TEST_CONSTANT)
+        .setTrueWirelessImageUrlLeftBud(TRUE_WIRELESS_IMAGE_URL_LEFT_BUD_TEST_CONSTANT)
+        .setTrueWirelessImageUrlRightBud(TRUE_WIRELESS_IMAGE_URL_RIGHT_BUD_TEST_CONSTANT)
+        .setUnableToConnectDescription(UNABLE_TO_CONNECT_DESCRIPTION_TEST_CONSTANT)
+        .setUnableToConnectTitle(UNABLE_TO_CONNECT_TITLE_TEST_CONSTANT)
+        .setUpdateCompanionAppDescription(UPDATE_COMPANION_APP_DESCRIPTION_TEST_CONSTANT)
+        .setWaitLaunchCompanionAppDescription(WAIT_LAUNCH_COMPANION_APP_DESCRIPTION_TEST_CONSTANT)
+        .build()
+
+    override fun onLoadFastPairAntispoofkeyDeviceMetadata(
+        request: FastPairAntispoofkeyDeviceMetadataRequest,
+        callback: FastPairAntispoofkeyDeviceMetadataCallback
+    ) {
+        val requestedModelId = request.modelId.bytesToStringLowerCase()
+        Log.d(TAG, "onLoadFastPairAntispoofkeyDeviceMetadata(modelId: $requestedModelId)")
+
+        val fastPairAntiSpoofKeyDeviceMetadata =
+            FastPairAntispoofkeyDeviceMetadata.Builder()
+                .setAntiSpoofPublicKey(ANTI_SPOOF_PUBLIC_KEY_BYTE_ARRAY)
+                .setFastPairDeviceMetadata(fastPairDeviceMetadata)
+                .build()
+
+        callback.onFastPairAntispoofkeyDeviceMetadataReceived(fastPairAntiSpoofKeyDeviceMetadata)
+    }
+
+    override fun onLoadFastPairAccountDevicesMetadata(
+        request: FastPairAccountDevicesMetadataRequest,
+        callback: FastPairAccountDevicesMetadataCallback
+    ) {
+        val requestedAccount = request.account
+        Log.d(TAG, "onLoadFastPairAccountDevicesMetadata(account: $requestedAccount)")
+        val discoveryItem = FastPairDiscoveryItem.Builder()
+            .setActionUrl(ACTION_URL_TEST_CONSTANT)
+            .setActionUrlType(ACTION_URL_TYPE_TEST_CONSTANT)
+            .setAppName(APP_NAME_TEST_CONSTANT)
+            .setAttachmentType(ATTACHMENT_TYPE_TEST_CONSTANT)
+            .setAuthenticationPublicKeySecp256r1(AUTHENTICATION_PUBLIC_KEY_SEC_P256R1_TEST_CONSTANT)
+            .setBleRecordBytes(BLE_RECORD_BYTES_TEST_CONSTANT)
+            .setDebugCategory(DEBUG_CATEGORY_TEST_CONSTANT)
+            .setDebugMessage(DEBUG_MESSAGE_TEST_CONSTANT)
+            .setDescription(DESCRIPTION_TEST_CONSTANT)
+            .setDeviceName(DEVICE_NAME_TEST_CONSTANT)
+            .setDisplayUrl(DISPLAY_URL_TEST_CONSTANT)
+            .setEntityId(ENTITY_ID_TEST_CONSTANT)
+            .setFeatureGraphicUrl(FEATURE_GRAPHIC_URL_TEST_CONSTANT)
+            .setFirstObservationTimestampMillis(FIRST_OBSERVATION_TIMESTAMP_MILLIS_TEST_CONSTANT)
+            .setGroupId(GROUP_ID_TEST_CONSTANT)
+            .setIconFfeUrl(ICON_FIFE_URL_TEST_CONSTANT)
+            .setIconPng(ICON_PNG_TEST_CONSTANT)
+            .setId(ID_TEST_CONSTANT)
+            .setLastObservationTimestampMillis(LAST_OBSERVATION_TIMESTAMP_MILLIS_TEST_CONSTANT)
+            .setLastUserExperience(LAST_USER_EXPERIENCE_TEST_CONSTANT)
+            .setLostMillis(LOST_MILLIS_TEST_CONSTANT)
+            .setMacAddress(MAC_ADDRESS_TEST_CONSTANT)
+            .setPackageName(PACKAGE_NAME_TEST_CONSTANT)
+            .setPendingAppInstallTimestampMillis(PENDING_APP_INSTALL_TIMESTAMP_MILLIS_TEST_CONSTANT)
+            .setRssi(RSSI_TEST_CONSTANT)
+            .setState(STATE_TEST_CONSTANT)
+            .setTitle(TITLE_TEST_CONSTANT)
+            .setTriggerId(TRIGGER_ID_TEST_CONSTANT)
+            .setTxPower(TX_POWER_TEST_CONSTANT)
+            .setType(TYPE_TEST_CONSTANT)
+            .build()
+        val accountDevicesMetadataList = listOf(
+            FastPairAccountKeyDeviceMetadata.Builder()
+                .setAccountKey(ACCOUNT_KEY_TEST_CONSTANT)
+                .setFastPairDeviceMetadata(fastPairDeviceMetadata)
+                .setSha256AccountKeyPublicAddress(SHA256_ACCOUNT_KEY_PUBLIC_ADDRESS_TEST_CONSTANT)
+                .setFastPairDiscoveryItem(discoveryItem)
+                .build()
+        )
+
+        callback.onFastPairAccountDevicesMetadataReceived(accountDevicesMetadataList)
+    }
+
+    override fun onLoadFastPairEligibleAccounts(
+        request: FastPairEligibleAccountsRequest,
+        callback: FastPairEligibleAccountsCallback
+    ) {
+        Log.d(TAG, "onLoadFastPairEligibleAccounts()")
+        callback.onFastPairEligibleAccountsReceived(ELIGIBLE_ACCOUNTS_TEST_CONSTANT)
+    }
+
+    override fun onManageFastPairAccount(
+        request: FastPairManageAccountRequest, callback: FastPairManageActionCallback
+    ) {
+        val requestedAccount = request.account
+        val requestType = request.requestType
+        Log.d(TAG, "onManageFastPairAccount(account: $requestedAccount, requestType: $requestType)")
+
+        callback.onSuccess()
+    }
+
+    override fun onManageFastPairAccountDevice(
+        request: FastPairManageAccountDeviceRequest, callback: FastPairManageActionCallback
+    ) {
+        val requestedAccount = request.account
+        val requestType = request.requestType
+        val requestedBleAddress = request.bleAddress
+        val requestedAccountKeyDeviceMetadata = request.accountKeyDeviceMetadata
+        Log.d(TAG, "onManageFastPairAccountDevice(requestedAccount: $requestedAccount, requestType: $requestType,")
+        Log.d(TAG, "requestedBleAddress: $requestedBleAddress,")
+        Log.d(TAG, "requestedAccountKeyDeviceMetadata: $requestedAccountKeyDeviceMetadata)")
+
+        callback.onSuccess()
+    }
+
+    companion object {
+        private const val TAG = "FastPairTestDataProvider"
+
+        private const val BLE_TX_POWER_TEST_CONSTANT = 5
+        private const val TRIGGER_DISTANCE_TEST_CONSTANT = 10f
+        private const val ACTION_URL_TEST_CONSTANT = "ACTION_URL_TEST_CONSTANT"
+        private const val ACTION_URL_TYPE_TEST_CONSTANT = Cache.ResolvedUrlType.APP_VALUE
+        private const val APP_NAME_TEST_CONSTANT = "Nearby Mainline Mobly Test Snippet"
+        private const val ATTACHMENT_TYPE_TEST_CONSTANT =
+            Cache.DiscoveryAttachmentType.DISCOVERY_ATTACHMENT_TYPE_NORMAL_VALUE
+        private const val DEBUG_CATEGORY_TEST_CONSTANT =
+            Cache.StoredDiscoveryItem.DebugMessageCategory.STATUS_VALID_NOTIFICATION_VALUE
+        private const val DEBUG_MESSAGE_TEST_CONSTANT = "DEBUG_MESSAGE_TEST_CONSTANT"
+        private const val DESCRIPTION_TEST_CONSTANT = "DESCRIPTION_TEST_CONSTANT"
+        private const val DEVICE_NAME_TEST_CONSTANT = "Fast Pair Headphone Simulator"
+        private const val DISPLAY_URL_TEST_CONSTANT = "DISPLAY_URL_TEST_CONSTANT"
+        private const val ENTITY_ID_TEST_CONSTANT = "ENTITY_ID_TEST_CONSTANT"
+        private const val FEATURE_GRAPHIC_URL_TEST_CONSTANT = "FEATURE_GRAPHIC_URL_TEST_CONSTANT"
+        private const val FIRST_OBSERVATION_TIMESTAMP_MILLIS_TEST_CONSTANT = 8_393L
+        private const val GROUP_ID_TEST_CONSTANT = "GROUP_ID_TEST_CONSTANT"
+        private const val ICON_FIFE_URL_TEST_CONSTANT = "ICON_FIFE_URL_TEST_CONSTANT"
+        private const val ID_TEST_CONSTANT = "ID_TEST_CONSTANT"
+        private const val LAST_OBSERVATION_TIMESTAMP_MILLIS_TEST_CONSTANT = 934_234L
+        private const val LAST_USER_EXPERIENCE_TEST_CONSTANT =
+            Cache.StoredDiscoveryItem.ExperienceType.EXPERIENCE_GOOD_VALUE
+        private const val LOST_MILLIS_TEST_CONSTANT = 393_284L
+        private const val MAC_ADDRESS_TEST_CONSTANT = "11:aa:22:bb:34:cd"
+        private const val PACKAGE_NAME_TEST_CONSTANT = "android.nearby.package.name.test.constant"
+        private const val PENDING_APP_INSTALL_TIMESTAMP_MILLIS_TEST_CONSTANT = 832_393L
+        private const val RSSI_TEST_CONSTANT = 9
+        private const val STATE_TEST_CONSTANT = Cache.StoredDiscoveryItem.State.STATE_ENABLED_VALUE
+        private const val TITLE_TEST_CONSTANT = "TITLE_TEST_CONSTANT"
+        private const val TRIGGER_ID_TEST_CONSTANT = "TRIGGER_ID_TEST_CONSTANT"
+        private const val TX_POWER_TEST_CONSTANT = 62
+        private const val TYPE_TEST_CONSTANT = Cache.NearbyType.NEARBY_DEVICE_VALUE
+
+        private val ANTI_SPOOF_PUBLIC_KEY_BYTE_ARRAY =
+            "Cbj9eCJrTdDgSYxLkqtfADQi86vIaMvxJsQ298sZYWE=".toByteArray()
+        private val LOCALE_US_LANGUAGE_TEST_CONSTANT = Locale.US.language
+        private val IMAGE_BYTE_ARRAY_FAKE_TEST_CONSTANT = byteArrayOf(7, 9)
+        private val IMAGE_URL_TEST_CONSTANT =
+            "2l9cq8LFjK4D7EvPiFAq08DMpUA1b2SoPv9FPw3q6iiwjDvh-hLfKsPCFy0j36rfjDNjSULvRgOodRDxfRHHxA".toFifeUrlString()
+        private val TRUE_WIRELESS_IMAGE_URL_CASE_TEST_CONSTANT =
+            "oNv4-sFfa0tM1uA7vZ8r7UJPBV8OreiKOFl-_KlFwrqnDD7MoOV4uX8NwGUdYb1dcMm7cfwjZ04628WTeS40".toFifeUrlString()
+        private val TRUE_WIRELESS_IMAGE_URL_LEFT_BUD_TEST_CONSTANT =
+            "RcGxVZRObx9Avn9AHwSMM4WvDbVNyYlqigW7PlDHL4RLU8W9lcENDMyaTWM9O7JIu1ewSX-FIe_GkQfDlItQkg".toFifeUrlString()
+        private val TRUE_WIRELESS_IMAGE_URL_RIGHT_BUD_TEST_CONSTANT =
+            "S7AuFqmr_hEqEFo_qfjxAiPz9moae0dkXUSUJV4gVFcysYpn4C95P77egPnuu35C3Eh_UY6_yNpQkmmUqn4N".toFifeUrlString()
+        private val ELIGIBLE_ACCOUNTS_TEST_CONSTANT = listOf(
+            FastPairEligibleAccount.Builder()
+                .setAccount(Account("nearby-mainline-fpseeker@google.com", "TestAccount"))
+                .setOptIn(true)
+                .build(),
+        )
+        private val ACCOUNT_KEY_TEST_CONSTANT = byteArrayOf(3)
+        private val SHA256_ACCOUNT_KEY_PUBLIC_ADDRESS_TEST_CONSTANT = byteArrayOf(2, 8)
+        private val AUTHENTICATION_PUBLIC_KEY_SEC_P256R1_TEST_CONSTANT = byteArrayOf(5, 7)
+        private val BLE_RECORD_BYTES_TEST_CONSTANT = byteArrayOf(2, 4)
+        private val ICON_PNG_TEST_CONSTANT = byteArrayOf(2, 5)
+
+        private const val DEVICE_TYPE_HEAD_PHONES_TEST_CONSTANT = DeviceType.HEADPHONES_VALUE
+        private const val ASSISTANT_SETUP_HALF_SHEET_TEST_CONSTANT =
+            "This is a test description in half sheet to ask user setup google assistant."
+        private const val ASSISTANT_SETUP_NOTIFICATION_TEST_CONSTANT =
+            "This is a test description in notification to ask user setup google assistant."
+        private const val CONFIRM_PIN_DESCRIPTION_TEST_CONSTANT =
+            "Please confirm the pin code to fast pair your device."
+        private const val CONFIRM_PIN_TITLE_TEST_CONSTANT = "PIN code confirmation"
+        private const val CONNECT_SUCCESS_COMPANION_APP_INSTALLED_TEST_CONSTANT =
+            "This is a test description that let user open the companion app."
+        private const val CONNECT_SUCCESS_COMPANION_APP_NOT_INSTALLED_TEST_CONSTANT =
+            "This is a test description that let user download the companion app."
+        private const val DOWNLOAD_COMPANION_APP_DESCRIPTION_TEST_CONSTANT =
+            "This is a test description for downloading companion app."
+        private const val FAIL_CONNECT_GOTO_SETTINGS_DESCRIPTION_TEST_CONSTANT = "This is a " +
+                "test description that indicates go to bluetooth settings when connection fail."
+        private const val TV_CONNECT_DEVICE_NO_ACCOUNT_DESCRIPTION_TEST_CONSTANT =
+            "This is a test description of the connect device action on TV, " +
+                    "when user is not logged in."
+        private const val INITIAL_NOTIFICATION_DESCRIPTION_TEST_CONSTANT =
+            "This is a test description for initial notification."
+        private const val INITIAL_NOTIFICATION_DESCRIPTION_NO_ACCOUNT_TEST_CONSTANT = "This is a " +
+                "test description of initial notification description when account is not present."
+        private const val INITIAL_PAIRING_DESCRIPTION_TEST_CONSTANT =
+            "This is a test description for Fast Pair initial pairing."
+        private const val COMPANION_APP_PACKAGE_TEST_CONSTANT = "android.nearby.companion"
+        private const val COMPANION_APP_ACTIVITY_TEST_CONSTANT =
+            "android.nearby.companion.MainActivity"
+        private const val OPEN_COMPANION_APP_DESCRIPTION_TEST_CONSTANT =
+            "This is a test description for opening companion app."
+        private const val RETRO_ACTIVE_PAIRING_DESCRIPTION_TEST_CONSTANT =
+            "This is a test description that reminds users opt in their device."
+        private const val SUBSEQUENT_PAIRING_DESCRIPTION_TEST_CONSTANT =
+            "This is a test description that reminds user there is a paired device nearby."
+        private const val SYNC_CONTACT_DESCRIPTION_TEST_CONSTANT =
+            "This is a test description of the UI to ask the user to confirm to sync contacts."
+        private const val SYNC_CONTACTS_TITLE_TEST_CONSTANT = "Sync contacts to your device"
+        private const val SYNC_SMS_DESCRIPTION_TEST_CONSTANT =
+            "This is a test description of the UI to ask the user to confirm to sync SMS."
+        private const val SYNC_SMS_TITLE_TEST_CONSTANT = "Sync SMS to your device"
+        private const val UNABLE_TO_CONNECT_DESCRIPTION_TEST_CONSTANT =
+            "This is a test description when Fast Pair device is unable to be connected to."
+        private const val UNABLE_TO_CONNECT_TITLE_TEST_CONSTANT =
+            "Unable to connect your Fast Pair device"
+        private const val UPDATE_COMPANION_APP_DESCRIPTION_TEST_CONSTANT =
+            "This is a test description for updating companion app."
+        private const val WAIT_LAUNCH_COMPANION_APP_DESCRIPTION_TEST_CONSTANT =
+            "This is a test description that indicates companion app is about to launch."
+
+        private fun ByteArray.bytesToStringLowerCase(): String =
+            joinToString(separator = "") { eachByte -> "%02x".format(eachByte) }
+
+        // Primary image serving domain for Google Photos and most other clients of FIFE.
+        private fun String.toFifeUrlString() = "https://lh3.googleusercontent.com/$this"
+    }
+}
\ No newline at end of file
diff --git a/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/FastPairTestDataProviderService.kt b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/FastPairTestDataProviderService.kt
new file mode 100644
index 0000000..5a1c832
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/FastPairTestDataProviderService.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.nearby.multidevices.fastpair.seeker
+
+import android.app.Service
+import android.content.Intent
+import android.os.IBinder
+import android.util.Log
+
+/**
+ * Fast Pair Test Data Provider Service entry point for platform overlay.
+ */
+class FastPairTestDataProviderService : Service() {
+    private var testDataProvider: FastPairTestDataProvider? = null
+
+    override fun onBind(intent: Intent?): IBinder? {
+        Log.d(TAG, "onBind(intent: $intent)")
+        if (testDataProvider == null) {
+            testDataProvider = FastPairTestDataProvider()
+        }
+        return testDataProvider!!.binder
+    }
+
+    override fun onDestroy() {
+        Log.d(TAG, "onDestroy()")
+        if (testDataProvider != null) {
+            testDataProvider = null
+        }
+        super.onDestroy()
+    }
+
+    companion object {
+        private const val TAG = "FastPairTestDataProviderService"
+    }
+}
\ No newline at end of file
diff --git a/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/ScanCallbackEvents.kt b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/ScanCallbackEvents.kt
new file mode 100644
index 0000000..55a6b8f
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/android/nearby/multidevices/fastpair/seeker/ScanCallbackEvents.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.nearby.multidevices.fastpair.seeker
+
+import android.nearby.NearbyDevice
+import android.nearby.ScanCallback
+import android.nearby.multidevices.common.postSnippetEvent
+
+/** The Mobly snippet events to report to the Python side. */
+class ScanCallbackEvents(private val callbackId: String) : ScanCallback {
+
+    override fun onDiscovered(device: NearbyDevice) {
+        postSnippetEvent(callbackId, "onDiscovered") {
+            putString("device", device.toString())
+        }
+    }
+
+    override fun onUpdated(device: NearbyDevice) {
+        postSnippetEvent(callbackId, "onUpdated") {
+            putString("device", device.toString())
+        }
+    }
+
+    override fun onLost(device: NearbyDevice) {
+        postSnippetEvent(callbackId, "onLost") {
+            putString("device", device.toString())
+        }
+    }
+}
\ No newline at end of file
diff --git a/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/Crypto.java b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/Crypto.java
new file mode 100644
index 0000000..1543953
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/Crypto.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.nearby.common.bluetooth.fastpair.testing;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+
+import android.annotation.SuppressLint;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.protobuf.ByteString;
+
+import java.nio.ByteBuffer;
+import java.security.GeneralSecurityException;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.SecretKeySpec;
+
+/** Cryptography utilities for ephemeral IDs. */
+public final class Crypto {
+    private static final int AES_BLOCK_SIZE = 16;
+    private static final ImmutableSet<Integer> VALID_AES_KEY_SIZES = ImmutableSet.of(16, 24, 32);
+    private static final String AES_ECB_NOPADDING_ENCRYPTION_ALGO = "AES/ECB/NoPadding";
+    private static final String AES_ENCRYPTION_ALGO = "AES";
+
+    /** Encrypts the provided data with the provided key using the AES/ECB/NoPadding algorithm. */
+    public static ByteString aesEcbNoPaddingEncrypt(ByteString key, ByteString data) {
+        return aesEcbOperation(key, data, Cipher.ENCRYPT_MODE);
+    }
+
+    /** Decrypts the provided data with the provided key using the AES/ECB/NoPadding algorithm. */
+    public static ByteString aesEcbNoPaddingDecrypt(ByteString key, ByteString data) {
+        return aesEcbOperation(key, data, Cipher.DECRYPT_MODE);
+    }
+
+    @SuppressLint("GetInstance")
+    private static ByteString aesEcbOperation(ByteString key, ByteString data, int operation) {
+        checkArgument(VALID_AES_KEY_SIZES.contains(key.size()));
+        checkArgument(data.size() % AES_BLOCK_SIZE == 0);
+        try {
+            Cipher aesCipher = Cipher.getInstance(AES_ECB_NOPADDING_ENCRYPTION_ALGO);
+            SecretKeySpec secretKeySpec = new SecretKeySpec(key.toByteArray(), AES_ENCRYPTION_ALGO);
+            aesCipher.init(operation, secretKeySpec);
+            ByteBuffer output = ByteBuffer.allocate(data.size());
+            checkState(aesCipher.doFinal(data.asReadOnlyByteBuffer(), output) == data.size());
+            output.rewind();
+            return ByteString.copyFrom(output);
+        } catch (GeneralSecurityException e) {
+            // Should never happen.
+            throw new AssertionError(e);
+        }
+    }
+
+    private Crypto() {
+    }
+}
diff --git a/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/E2eeCalculator.java b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/E2eeCalculator.java
new file mode 100644
index 0000000..6f213e6
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/E2eeCalculator.java
@@ -0,0 +1,185 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.nearby.common.bluetooth.fastpair.testing;
+
+import com.google.common.base.Preconditions;
+import com.google.common.base.Verify;
+import com.google.common.primitives.Bytes;
+import com.google.common.primitives.Ints;
+import com.google.protobuf.ByteString;
+
+import java.math.BigInteger;
+import java.security.spec.ECFieldFp;
+import java.security.spec.ECParameterSpec;
+import java.security.spec.ECPoint;
+import java.security.spec.EllipticCurve;
+import java.util.Collections;
+
+/** Provides methods for calculating E2EE EIDs and E2E encryption/decryption based on E2EE EIDs. */
+public final class E2eeCalculator {
+
+    private static final byte[] TEMP_KEY_PADDING_1 =
+            Bytes.toArray(Collections.nCopies(11, (byte) 0xFF));
+    private static final byte[] TEMP_KEY_PADDING_2 = new byte[11];
+    private static final ECParameterSpec CURVE_SPEC = getCurveSpec();
+    private static final BigInteger P = ((ECFieldFp) CURVE_SPEC.getCurve().getField()).getP();
+    private static final BigInteger TWO = new BigInteger("2");
+    private static final BigInteger THREE = new BigInteger("3");
+    private static final int E2EE_EID_IDENTITY_KEY_SIZE = 32;
+    private static final int E2EE_EID_SIZE = 20;
+
+    /**
+     * Computes the E2EE EID value for the given device clock based time. Note that Eddystone beacons
+     * start advertising the new EID at a random time within the window, therefore the currently
+     * advertised EID for beacon time <em>t</em> may be either {@code computeE2eeEid(eik, k, t)} or
+     * {@code computeE2eeEid(eik, k, t - (1 << k))}.
+     *
+     * <p>The E2EE EID computation is based on https://goto.google.com/e2ee-eid-computation.
+     *
+     * @param identityKey        the beacon's 32-byte Eddystone E2EE identity key
+     * @param exponent           rotation period exponent as configured on the beacon, must be in range the [0,
+     *                           15]
+     * @param deviceClockSeconds the value of the beacon's 32-bit seconds time counter (treated as an
+     *                           unsigned value)
+     * @return E2EE EID value.
+     */
+    public static ByteString computeE2eeEid(
+            ByteString identityKey, int exponent, int deviceClockSeconds) {
+        return computePublicKey(computePrivateKey(identityKey, exponent, deviceClockSeconds));
+    }
+
+    private static ByteString computePublicKey(BigInteger privateKey) {
+        return getXCoordinateBytes(toPoint(privateKey));
+    }
+
+    private static BigInteger computePrivateKey(
+            ByteString identityKey, int exponent, int deviceClockSeconds) {
+        Preconditions.checkArgument(
+                Preconditions.checkNotNull(identityKey).size() == E2EE_EID_IDENTITY_KEY_SIZE);
+        Preconditions.checkArgument(exponent >= 0 && exponent < 16);
+
+        byte[] exponentByte = new byte[]{(byte) exponent};
+        byte[] paddedCounter = Ints.toByteArray((deviceClockSeconds >>> exponent) << exponent);
+        byte[] data =
+                Bytes.concat(
+                        TEMP_KEY_PADDING_1,
+                        exponentByte,
+                        paddedCounter,
+                        TEMP_KEY_PADDING_2,
+                        exponentByte,
+                        paddedCounter);
+
+        byte[] rTag =
+                Crypto.aesEcbNoPaddingEncrypt(identityKey, ByteString.copyFrom(data)).toByteArray();
+        return new BigInteger(1, rTag).mod(CURVE_SPEC.getOrder());
+    }
+
+    private static ECPoint toPoint(BigInteger privateKey) {
+        return multiplyPoint(CURVE_SPEC.getGenerator(), privateKey);
+    }
+
+    private static ByteString getXCoordinateBytes(ECPoint point) {
+        byte[] unalignedBytes = point.getAffineX().toByteArray();
+
+        // The unalignedBytes may have length < 32 if the leading E2EE EID bytes are zero, or
+        // it may be E2EE_EID_SIZE + 1 if the leading bit is 1, in which case the first byte is always
+        // zero.
+        Verify.verify(
+                unalignedBytes.length <= E2EE_EID_SIZE
+                        || (unalignedBytes.length == E2EE_EID_SIZE + 1 && unalignedBytes[0] == 0));
+
+        byte[] bytes;
+        if (unalignedBytes.length < E2EE_EID_SIZE) {
+            bytes = new byte[E2EE_EID_SIZE];
+            System.arraycopy(
+                    unalignedBytes, 0, bytes, bytes.length - unalignedBytes.length, unalignedBytes.length);
+        } else if (unalignedBytes.length == E2EE_EID_SIZE + 1) {
+            bytes = new byte[E2EE_EID_SIZE];
+            System.arraycopy(unalignedBytes, 1, bytes, 0, E2EE_EID_SIZE);
+        } else { // unalignedBytes.length ==  GattE2EE_EID_SIZE
+            bytes = unalignedBytes;
+        }
+        return ByteString.copyFrom(bytes);
+    }
+
+    /** Returns a secp160r1 curve spec. */
+    private static ECParameterSpec getCurveSpec() {
+        final BigInteger p = new BigInteger("ffffffffffffffffffffffffffffffff7fffffff", 16);
+        final BigInteger n = new BigInteger("0100000000000000000001f4c8f927aed3ca752257", 16);
+        final BigInteger a = new BigInteger("ffffffffffffffffffffffffffffffff7ffffffc", 16);
+        final BigInteger b = new BigInteger("1c97befc54bd7a8b65acf89f81d4d4adc565fa45", 16);
+        final BigInteger gx = new BigInteger("4a96b5688ef573284664698968c38bb913cbfc82", 16);
+        final BigInteger gy = new BigInteger("23a628553168947d59dcc912042351377ac5fb32", 16);
+        final int h = 1;
+        ECFieldFp fp = new ECFieldFp(p);
+        EllipticCurve spec = new EllipticCurve(fp, a, b);
+        ECPoint g = new ECPoint(gx, gy);
+        return new ECParameterSpec(spec, g, n, h);
+    }
+
+    /** Returns the scalar multiplication result of k*p in Fp. */
+    private static ECPoint multiplyPoint(ECPoint p, BigInteger k) {
+        ECPoint r = ECPoint.POINT_INFINITY;
+        ECPoint s = p;
+        BigInteger kModP = k.mod(P);
+        int length = kModP.bitLength();
+        for (int i = 0; i <= length - 1; i++) {
+            if (kModP.mod(TWO).byteValue() == 1) {
+                r = addPoint(r, s);
+            }
+            s = doublePoint(s);
+            kModP = kModP.divide(TWO);
+        }
+        return r;
+    }
+
+    /** Returns the point addition r+s in Fp. */
+    private static ECPoint addPoint(ECPoint r, ECPoint s) {
+        if (r.equals(s)) {
+            return doublePoint(r);
+        } else if (r.equals(ECPoint.POINT_INFINITY)) {
+            return s;
+        } else if (s.equals(ECPoint.POINT_INFINITY)) {
+            return r;
+        }
+        BigInteger slope =
+                r.getAffineY()
+                        .subtract(s.getAffineY())
+                        .multiply(r.getAffineX().subtract(s.getAffineX()).modInverse(P))
+                        .mod(P);
+        BigInteger x = slope.modPow(TWO, P).subtract(r.getAffineX()).subtract(s.getAffineX()).mod(P);
+        BigInteger y = s.getAffineY().negate().mod(P);
+        y = y.add(slope.multiply(s.getAffineX().subtract(x))).mod(P);
+        return new ECPoint(x, y);
+    }
+
+    /** Returns the point doubling 2*r in Fp. */
+    private static ECPoint doublePoint(ECPoint r) {
+        if (r.equals(ECPoint.POINT_INFINITY)) {
+            return r;
+        }
+        BigInteger slope = r.getAffineX().pow(2).multiply(THREE);
+        slope = slope.add(CURVE_SPEC.getCurve().getA());
+        slope = slope.multiply(r.getAffineY().multiply(TWO).modInverse(P));
+        BigInteger x = slope.pow(2).subtract(r.getAffineX().multiply(TWO)).mod(P);
+        BigInteger y = r.getAffineY().negate().add(slope.multiply(r.getAffineX().subtract(x))).mod(P);
+        return new ECPoint(x, y);
+    }
+
+    private E2eeCalculator() {
+    }
+}
diff --git a/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/FastPairAdvertiser.java b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/FastPairAdvertiser.java
new file mode 100644
index 0000000..33add27
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/FastPairAdvertiser.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.nearby.common.bluetooth.fastpair.testing;
+
+import androidx.annotation.Nullable;
+
+/** Helper for advertising Fast Pair data. */
+public interface FastPairAdvertiser {
+
+    void startAdvertising(@Nullable byte[] serviceData);
+
+    void stopAdvertising();
+}
diff --git a/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/FastPairSimulator.java b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/FastPairSimulator.java
new file mode 100644
index 0000000..acc13e3
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/FastPairSimulator.java
@@ -0,0 +1,2358 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.nearby.common.bluetooth.fastpair.testing;
+
+import static android.bluetooth.BluetoothAdapter.EXTRA_STATE;
+import static android.bluetooth.BluetoothAdapter.SCAN_MODE_CONNECTABLE;
+import static android.bluetooth.BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE;
+import static android.bluetooth.BluetoothAdapter.SCAN_MODE_NONE;
+import static android.bluetooth.BluetoothAdapter.STATE_OFF;
+import static android.bluetooth.BluetoothAdapter.STATE_ON;
+import static android.bluetooth.BluetoothDevice.ERROR;
+import static android.bluetooth.BluetoothGattCharacteristic.PERMISSION_READ;
+import static android.bluetooth.BluetoothGattCharacteristic.PERMISSION_WRITE;
+import static android.bluetooth.BluetoothGattCharacteristic.PROPERTY_INDICATE;
+import static android.bluetooth.BluetoothGattCharacteristic.PROPERTY_NOTIFY;
+import static android.bluetooth.BluetoothGattCharacteristic.PROPERTY_READ;
+import static android.bluetooth.BluetoothGattCharacteristic.PROPERTY_WRITE;
+import static com.android.server.nearby.common.bluetooth.fastpair.testing.RfcommServer.State.CONNECTED;
+
+import static com.android.server.nearby.common.bluetooth.fastpair.AesEcbSingleBlockEncryption.AES_BLOCK_LENGTH;
+import static com.android.server.nearby.common.bluetooth.fastpair.AesEcbSingleBlockEncryption.encrypt;
+import static com.android.server.nearby.common.bluetooth.fastpair.Bytes.toBytes;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.A2DP_SINK_SERVICE_UUID;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.TransportDiscoveryService.BLUETOOTH_SIG_ORGANIZATION_ID;
+import static com.android.server.nearby.common.bluetooth.fastpair.MessageStreamHmacEncoder.SECTION_NONCE_LENGTH;
+import static com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothManager.wrap;
+
+import static com.google.common.io.BaseEncoding.base16;
+import static com.google.common.primitives.Bytes.concat;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothClass.Device.Major;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothManager;
+import android.bluetooth.BluetoothProfile;
+import android.bluetooth.le.AdvertiseSettings;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.nearby.multidevices.fastpair.EventStreamProtocol;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.SystemClock;
+import android.provider.Settings;
+import android.text.TextUtils;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.core.util.Consumer;
+
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+import com.android.server.nearby.common.bluetooth.BluetoothGattException;
+import com.android.server.nearby.common.bluetooth.fastpair.AesEcbSingleBlockEncryption;
+import com.android.server.nearby.common.bluetooth.fastpair.BluetoothAddress;
+import com.android.server.nearby.common.bluetooth.fastpair.Bytes.Value;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.AccountKeyCharacteristic;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.BeaconActionsCharacteristic;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.BeaconActionsCharacteristic.BeaconActionType;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.FirmwareVersionCharacteristic;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.NameCharacteristic;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.PasskeyCharacteristic;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.TransportDiscoveryService;
+import com.android.server.nearby.common.bluetooth.fastpair.EllipticCurveDiffieHellmanExchange;
+import com.android.server.nearby.common.bluetooth.fastpair.Ltv;
+import com.android.server.nearby.common.bluetooth.fastpair.MessageStreamHmacEncoder;
+import com.android.server.nearby.common.bluetooth.fastpair.NamingEncoder;
+import com.android.server.nearby.common.bluetooth.fastpair.Reflect;
+import com.android.server.nearby.common.bluetooth.fastpair.ReflectionException;
+import com.android.server.nearby.common.bluetooth.gatt.server.BluetoothGattServerConfig;
+import com.android.server.nearby.common.bluetooth.gatt.server.BluetoothGattServerConfig.ServiceConfig;
+import com.android.server.nearby.common.bluetooth.gatt.server.BluetoothGattServerConnection;
+import com.android.server.nearby.common.bluetooth.gatt.server.BluetoothGattServerConnection.Notifier;
+import com.android.server.nearby.common.bluetooth.gatt.server.BluetoothGattServerHelper;
+import com.android.server.nearby.common.bluetooth.gatt.server.BluetoothGattServlet;
+
+import com.google.common.base.Ascii;
+import com.google.common.primitives.Bytes;
+
+import com.android.server.nearby.common.bloomfilter.BloomFilter;
+import com.android.server.nearby.common.bloomfilter.FastPairBloomFilterHasher;
+
+import com.google.protobuf.ByteString;
+
+import java.io.UnsupportedEncodingException;
+import java.lang.reflect.Method;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Random;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * Simulates a Fast Pair device (e.g. a headset).
+ *
+ * <p>Note: There are two deviations from the spec:
+ *
+ * <ul>
+ *   <li>Instead of using the public address when in pairing mode (discoverable), it always uses the
+ *       random private address (RPA), because that's how stock Android works. To work around this,
+ *       it implements the BR/EDR Handover profile (which is no longer part of the Fast Pair spec)
+ *       when simulating a keyless device (i.e. Fast Pair 1.0), which allows the phone to ask for
+ *       the public address. When there is an anti-spoofing key, i.e. Fast Pair 2.0, the public
+ *       address is delivered via the Key-based Pairing handshake. b/79374759 tracks fixing this.
+ *   <li>The simulator always identifies its device capabilities as Keyboard/Display, even when
+ *       simulating a keyless (Fast Pair 1.0) device that should identify as NoInput/NoOutput.
+ *       b/79377125 tracks fixing this.
+ * </ul>
+ *
+ * @see {http://go/fast-pair-2-spec}
+ */
+public class FastPairSimulator {
+    private static final String TAG = "FastPairSimulator";
+    private final Logger logger = new Logger(TAG);
+
+    /**
+     * Headphones. Generated by
+     * http://bluetooth-pentest.narod.ru/software/bluetooth_class_of_device-service_generator.html
+     */
+    private static final Value CLASS_OF_DEVICE =
+            new Value(base16().decode("200418"), ByteOrder.BIG_ENDIAN);
+
+    private static final byte[] SUPPORTED_SERVICES_LTV =
+            new Ltv(
+                    TransportDiscoveryService.SERVICE_UUIDS_16_BIT_LIST_TYPE,
+                    toBytes(ByteOrder.LITTLE_ENDIAN, A2DP_SINK_SERVICE_UUID))
+                    .getBytes();
+    private static final byte[] TDS_CONTROL_POINT_RESPONSE_PARAMETER =
+            Bytes.concat(new byte[]{BLUETOOTH_SIG_ORGANIZATION_ID}, SUPPORTED_SERVICES_LTV);
+
+    private static final String SIMULATOR_FAKE_BLE_ADDRESS = "11:22:33:44:55:66";
+
+    private static final long ADVERTISING_REFRESH_DELAY_1_MIN = TimeUnit.MINUTES.toMillis(1);
+    private static final long ADVERTISING_REFRESH_DELAY_5_MINS = TimeUnit.MINUTES.toMillis(5);
+
+    /** The user will be prompted to accept or deny the incoming pairing request */
+    public static final int PAIRING_VARIANT_CONSENT = 3;
+
+    /**
+     * The user will be prompted to enter the passkey displayed on remote device. This is used for
+     * Bluetooth 2.1 pairing.
+     */
+    public static final int PAIRING_VARIANT_DISPLAY_PASSKEY = 4;
+
+    /**
+     * The size of account key filter in bytes is (1.2*n + 3), n represents the size of account key,
+     * see https://developers.google.com/nearby/fast-pair/spec#advertising_when_not_discoverable.
+     * However we'd like to advertise something else, so we could only afford 8 account keys.
+     *
+     * <ul>
+     *   <li>BLE flags: 3 bytes
+     *   <li>TxPower: 3 bytes
+     *   <li>FastPair: max 25 bytes
+     *       <ul>
+     *         <li>FastPair service data: 4 bytes
+     *         <li>Flags: 1 byte
+     *         <li>Account key filter: max 14 bytes (1 byte: length + type, 13 bytes: max 8 account
+     *             keys)
+     *         <li>Salt: 2 bytes
+     *         <li>Battery: 4 bytes
+     *       </ul>
+     * </ul>
+     */
+    private String deviceFirmwareVersion = "1.1.0";
+
+    private byte[] sessionNonce;
+
+    private boolean useLogFullEvent = true;
+
+    private enum ResultCode {
+        SUCCESS((byte) 0x00),
+        OP_CODE_NOT_SUPPORTED((byte) 0x01),
+        INVALID_PARAMETER((byte) 0x02),
+        UNSUPPORTED_ORGANIZATION_ID((byte) 0x03),
+        OPERATION_FAILED((byte) 0x04);
+
+        private final byte byteValue;
+
+        ResultCode(byte byteValue) {
+            this.byteValue = byteValue;
+        }
+    }
+
+    private enum TransportState {
+        OFF((byte) 0x00),
+        ON((byte) 0x01),
+        TEMPORARILY_UNAVAILABLE((byte) 0x10);
+
+        private final byte byteValue;
+
+        TransportState(byte byteValue) {
+            this.byteValue = byteValue;
+        }
+    }
+
+    private final Context context;
+    private final Options options;
+    private final Handler uiThreadHandler = new Handler(Looper.getMainLooper());
+    // No thread pool: Only used in test app (outside gmscore) and in javatests/.../gmscore/.
+    private final ScheduledExecutorService executor =
+            Executors.newSingleThreadScheduledExecutor(); // exempt
+    private final BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
+    private final BroadcastReceiver broadcastReceiver =
+            new BroadcastReceiver() {
+                @Override
+                public void onReceive(Context context, Intent intent) {
+                    if (shouldFailPairing) {
+                        logger.log("Pairing disabled by test app switch");
+                        return;
+                    }
+                    if (isDestroyed) {
+                        // Sometimes this receiver does not successfully unregister in destroy() which causes
+                        // events to occur after the simulator is stopped, so ignore those events.
+                        logger.log("Intent received after simulator destroyed, ignoring");
+                        return;
+                    }
+                    BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
+                    switch (intent.getAction()) {
+                        case BluetoothAdapter.ACTION_SCAN_MODE_CHANGED:
+                            if (isDiscoverable()) {
+                                isDiscoverableLatch.countDown();
+                            }
+                            break;
+                        case BluetoothDevice.ACTION_PAIRING_REQUEST:
+                            int variant = intent.getIntExtra(BluetoothDevice.EXTRA_PAIRING_VARIANT, ERROR);
+                            int key = intent.getIntExtra(BluetoothDevice.EXTRA_PAIRING_KEY, ERROR);
+                            logger.log(
+                                    "Pairing request, variant=%d, key=%s", variant, key == ERROR ? "(none)" : key);
+
+                            // Prevent Bluetooth Settings from getting the pairing request.
+                            abortBroadcast();
+
+                            pairingDevice = device;
+                            if (secret == null) {
+                                // We haven't done the handshake over GATT to agree on the shared secret. For now,
+                                // just accept anyway (so we can still simulate old 1.0 model IDs).
+                                logger.log("No handshake, auto-accepting anyway.");
+                                setPasskeyConfirmation(true);
+                            } else if (variant == BluetoothDevice.PAIRING_VARIANT_PASSKEY_CONFIRMATION) {
+                                // Store the passkey. And check it, since there's a race (see method for why).
+                                // Usually this check is a no-op and we'll get the passkey later over GATT.
+                                localPasskey = key;
+                                checkPasskey();
+                            } else if (variant == PAIRING_VARIANT_DISPLAY_PASSKEY) {
+                                if (passkeyEventCallback != null) {
+                                    passkeyEventCallback.onPasskeyRequested(FastPairSimulator.this::enterPassKey);
+                                } else {
+                                    logger.log("passkeyEventCallback is not set!");
+                                    enterPassKey(key);
+                                }
+                            } else if (variant == PAIRING_VARIANT_CONSENT) {
+                                setPasskeyConfirmation(true);
+
+                            } else if (variant == BluetoothDevice.PAIRING_VARIANT_PIN) {
+                                if (passkeyEventCallback != null) {
+                                    passkeyEventCallback.onPasskeyRequested(
+                                            (int pin) ->
+                                                    pairingDevice.setPin(
+                                                            convertPinToBytes(
+                                                                    String.format(Locale.ENGLISH, "%d", pin))));
+                                }
+                            } else {
+                                // Reject the pairing request if it's not using the Numeric Comparison (aka Passkey
+                                // Confirmation) method.
+                                setPasskeyConfirmation(false);
+                            }
+                            break;
+                        case BluetoothDevice.ACTION_BOND_STATE_CHANGED:
+                            int bondState =
+                                    intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.BOND_NONE);
+                            logger.log("Bond state to %s changed to %d", device, bondState);
+                            switch (bondState) {
+                                case BluetoothDevice.BOND_BONDING:
+                                    // If we've started bonding, we shouldn't be advertising.
+                                    advertiser.stopAdvertising();
+                                    // Not discoverable anymore, but still connectable.
+                                    setScanMode(BluetoothAdapter.SCAN_MODE_CONNECTABLE);
+                                    break;
+                                case BluetoothDevice.BOND_BONDED:
+                                    // Once bonded, advertise the account keys.
+                                    advertiser.startAdvertising(accountKeysServiceData());
+                                    setScanMode(BluetoothAdapter.SCAN_MODE_CONNECTABLE);
+
+                                    // If it is subsequent pair, we need to add paired device here.
+                                    if (isSubsequentPair && secret != null && secret.length == AES_BLOCK_LENGTH) {
+                                        addAccountKey(secret, pairingDevice);
+                                    }
+                                    break;
+                                case BluetoothDevice.BOND_NONE:
+                                    // If the bonding process fails, we should be advertising again.
+                                    advertiser.startAdvertising(getServiceData());
+                                    break;
+                                default:
+                                    break;
+                            }
+                            break;
+                        case BluetoothAdapter.ACTION_CONNECTION_STATE_CHANGED:
+                            logger.log(
+                                    "Connection state to %s changed to %d",
+                                    device,
+                                    intent.getIntExtra(
+                                            BluetoothAdapter.EXTRA_CONNECTION_STATE,
+                                            BluetoothAdapter.STATE_DISCONNECTED));
+                            break;
+                        case BluetoothAdapter.ACTION_STATE_CHANGED:
+                            int state = intent.getIntExtra(EXTRA_STATE, -1);
+                            logger.log("Bluetooth adapter state=%s", state);
+                            switch (state) {
+                                case STATE_ON:
+                                    startRfcommServer();
+                                    break;
+                                case STATE_OFF:
+                                    stopRfcommServer();
+                                    break;
+                                default: // fall out
+                            }
+                            break;
+                        default:
+                            logger.log(
+                                    new IllegalArgumentException(intent.toString()), "Received unexpected intent");
+                            break;
+                    }
+                }
+            };
+
+    @Nullable
+    private byte[] convertPinToBytes(@Nullable String pin) {
+        if (TextUtils.isEmpty(pin)) {
+            return null;
+        }
+        byte[] pinBytes;
+        try {
+            pinBytes = pin.getBytes("UTF-8");
+        } catch (UnsupportedEncodingException uee) {
+            logger.log("UTF-8 not supported?!?");
+            return null;
+        }
+        if (pinBytes.length <= 0 || pinBytes.length > 16) {
+            return null;
+        }
+        return pinBytes;
+    }
+
+    private final NotifiableGattServlet passkeyServlet =
+            new NotifiableGattServlet() {
+                @Override
+                // Simulating deprecated API {@code PasskeyCharacteristic.ID} for testing.
+                @SuppressWarnings("deprecation")
+                public BluetoothGattCharacteristic getBaseCharacteristic() {
+                    return new BluetoothGattCharacteristic(
+                            PasskeyCharacteristic.CUSTOM_128_BIT_UUID,
+                            PROPERTY_WRITE | PROPERTY_INDICATE,
+                            PERMISSION_WRITE);
+                }
+
+                @Override
+                public void write(BluetoothGattServerConnection connection, int offset, byte[] value) {
+                    logger.log("Got value from passkey servlet: %s", base16().encode(value));
+                    if (secret == null) {
+                        logger.log("Ignoring write to passkey characteristic, no pairing secret.");
+                        return;
+                    }
+
+                    try {
+                        remotePasskey =
+                                PasskeyCharacteristic.decrypt(PasskeyCharacteristic.Type.SEEKER, secret, value);
+                        if (passkeyEventCallback != null) {
+                            passkeyEventCallback.onRemotePasskeyReceived(remotePasskey);
+                        }
+                        checkPasskey();
+                    } catch (GeneralSecurityException e) {
+                        logger.log(
+                                "Decrypting passkey value %s failed using key %s",
+                                base16().encode(value), base16().encode(secret));
+                    }
+                }
+            };
+
+    private final NotifiableGattServlet deviceNameServlet =
+            new NotifiableGattServlet() {
+                @Override
+                // Simulating deprecated API {@code NameCharacteristic.ID} for testing.
+                @SuppressWarnings("deprecation")
+                BluetoothGattCharacteristic getBaseCharacteristic() {
+                    return new BluetoothGattCharacteristic(
+                            NameCharacteristic.CUSTOM_128_BIT_UUID,
+                            PROPERTY_WRITE | PROPERTY_INDICATE,
+                            PERMISSION_WRITE);
+                }
+
+                @Override
+                public void write(BluetoothGattServerConnection connection, int offset, byte[] value) {
+                    logger.log("Got value from device naming servlet: %s", base16().encode(value));
+                    if (secret == null) {
+                        logger.log("Ignoring write to name characteristic, no pairing secret.");
+                        return;
+                    }
+                    // Parse the device name from seeker to write name into provider. See
+                    // go/fast-pair-naming-design-doc for the decryption detail to get the device name.
+                    logger.log("Got name byte array size = %d", value.length);
+                    try {
+                        String decryptedDeviceName = NamingEncoder.decodeNamingPacket(secret, value);
+                        if (decryptedDeviceName != null) {
+                            setDeviceName(decryptedDeviceName.getBytes(StandardCharsets.UTF_8));
+                            logger.log("write device name = %s", decryptedDeviceName);
+                        }
+                    } catch (GeneralSecurityException e) {
+                        logger.log(e, "Failed to decrypt device name.");
+                    }
+                    // For testing to make sure we get the new provider name from simulator.
+                    if (writeNameCountDown != null) {
+                        logger.log("finish count down latch to write device name.");
+                        writeNameCountDown.countDown();
+                    }
+                }
+            };
+
+    private Value bluetoothAddress;
+    private final FastPairAdvertiser advertiser;
+    private final Map<String, BluetoothGattServerHelper> bluetoothGattServerHelpers = new HashMap<>();
+    private CountDownLatch isDiscoverableLatch = new CountDownLatch(1);
+    private ScheduledFuture<?> revertDiscoverableFuture;
+    private boolean shouldFailPairing = false;
+    private boolean isDestroyed = false;
+    private boolean isAdvertising;
+    @Nullable
+    private String bleAddress;
+    private BluetoothDevice pairingDevice;
+    private int localPasskey;
+    private int remotePasskey;
+    @Nullable
+    private byte[] secret;
+    @Nullable
+    private byte[] accountKey; // The latest account key added.
+    // The first account key added. Eddystone treats that account as the owner of the device.
+    @Nullable
+    private byte[] ownerAccountKey;
+    @Nullable
+    private PasskeyConfirmationCallback passkeyConfirmationCallback;
+    @Nullable
+    private DeviceNameCallback deviceNameCallback;
+    @Nullable
+    private PasskeyEventCallback passkeyEventCallback;
+    private final List<BatteryValue> batteryValues;
+    private boolean suppressBatteryNotification = false;
+    private boolean suppressSubsequentPairingNotification = false;
+    HandshakeRequest handshakeRequest;
+    @Nullable
+    private CountDownLatch writeNameCountDown;
+    private final RfcommServer rfcommServer = new RfcommServer();
+    private final boolean dataOnlyConnection;
+    private boolean supportDynamicBufferSize = false;
+    private NotifiableGattServlet beaconActionsServlet;
+    private final FastPairSimulatorDatabase fastPairSimulatorDatabase;
+    private boolean isSubsequentPair = false;
+
+    /** Sets the flag for failing paring for debug purpose. */
+    public void setShouldFailPairing(boolean shouldFailPairing) {
+        this.shouldFailPairing = shouldFailPairing;
+    }
+
+    /** Gets the flag for failing paring for debug purpose. */
+    public boolean getShouldFailPairing() {
+        return shouldFailPairing;
+    }
+
+    /** Clear the battery values, then no battery information is packed when advertising. */
+    public void clearBatteryValues() {
+        batteryValues.clear();
+    }
+
+    /** Sets the battery items which will be included in the advertisement packet. */
+    public void setBatteryValues(BatteryValue... batteryValues) {
+        this.batteryValues.clear();
+        Collections.addAll(this.batteryValues, batteryValues);
+    }
+
+    /** Sets whether the battery advertisement packet is within suppress type or not. */
+    public void setSuppressBatteryNotification(boolean suppressBatteryNotification) {
+        this.suppressBatteryNotification = suppressBatteryNotification;
+    }
+
+    /** Sets whether the account key data is within suppress type or not. */
+    public void setSuppressSubsequentPairingNotification(boolean isSuppress) {
+        suppressSubsequentPairingNotification = isSuppress;
+    }
+
+    /** Calls this to start advertising after some values are changed. */
+    public void startAdvertising() {
+        advertiser.startAdvertising(getServiceData());
+    }
+
+    /** Send Event Message on to rfcomm connected devices. */
+    public void sendEventStreamMessageToRfcommDevices(EventStreamProtocol.EventGroup eventGroup) {
+        // Send fake log when event code is logging and type is not using Log_Full event.
+        if (eventGroup == EventStreamProtocol.EventGroup.LOGGING && !useLogFullEvent) {
+            rfcommServer.sendFakeEventStreamLoggingMessage(
+                    getDeviceName()
+                            + " "
+                            + getBleAddress()
+                            + " send log at "
+                            + new SimpleDateFormat("HH:mm:ss:SSS", Locale.US)
+                            .format(Calendar.getInstance().getTime()));
+        } else {
+            rfcommServer.sendFakeEventStreamMessage(eventGroup);
+        }
+    }
+
+    public void setUseLogFullEvent(boolean useLogFullEvent) {
+        this.useLogFullEvent = useLogFullEvent;
+    }
+
+    /** An optional way to get status updates. */
+    public interface Callback {
+        /** Called when we change our BLE advertisement. */
+        void onAdvertisingChanged();
+    }
+
+    /** A way for tests to get callbacks when passkey confirmation is invoked. */
+    public interface PasskeyConfirmationCallback {
+        void onPasskeyConfirmation(boolean confirm);
+    }
+
+    /** A way for simulator UI update to get callback when device name is changed. */
+    public interface DeviceNameCallback {
+        void onNameChanged(String deviceName);
+    }
+
+    /**
+     * Callback when there comes a passkey input request from BT service, or receiving remote device's
+     * passkey.
+     */
+    public interface PasskeyEventCallback {
+        void onPasskeyRequested(KeyInputCallback keyInputCallback);
+
+        void onRemotePasskeyReceived(int passkey);
+
+        default void onPasskeyConfirmation(int passkey, Consumer<Boolean> isConfirmed) {
+        }
+    }
+
+    /** Options for the simulator. */
+    public static class Options {
+        private final String mModelId;
+
+        // TODO(b/143117318):Remove this when app-launch type has its own anti-spoofing key.
+        private final String mAdvertisingModelId;
+
+        @Nullable
+        private final String mBluetoothAddress;
+
+        @Nullable
+        private final String mBleAddress;
+
+        private final boolean mDataOnlyConnection;
+
+        private final int mTxPowerLevel;
+
+        private final boolean mEnableNameCharacteristic;
+
+        private final Callback mCallback;
+
+        private final boolean mIncludeTransportDataDescriptor;
+
+        @Nullable
+        private final byte[] mAntiSpoofingPrivateKey;
+
+        private final boolean mUseRandomSaltForAccountKeyRotation;
+
+        private final boolean mIsMemoryTest;
+
+        private final boolean mBecomeDiscoverable;
+
+        private final boolean mShowsPasskeyConfirmation;
+
+        private final boolean mEnableBeaconActionsCharacteristic;
+
+        private final boolean mRemoveAllDevicesDuringPairing;
+
+        @Nullable
+        private final ByteString mEddystoneIdentityKey;
+
+        private Options(
+                String modelId,
+                String advertisingModelId,
+                @Nullable String bluetoothAddress,
+                @Nullable String bleAddress,
+                boolean dataOnlyConnection,
+                int txPowerLevel,
+                boolean enableNameCharacteristic,
+                Callback callback,
+                boolean includeTransportDataDescriptor,
+                @Nullable byte[] antiSpoofingPrivateKey,
+                boolean useRandomSaltForAccountKeyRotation,
+                boolean isMemoryTest,
+                boolean becomeDiscoverable,
+                boolean showsPasskeyConfirmation,
+                boolean enableBeaconActionsCharacteristic,
+                boolean removeAllDevicesDuringPairing,
+                @Nullable ByteString eddystoneIdentityKey) {
+            this.mModelId = modelId;
+            this.mAdvertisingModelId = advertisingModelId;
+            this.mBluetoothAddress = bluetoothAddress;
+            this.mBleAddress = bleAddress;
+            this.mDataOnlyConnection = dataOnlyConnection;
+            this.mTxPowerLevel = txPowerLevel;
+            this.mEnableNameCharacteristic = enableNameCharacteristic;
+            this.mCallback = callback;
+            this.mIncludeTransportDataDescriptor = includeTransportDataDescriptor;
+            this.mAntiSpoofingPrivateKey = antiSpoofingPrivateKey;
+            this.mUseRandomSaltForAccountKeyRotation = useRandomSaltForAccountKeyRotation;
+            this.mIsMemoryTest = isMemoryTest;
+            this.mBecomeDiscoverable = becomeDiscoverable;
+            this.mShowsPasskeyConfirmation = showsPasskeyConfirmation;
+            this.mEnableBeaconActionsCharacteristic = enableBeaconActionsCharacteristic;
+            this.mRemoveAllDevicesDuringPairing = removeAllDevicesDuringPairing;
+            this.mEddystoneIdentityKey = eddystoneIdentityKey;
+        }
+
+        public String getModelId() {
+            return mModelId;
+        }
+
+        // TODO(b/143117318):Remove this when app-launch type has its own anti-spoofing key.
+        public String getAdvertisingModelId() {
+            return mAdvertisingModelId;
+        }
+
+        @Nullable
+        public String getBluetoothAddress() {
+            return mBluetoothAddress;
+        }
+
+        @Nullable
+        public String getBleAddress() {
+            return mBleAddress;
+        }
+
+        public boolean getDataOnlyConnection() {
+            return mDataOnlyConnection;
+        }
+
+        public int getTxPowerLevel() {
+            return mTxPowerLevel;
+        }
+
+        public boolean getEnableNameCharacteristic() {
+            return mEnableNameCharacteristic;
+        }
+
+        public Callback getCallback() {
+            return mCallback;
+        }
+
+        public boolean getIncludeTransportDataDescriptor() {
+            return mIncludeTransportDataDescriptor;
+        }
+
+        @Nullable
+        public byte[] getAntiSpoofingPrivateKey() {
+            return mAntiSpoofingPrivateKey;
+        }
+
+        public boolean getUseRandomSaltForAccountKeyRotation() {
+            return mUseRandomSaltForAccountKeyRotation;
+        }
+
+        public boolean getIsMemoryTest() {
+            return mIsMemoryTest;
+        }
+
+        public boolean getBecomeDiscoverable() {
+            return mBecomeDiscoverable;
+        }
+
+        public boolean getShowsPasskeyConfirmation() {
+            return mShowsPasskeyConfirmation;
+        }
+
+        public boolean getEnableBeaconActionsCharacteristic() {
+            return mEnableBeaconActionsCharacteristic;
+        }
+
+        public boolean getRemoveAllDevicesDuringPairing() {
+            return mRemoveAllDevicesDuringPairing;
+        }
+
+        @Nullable
+        public ByteString getEddystoneIdentityKey() {
+            return mEddystoneIdentityKey;
+        }
+
+        /** Converts an instance to a builder. */
+        public Builder toBuilder() {
+            return new Options.Builder(this);
+        }
+
+        /** Constructs a builder. */
+        public static Builder builder() {
+            return new Options.Builder();
+        }
+
+        /** @param modelId Must be a 3-byte hex string. */
+        public static Builder builder(String modelId) {
+            return new Options.Builder()
+                    .setModelId(Ascii.toUpperCase(modelId))
+                    .setAdvertisingModelId(Ascii.toUpperCase(modelId))
+                    .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
+                    .setCallback(() -> {
+                    })
+                    .setIncludeTransportDataDescriptor(true)
+                    .setUseRandomSaltForAccountKeyRotation(false)
+                    .setEnableNameCharacteristic(true)
+                    .setDataOnlyConnection(false)
+                    .setIsMemoryTest(false)
+                    .setBecomeDiscoverable(true)
+                    .setShowsPasskeyConfirmation(false)
+                    .setEnableBeaconActionsCharacteristic(true)
+                    .setRemoveAllDevicesDuringPairing(true);
+        }
+
+        /** A builder for {@link Options}. */
+        public static class Builder {
+
+            private String mModelId;
+
+            // TODO(b/143117318):Remove this when app-launch type has its own anti-spoofing key.
+            private String mAdvertisingModelId;
+
+            @Nullable
+            private String mBluetoothAddress;
+
+            @Nullable
+            private String mbleAddress;
+
+            private boolean mDataOnlyConnection;
+
+            private int mTxPowerLevel;
+
+            private boolean mEnableNameCharacteristic;
+
+            private Callback mCallback;
+
+            private boolean mIncludeTransportDataDescriptor;
+
+            @Nullable
+            private byte[] mAntiSpoofingPrivateKey;
+
+            private boolean mUseRandomSaltForAccountKeyRotation;
+
+            private boolean mIsMemoryTest;
+
+            private boolean mBecomeDiscoverable;
+
+            private boolean mShowsPasskeyConfirmation;
+
+            private boolean mEnableBeaconActionsCharacteristic;
+
+            private boolean mRemoveAllDevicesDuringPairing;
+
+            @Nullable
+            private ByteString mEddystoneIdentityKey;
+
+            private Builder() {
+            }
+
+            private Builder(Options option) {
+                this.mModelId = option.mModelId;
+                this.mAdvertisingModelId = option.mAdvertisingModelId;
+                this.mBluetoothAddress = option.mBluetoothAddress;
+                this.mbleAddress = option.mBleAddress;
+                this.mDataOnlyConnection = option.mDataOnlyConnection;
+                this.mTxPowerLevel = option.mTxPowerLevel;
+                this.mEnableNameCharacteristic = option.mEnableNameCharacteristic;
+                this.mCallback = option.mCallback;
+                this.mIncludeTransportDataDescriptor = option.mIncludeTransportDataDescriptor;
+                this.mAntiSpoofingPrivateKey = option.mAntiSpoofingPrivateKey;
+                this.mUseRandomSaltForAccountKeyRotation = option.mUseRandomSaltForAccountKeyRotation;
+                this.mIsMemoryTest = option.mIsMemoryTest;
+                this.mBecomeDiscoverable = option.mBecomeDiscoverable;
+                this.mShowsPasskeyConfirmation = option.mShowsPasskeyConfirmation;
+                this.mEnableBeaconActionsCharacteristic = option.mEnableBeaconActionsCharacteristic;
+                this.mRemoveAllDevicesDuringPairing = option.mRemoveAllDevicesDuringPairing;
+                this.mEddystoneIdentityKey = option.mEddystoneIdentityKey;
+            }
+
+            /**
+             * Must be one of the {@code ADVERTISE_TX_POWER_*} levels in {@link AdvertiseSettings}.
+             * Default is HIGH.
+             */
+            public Builder setTxPowerLevel(int txPowerLevel) {
+                this.mTxPowerLevel = txPowerLevel;
+                return this;
+            }
+
+            /** Must be a 6-byte hex string (optionally with colons). Default is this device's BT MAC. */
+            public Builder setBluetoothAddress(@Nullable String bluetoothAddress) {
+                this.mBluetoothAddress = bluetoothAddress;
+                return this;
+            }
+
+            public Builder setBleAddress(@Nullable String bleAddress) {
+                this.mbleAddress = bleAddress;
+                return this;
+            }
+
+            /** A boolean to decide if enable name characteristic as simulator characteristic. */
+            public Builder setEnableNameCharacteristic(boolean enable) {
+                this.mEnableNameCharacteristic = enable;
+                return this;
+            }
+
+            /** @see Callback */
+            public Builder setCallback(Callback callback) {
+                this.mCallback = callback;
+                return this;
+            }
+
+            public Builder setDataOnlyConnection(boolean dataOnlyConnection) {
+                this.mDataOnlyConnection = dataOnlyConnection;
+                return this;
+            }
+
+            /**
+             * Set whether to include the Transport Data descriptor, which has the list of supported
+             * profiles. This is required by the spec, but if we can't get it, we recover gracefully by
+             * assuming support for one of {A2DP, Headset}. Default is true.
+             */
+            public Builder setIncludeTransportDataDescriptor(boolean includeTransportDataDescriptor) {
+                this.mIncludeTransportDataDescriptor = includeTransportDataDescriptor;
+                return this;
+            }
+
+            public Builder setAntiSpoofingPrivateKey(@Nullable byte[] antiSpoofingPrivateKey) {
+                this.mAntiSpoofingPrivateKey = antiSpoofingPrivateKey;
+                return this;
+            }
+
+            public Builder setUseRandomSaltForAccountKeyRotation(
+                    boolean useRandomSaltForAccountKeyRotation) {
+                this.mUseRandomSaltForAccountKeyRotation = useRandomSaltForAccountKeyRotation;
+                return this;
+            }
+
+            // TODO(b/143117318):Remove this when app-launch type has its own anti-spoofing key.
+            public Builder setAdvertisingModelId(String modelId) {
+                this.mAdvertisingModelId = modelId;
+                return this;
+            }
+
+            public Builder setIsMemoryTest(boolean isMemoryTest) {
+                this.mIsMemoryTest = isMemoryTest;
+                return this;
+            }
+
+            public Builder setBecomeDiscoverable(boolean becomeDiscoverable) {
+                this.mBecomeDiscoverable = becomeDiscoverable;
+                return this;
+            }
+
+            public Builder setShowsPasskeyConfirmation(boolean showsPasskeyConfirmation) {
+                this.mShowsPasskeyConfirmation = showsPasskeyConfirmation;
+                return this;
+            }
+
+            public Builder setEnableBeaconActionsCharacteristic(
+                    boolean enableBeaconActionsCharacteristic) {
+                this.mEnableBeaconActionsCharacteristic = enableBeaconActionsCharacteristic;
+                return this;
+            }
+
+            public Builder setRemoveAllDevicesDuringPairing(boolean removeAllDevicesDuringPairing) {
+                this.mRemoveAllDevicesDuringPairing = removeAllDevicesDuringPairing;
+                return this;
+            }
+
+            /** Non-public because this is required to create a builder. See {@link Options#builder}. */
+            public Builder setModelId(String modelId) {
+                this.mModelId = modelId;
+                return this;
+            }
+
+            public Builder setEddystoneIdentityKey(@Nullable ByteString eddystoneIdentityKey) {
+                this.mEddystoneIdentityKey = eddystoneIdentityKey;
+                return this;
+            }
+
+            // Custom builder in order to normalize properties. go/autovalue/builders-howto
+            public Options build() {
+                return new Options(
+                        Ascii.toUpperCase(mModelId),
+                        Ascii.toUpperCase(mAdvertisingModelId),
+                        mBluetoothAddress,
+                        mbleAddress,
+                        mDataOnlyConnection,
+                        mTxPowerLevel,
+                        mEnableNameCharacteristic,
+                        mCallback,
+                        mIncludeTransportDataDescriptor,
+                        mAntiSpoofingPrivateKey,
+                        mUseRandomSaltForAccountKeyRotation,
+                        mIsMemoryTest,
+                        mBecomeDiscoverable,
+                        mShowsPasskeyConfirmation,
+                        mEnableBeaconActionsCharacteristic,
+                        mRemoveAllDevicesDuringPairing,
+                        mEddystoneIdentityKey);
+            }
+        }
+    }
+
+    public FastPairSimulator(Context context, Options options) {
+        this.context = context;
+        this.options = options;
+
+        this.batteryValues = new ArrayList<>();
+
+        String bluetoothAddress =
+                !TextUtils.isEmpty(options.getBluetoothAddress())
+                        ? options.getBluetoothAddress()
+                        : Settings.Secure.getString(context.getContentResolver(), "bluetooth_address");
+        if (bluetoothAddress == null && VERSION.SDK_INT >= VERSION_CODES.O) {
+            // Requires a modified Android O build for access to bluetoothAdapter.getAddress().
+            // See http://google3/java/com/google/location/nearby/apps/fastpair/simulator/README.md.
+            bluetoothAddress = bluetoothAdapter.getAddress();
+        }
+        this.bluetoothAddress =
+                new Value(BluetoothAddress.decode(bluetoothAddress), ByteOrder.BIG_ENDIAN);
+        this.bleAddress = options.getBleAddress();
+        this.dataOnlyConnection = options.getDataOnlyConnection();
+        this.advertiser = new OreoFastPairAdvertiser(this);
+
+        fastPairSimulatorDatabase = new FastPairSimulatorDatabase(context);
+
+        byte[] deviceName = getDeviceNameInBytes();
+        logger.log(
+                "Provider default device name is %s",
+                deviceName != null ? new String(deviceName, StandardCharsets.UTF_8) : null);
+
+        if (dataOnlyConnection) {
+            // To get BLE address, we need to start advertising first, and then {@code #setBleAddress}
+            // will be called with BLE address.
+            advertiser.startAdvertising(modelIdServiceData(/* forAdvertising= */ true));
+        } else {
+            // TODO(jklinker): Make this so that the simulator doesn't start automatically. This is tricky
+            // since the simulator is used in our integ tests as well.
+            start(bleAddress != null ? bleAddress : bluetoothAddress);
+        }
+    }
+
+    public void start(String address) {
+        IntentFilter filter = new IntentFilter(BluetoothAdapter.ACTION_SCAN_MODE_CHANGED);
+        filter.addAction(BluetoothDevice.ACTION_PAIRING_REQUEST);
+        filter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED);
+        filter.addAction(BluetoothAdapter.ACTION_CONNECTION_STATE_CHANGED);
+        filter.addAction(BluetoothAdapter.ACTION_STATE_CHANGED);
+        context.registerReceiver(broadcastReceiver, filter);
+
+        BluetoothManager bluetoothManager =
+                (BluetoothManager) context.getSystemService(Context.BLUETOOTH_SERVICE);
+        BluetoothGattServerHelper bluetoothGattServerHelper =
+                new BluetoothGattServerHelper(context, wrap(bluetoothManager));
+        bluetoothGattServerHelpers.put(address, bluetoothGattServerHelper);
+
+        if (options.getBecomeDiscoverable()) {
+            try {
+                becomeDiscoverable();
+            } catch (InterruptedException | TimeoutException e) {
+                logger.log(e, "Error becoming discoverable");
+            }
+        }
+
+        advertiser.startAdvertising(modelIdServiceData(/* forAdvertising= */ true));
+        startGattServer(bluetoothGattServerHelper);
+        startRfcommServer();
+        scheduleAdvertisingRefresh();
+    }
+
+    /**
+     * Regenerate service data on a fixed interval. This causes the bloom filter to be refreshed and a
+     * different salt to be used for rotation.
+     */
+    @SuppressWarnings("FutureReturnValueIgnored")
+    private void scheduleAdvertisingRefresh() {
+        executor.scheduleAtFixedRate(
+                () -> {
+                    if (isAdvertising) {
+                        advertiser.startAdvertising(getServiceData());
+                    }
+                },
+                options.getIsMemoryTest()
+                        ? ADVERTISING_REFRESH_DELAY_5_MINS
+                        : ADVERTISING_REFRESH_DELAY_1_MIN,
+                options.getIsMemoryTest()
+                        ? ADVERTISING_REFRESH_DELAY_5_MINS
+                        : ADVERTISING_REFRESH_DELAY_1_MIN,
+                TimeUnit.MILLISECONDS);
+    }
+
+    public void destroy() {
+        try {
+            logger.log("Destroying simulator");
+            isDestroyed = true;
+            context.unregisterReceiver(broadcastReceiver);
+            advertiser.stopAdvertising();
+            for (BluetoothGattServerHelper helper : bluetoothGattServerHelpers.values()) {
+                helper.close();
+            }
+            stopRfcommServer();
+            deviceNameCallback = null;
+            executor.shutdownNow();
+        } catch (IllegalArgumentException ignored) {
+            // Happens if you haven't given us permissions yet, so we didn't register the receiver.
+        }
+    }
+
+    public boolean isDestroyed() {
+        return isDestroyed;
+    }
+
+    @Nullable
+    public String getBluetoothAddress() {
+        return BluetoothAddress.encode(bluetoothAddress.getBytes(ByteOrder.BIG_ENDIAN));
+    }
+
+    public boolean isAdvertising() {
+        return isAdvertising;
+    }
+
+    public void setIsAdvertising(boolean isAdvertising) {
+        if (this.isAdvertising != isAdvertising) {
+            this.isAdvertising = isAdvertising;
+            options.getCallback().onAdvertisingChanged();
+        }
+    }
+
+    public void stopAdvertising() {
+        advertiser.stopAdvertising();
+    }
+
+    public void setBleAddress(String bleAddress) {
+        this.bleAddress = bleAddress;
+        if (dataOnlyConnection) {
+            bluetoothAddress = new Value(BluetoothAddress.decode(bleAddress), ByteOrder.BIG_ENDIAN);
+            start(bleAddress);
+        }
+        // When BLE address changes, needs to send BLE address to the client again.
+        sendDeviceBleAddress(bleAddress);
+
+        // If we are advertising something other than the model id (eg the bloom filter), restart the
+        // advertisement so that it is updated with the new address.
+        if (isAdvertising() && !isDiscoverable()) {
+            advertiser.startAdvertising(getServiceData());
+        }
+    }
+
+    @Nullable
+    public String getBleAddress() {
+        return bleAddress;
+    }
+
+    // This method is only for testing to make test block until write name success or time out.
+    @VisibleForTesting
+    public void setCountDownLatchToWriteName(CountDownLatch countDownLatch) {
+        logger.log("Set up count down latch to write device name.");
+        writeNameCountDown = countDownLatch;
+    }
+
+    public boolean areBeaconActionsNotificationsEnabled() {
+        return beaconActionsServlet.areNotificationsEnabled();
+    }
+
+    private abstract class NotifiableGattServlet extends BluetoothGattServlet {
+        private final Map<BluetoothGattServerConnection, Notifier> connections = new HashMap<>();
+
+        abstract BluetoothGattCharacteristic getBaseCharacteristic();
+
+        @Override
+        public BluetoothGattCharacteristic getCharacteristic() {
+            // Enabling indication requires the Client Characteristic Configuration descriptor.
+            BluetoothGattCharacteristic characteristic = getBaseCharacteristic();
+            characteristic.addDescriptor(
+                    new BluetoothGattDescriptor(
+                            Constants.CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR_UUID,
+                            BluetoothGattDescriptor.PERMISSION_READ | BluetoothGattDescriptor.PERMISSION_WRITE));
+            return characteristic;
+        }
+
+        @Override
+        public void enableNotification(BluetoothGattServerConnection connection, Notifier notifier)
+                throws BluetoothGattException {
+            logger.log("Registering notifier for %s", getCharacteristic());
+            connections.put(connection, notifier);
+        }
+
+        @Override
+        public void disableNotification(BluetoothGattServerConnection connection, Notifier notifier)
+                throws BluetoothGattException {
+            logger.log("Removing notifier for %s", getCharacteristic());
+            connections.remove(connection);
+        }
+
+        boolean areNotificationsEnabled() {
+            return !connections.isEmpty();
+        }
+
+        void sendNotification(byte[] data) {
+            if (connections.isEmpty()) {
+                logger.log("Not sending notify as no notifier registered");
+                return;
+            }
+            // Needs to be on a separate thread to avoid deadlocking and timing out (waits for a
+            // callback from OS, which happens on the main thread).
+            executor.execute(
+                    () -> {
+                        for (Map.Entry<BluetoothGattServerConnection, Notifier> entry :
+                                connections.entrySet()) {
+                            try {
+                                logger.log(
+                                        "Sending notify %s to %s",
+                                        getCharacteristic(), entry.getKey().getDevice().getAddress());
+                                entry.getValue().notify(data);
+                            } catch (BluetoothException e) {
+                                logger.log(
+                                        e,
+                                        "Failed to notify (indicate) result of %s to %s",
+                                        getCharacteristic(),
+                                        entry.getKey().getDevice().getAddress());
+                            }
+                        }
+                    });
+        }
+    }
+
+    private void startRfcommServer() {
+        rfcommServer.setRequestHandler(
+                (int eventGroup, int eventCode, byte[] data) -> {
+                    switch (eventGroup) {
+                        case EventStreamProtocol.EventGroup.DEVICE_VALUE:
+                            if (eventCode == EventStreamProtocol.DeviceEventCode.DEVICE_CAPABILITY_VALUE
+                                    && data != null) {
+                                logger.log("Received phone capability: %s", base16().encode(data));
+                            } else if (eventCode == EventStreamProtocol.DeviceEventCode.PLATFORM_TYPE_VALUE
+                                    && data != null) {
+                                logger.log("Received platform type: %s", base16().encode(data));
+                            }
+                            break;
+                        case EventStreamProtocol.EventGroup.DEVICE_ACTION_VALUE:
+                            if (eventCode == EventStreamProtocol.DeviceActionEventCode.DEVICE_ACTION_RING_VALUE) {
+                                logger.log("receive device action with ring value, data = %d", data[0]);
+                                sendDeviceRingActionResponse();
+                                // Simulate notifying the seeker that the ringing has stopped due to user
+                                // interaction (such as tapping the bud).
+                                uiThreadHandler.postDelayed(this::sendDeviceRingStoppedAction, 5000);
+                            }
+                            break;
+                        case EventStreamProtocol.EventGroup.DEVICE_CONFIGURATION_VALUE:
+                            if (eventCode
+                                    == EventStreamProtocol.DeviceConfigurationEventCode.CONFIGURATION_BUFFER_SIZE_VALUE) {
+                                logger.log(
+                                        "receive device action with buffer size value, data = %s",
+                                        base16().encode(data));
+                                sendSetBufferActionResponse(data);
+                            }
+                            break;
+                        case EventStreamProtocol.EventGroup.DEVICE_CAPABILITY_SYNC_VALUE:
+                            if (eventCode
+                                    == EventStreamProtocol.DeviceCapabilitySyncEventCode.REQUEST_CAPABILITY_UPDATE_VALUE) {
+                                logger.log("receive device capability update request.");
+                                sendCapabilitySync();
+                            }
+                            break;
+                        default: // fall out
+                    }
+                });
+        rfcommServer.setStateMonitor(
+                state -> {
+                    logger.log("RfcommServer is in %s state", state);
+                    if (CONNECTED.equals(state)) {
+                        sendModelId();
+                        sendDeviceBleAddress(bleAddress);
+                        sendFirmwareVersion();
+                        sendSessionNonce();
+                    }
+                });
+
+        rfcommServer.start();
+    }
+
+    private void stopRfcommServer() {
+        rfcommServer.stop();
+        rfcommServer.setRequestHandler(null);
+        rfcommServer.setStateMonitor(null);
+    }
+
+    private void sendModelId() {
+        logger.log("Send model ID to the client");
+        rfcommServer.send(
+                EventStreamProtocol.EventGroup.DEVICE_VALUE,
+                EventStreamProtocol.DeviceEventCode.DEVICE_MODEL_ID_VALUE,
+                modelIdServiceData(/* forAdvertising= */ false));
+    }
+
+    private void sendDeviceBleAddress(String bleAddress) {
+        logger.log("Send BLE address (%s) to the client", bleAddress);
+        // TODO(b/134244147): to solve central address resolution problem, adds api for simulator app.
+        if (bleAddress != null) {
+            rfcommServer.send(
+                    EventStreamProtocol.EventGroup.DEVICE_VALUE,
+                    EventStreamProtocol.DeviceEventCode.DEVICE_BLE_ADDRESS_VALUE,
+                    BluetoothAddress.decode(bleAddress));
+        }
+    }
+
+    private void sendFirmwareVersion() {
+        logger.log("Send Firmware Version (%s) to the client", deviceFirmwareVersion);
+        rfcommServer.send(
+                EventStreamProtocol.EventGroup.DEVICE_VALUE,
+                EventStreamProtocol.DeviceEventCode.FIRMWARE_VERSION_VALUE,
+                deviceFirmwareVersion.getBytes());
+    }
+
+    private void sendSessionNonce() {
+        logger.log("Send SessionNonce (%s) to the client", deviceFirmwareVersion);
+        SecureRandom secureRandom = new SecureRandom();
+        sessionNonce = new byte[SECTION_NONCE_LENGTH];
+        secureRandom.nextBytes(sessionNonce);
+        rfcommServer.send(EventStreamProtocol.EventGroup.DEVICE_VALUE,
+                EventStreamProtocol.DeviceEventCode.SECTION_NONCE_VALUE, sessionNonce);
+    }
+
+    private void sendDeviceRingActionResponse() {
+        logger.log("Send device ring action response to the client");
+        rfcommServer.send(
+                EventStreamProtocol.EventGroup.ACKNOWLEDGEMENT_VALUE,
+                EventStreamProtocol.AcknowledgementEventCode.ACKNOWLEDGEMENT_ACK_VALUE,
+                new byte[]{
+                        EventStreamProtocol.EventGroup.DEVICE_ACTION_VALUE,
+                        EventStreamProtocol.DeviceActionEventCode.DEVICE_ACTION_RING_VALUE
+                });
+    }
+
+    private void sendSetBufferActionResponse(byte[] data) {
+        boolean hmacPassed = false;
+        for (ByteString accountKey : getAccountKeys()) {
+            try {
+                if (MessageStreamHmacEncoder.verifyHmac(accountKey.toByteArray(), sessionNonce, data)) {
+                    hmacPassed = true;
+                    logger.log(
+                            "Buffer size data matches account key %s", base16().encode(accountKey.toByteArray()));
+                    break;
+                }
+            } catch (GeneralSecurityException e) {
+                // Ignore.
+            }
+        }
+        if (hmacPassed) {
+            logger.log("Send buffer size action response %s to the client", base16().encode(data));
+            rfcommServer.send(
+                    EventStreamProtocol.EventGroup.ACKNOWLEDGEMENT_VALUE,
+                    EventStreamProtocol.AcknowledgementEventCode.ACKNOWLEDGEMENT_ACK_VALUE,
+                    new byte[]{
+                            EventStreamProtocol.EventGroup.DEVICE_CONFIGURATION_VALUE,
+                            EventStreamProtocol.DeviceConfigurationEventCode.CONFIGURATION_BUFFER_SIZE_VALUE,
+                            data[0],
+                            data[1],
+                            data[2]
+                    });
+        } else {
+            logger.log("No matched account key for sendSetBufferActionResponse");
+        }
+    }
+
+    private void sendCapabilitySync() {
+        logger.log("Send capability sync to the client");
+        if (supportDynamicBufferSize) {
+            logger.log("Send dynamic buffer size range to the client");
+            rfcommServer.send(
+                    EventStreamProtocol.EventGroup.DEVICE_CAPABILITY_SYNC_VALUE,
+                    EventStreamProtocol.DeviceCapabilitySyncEventCode.CONFIGURABLE_BUFFER_SIZE_RANGE_VALUE,
+                    new byte[]{
+                            0x00, 0x01, (byte) 0xf4, 0x00, 0x64, 0x00, (byte) 0xc8,
+                            0x01, 0x00, (byte) 0xff, 0x00, 0x01, 0x00, (byte) 0x88,
+                            0x02, 0x01, (byte) 0xff, 0x01, 0x01, 0x01, (byte) 0x88,
+                            0x03, 0x02, (byte) 0xff, 0x02, 0x01, 0x02, (byte) 0x88,
+                            0x04, 0x03, (byte) 0xff, 0x03, 0x01, 0x03, (byte) 0x88
+                    });
+        }
+    }
+
+    private void sendDeviceRingStoppedAction() {
+        logger.log("Sending device ring stopped action to the client");
+        rfcommServer.send(
+                EventStreamProtocol.EventGroup.DEVICE_ACTION_VALUE,
+                EventStreamProtocol.DeviceActionEventCode.DEVICE_ACTION_RING_VALUE,
+                // Additional data for stopping ringing on all components.
+                new byte[]{0x00});
+    }
+
+    private void startGattServer(BluetoothGattServerHelper helper) {
+        BluetoothGattServlet tdsControlPointServlet =
+                new NotifiableGattServlet() {
+                    @Override
+                    public BluetoothGattCharacteristic getBaseCharacteristic() {
+                        return new BluetoothGattCharacteristic(
+                                TransportDiscoveryService.ControlPointCharacteristic.ID,
+                                PROPERTY_WRITE | PROPERTY_INDICATE,
+                                PERMISSION_WRITE);
+                    }
+
+                    @Override
+                    public void write(BluetoothGattServerConnection connection, int offset, byte[] value)
+                            throws BluetoothGattException {
+                        logger.log("Requested TDS Control Point write, value=%s", base16().encode(value));
+
+                        ResultCode resultCode = checkTdsControlPointRequest(value);
+                        if (resultCode == ResultCode.SUCCESS) {
+                            try {
+                                becomeDiscoverable();
+                            } catch (TimeoutException | InterruptedException e) {
+                                logger.log(e, "Failed to become discoverable");
+                                resultCode = ResultCode.OPERATION_FAILED;
+                            }
+                        }
+
+                        logger.log("Request complete, resultCode=%s", resultCode);
+
+                        logger.log("Sending TDS Control Point response indication");
+                        sendNotification(
+                                Bytes.concat(
+                                        new byte[]{getTdsControlPointOpCode(value), resultCode.byteValue},
+                                        resultCode == ResultCode.SUCCESS
+                                                ? TDS_CONTROL_POINT_RESPONSE_PARAMETER
+                                                : new byte[0]));
+                    }
+                };
+
+        BluetoothGattServlet brHandoverDataServlet =
+                new BluetoothGattServlet() {
+
+                    @Override
+                    public BluetoothGattCharacteristic getCharacteristic() {
+                        return new BluetoothGattCharacteristic(
+                                TransportDiscoveryService.BrHandoverDataCharacteristic.ID,
+                                PROPERTY_READ,
+                                PERMISSION_READ);
+                    }
+
+                    @Override
+                    public byte[] read(BluetoothGattServerConnection connection, int offset)
+                            throws BluetoothGattException {
+                        return Bytes.concat(
+                                new byte[]{TransportDiscoveryService.BrHandoverDataCharacteristic.BR_EDR_FEATURES},
+                                bluetoothAddress.getBytes(ByteOrder.LITTLE_ENDIAN),
+                                CLASS_OF_DEVICE.getBytes(ByteOrder.LITTLE_ENDIAN));
+                    }
+                };
+
+        BluetoothGattServlet bluetoothSigServlet =
+                new BluetoothGattServlet() {
+
+                    @Override
+                    public BluetoothGattCharacteristic getCharacteristic() {
+                        BluetoothGattCharacteristic characteristic =
+                                new BluetoothGattCharacteristic(
+                                        TransportDiscoveryService.BluetoothSigDataCharacteristic.ID,
+                                        0 /* no properties */,
+                                        0 /* no permissions */);
+
+                        if (options.getIncludeTransportDataDescriptor()) {
+                            characteristic.addDescriptor(
+                                    new BluetoothGattDescriptor(
+                                            TransportDiscoveryService.BluetoothSigDataCharacteristic
+                                                    .BrTransportBlockDataDescriptor.ID,
+                                            BluetoothGattDescriptor.PERMISSION_READ));
+                        }
+                        return characteristic;
+                    }
+
+                    @Override
+                    public byte[] readDescriptor(
+                            BluetoothGattServerConnection connection,
+                            BluetoothGattDescriptor descriptor,
+                            int offset)
+                            throws BluetoothGattException {
+                        return transportDiscoveryData();
+                    }
+                };
+
+        BluetoothGattServlet accountKeyServlet =
+                new BluetoothGattServlet() {
+                    @Override
+                    // Simulating deprecated API {@code AccountKeyCharacteristic.ID} for testing.
+                    @SuppressWarnings("deprecation")
+                    public BluetoothGattCharacteristic getCharacteristic() {
+                        return new BluetoothGattCharacteristic(
+                                AccountKeyCharacteristic.CUSTOM_128_BIT_UUID,
+                                PROPERTY_WRITE,
+                                PERMISSION_WRITE);
+                    }
+
+                    @Override
+                    public void write(BluetoothGattServerConnection connection, int offset, byte[] value) {
+                        logger.log("Got value from account key servlet: %s", base16().encode(value));
+                        try {
+                            addAccountKey(AesEcbSingleBlockEncryption.decrypt(secret, value), pairingDevice);
+                        } catch (GeneralSecurityException e) {
+                            logger.log(e, "Failed to decrypt account key.");
+                        }
+                        uiThreadHandler.post(() -> advertiser.startAdvertising(accountKeysServiceData()));
+                    }
+                };
+
+        BluetoothGattServlet firmwareVersionServlet =
+                new BluetoothGattServlet() {
+                    @Override
+                    public BluetoothGattCharacteristic getCharacteristic() {
+                        return new BluetoothGattCharacteristic(
+                                FirmwareVersionCharacteristic.ID, PROPERTY_READ, PERMISSION_READ);
+                    }
+
+                    @Override
+                    public byte[] read(BluetoothGattServerConnection connection, int offset) {
+                        return deviceFirmwareVersion.getBytes();
+                    }
+                };
+
+        BluetoothGattServlet keyBasedPairingServlet =
+                new NotifiableGattServlet() {
+                    @Override
+                    // Simulating deprecated API {@code KeyBasedPairingCharacteristic.ID} for testing.
+                    @SuppressWarnings("deprecation")
+                    public BluetoothGattCharacteristic getBaseCharacteristic() {
+                        return new BluetoothGattCharacteristic(
+                                KeyBasedPairingCharacteristic.CUSTOM_128_BIT_UUID,
+                                PROPERTY_WRITE | PROPERTY_INDICATE,
+                                PERMISSION_WRITE);
+                    }
+
+                    @Override
+                    public void write(BluetoothGattServerConnection connection, int offset, byte[] value)
+                            throws BluetoothGattException {
+                        logger.log("Requesting key based pairing handshake, value=%s", base16().encode(value));
+
+                        secret = null;
+                        byte[] seekerPublicAddress = null;
+                        if (value.length == AES_BLOCK_LENGTH) {
+
+                            for (ByteString key : getAccountKeys()) {
+                                byte[] candidateSecret = key.toByteArray();
+                                try {
+                                    seekerPublicAddress = handshake(candidateSecret, value);
+                                    secret = candidateSecret;
+                                    isSubsequentPair = true;
+                                    break;
+                                } catch (GeneralSecurityException e) {
+                                    logger.log(e, "Failed to decrypt with %s", base16().encode(candidateSecret));
+                                }
+                            }
+                        } else if (value.length
+                                == AES_BLOCK_LENGTH + EllipticCurveDiffieHellmanExchange.PUBLIC_KEY_LENGTH
+                                && options.getAntiSpoofingPrivateKey() != null) {
+                            try {
+                                byte[] encryptedRequest = Arrays.copyOf(value, AES_BLOCK_LENGTH);
+                                byte[] receivedPublicKey =
+                                        Arrays.copyOfRange(value, AES_BLOCK_LENGTH, value.length);
+                                byte[] candidateSecret =
+                                        EllipticCurveDiffieHellmanExchange.create(options.getAntiSpoofingPrivateKey())
+                                                .generateSecret(receivedPublicKey);
+                                seekerPublicAddress = handshake(candidateSecret, encryptedRequest);
+                                secret = candidateSecret;
+                            } catch (Exception e) {
+                                logger.log(
+                                        e,
+                                        "Failed to decrypt with anti-spoofing private key %s",
+                                        base16().encode(options.getAntiSpoofingPrivateKey()));
+                            }
+                        } else {
+                            logger.log("Packet length invalid, %d", value.length);
+                            return;
+                        }
+
+                        if (secret == null) {
+                            logger.log("Couldn't find a usable key to decrypt with.");
+                            return;
+                        }
+
+                        logger.log("Found valid decryption key, %s", base16().encode(secret));
+                        byte[] salt = new byte[9];
+                        new Random().nextBytes(salt);
+                        try {
+                            byte[] encryptedAddress =
+                                    encrypt(
+                                            secret,
+                                            Bytes.concat(
+                                                    new byte[]{KeyBasedPairingCharacteristic.Response.TYPE},
+                                                    bluetoothAddress.getBytes(ByteOrder.BIG_ENDIAN),
+                                                    salt));
+                            logger.log(
+                                    "Sending handshake response %s with size %d",
+                                    base16().encode(encryptedAddress), encryptedAddress.length);
+                            sendNotification(encryptedAddress);
+
+                            // Notify seeker for NameCharacteristic to get provider device name  when seeker
+                            // request device name flag is true.
+                            if (options.getEnableNameCharacteristic() && handshakeRequest.requestDeviceName()) {
+                                byte[] encryptedResponse =
+                                        getDeviceNameInBytes() != null ? createEncryptedDeviceName() : new byte[0];
+                                logger.log(
+                                        "Sending device name response %s with size %d",
+                                        base16().encode(encryptedResponse), encryptedResponse.length);
+                                deviceNameServlet.sendNotification(encryptedResponse);
+                            }
+
+                            // Disconnects the current connection to allow the following pairing request.
+                            // Needs to be on a separate thread to avoid deadlocking and timing out (waits for a
+                            // callback from OS, which happens on this thread).
+                            //
+                            // Note: The spec does not require you to disconnect from other devices at this point.
+                            // If headphones support multiple simultaneous connections, they should stay
+                            // connected. But Android fails to pair with the new device if we don't first
+                            // disconnect from any other device.
+                            logger.log("Skip remove bond, value=%s", options.getRemoveAllDevicesDuringPairing());
+                            if (options.getRemoveAllDevicesDuringPairing()
+                                    && handshakeRequest.getType() == HandshakeRequest.Type.KEY_BASED_PAIRING_REQUEST
+                                    && !handshakeRequest.requestRetroactivePair()) {
+                                executor.execute(() -> disconnect());
+                            }
+
+                            if (handshakeRequest.getType() == HandshakeRequest.Type.KEY_BASED_PAIRING_REQUEST
+                                    && handshakeRequest.requestProviderInitialBonding()) {
+                                // Run on executor to ensure it doesn't happen until after the notify (which tells
+                                // the remote device what address to expect).
+                                String seekerPublicAddressString = BluetoothAddress.encode(seekerPublicAddress);
+                                executor.execute(
+                                        () -> {
+                                            logger.log("Sending pairing request to %s", seekerPublicAddressString);
+                                            bluetoothAdapter.getRemoteDevice(seekerPublicAddressString).createBond();
+                                        });
+                            }
+                        } catch (GeneralSecurityException e) {
+                            logger.log(e, "Failed to notify of static mac address");
+                        }
+                    }
+
+                    @Nullable
+                    private byte[] handshake(byte[] key, byte[] encryptedPairingRequest)
+                            throws GeneralSecurityException {
+                        handshakeRequest = new HandshakeRequest(key, encryptedPairingRequest);
+
+                        byte[] decryptedAddress = handshakeRequest.getVerificationData();
+                        if (bleAddress != null
+                                && Arrays.equals(decryptedAddress, BluetoothAddress.decode(bleAddress))
+                                || (Arrays.equals(
+                                decryptedAddress, bluetoothAddress.getBytes(ByteOrder.BIG_ENDIAN)))) {
+                            logger.log("Address matches: %s", base16().encode(decryptedAddress));
+                        } else {
+                            throw new GeneralSecurityException(
+                                    "Address (BLE or BR/EDR) is not correct: "
+                                            + base16().encode(decryptedAddress)
+                                            + ", "
+                                            + bleAddress
+                                            + ", "
+                                            + getBluetoothAddress());
+                        }
+
+                        switch (handshakeRequest.getType()) {
+                            case KEY_BASED_PAIRING_REQUEST:
+                                return handleKeyBasedPairingRequest(handshakeRequest);
+                            case ACTION_OVER_BLE:
+                                return handleActionOverBleRequest(handshakeRequest);
+                            case UNKNOWN:
+                                // continue to throw the exception;
+                        }
+                        throw new GeneralSecurityException(
+                                "Type is not correct: " + handshakeRequest.getType());
+                    }
+
+                    @Nullable
+                    private byte[] handleKeyBasedPairingRequest(HandshakeRequest handshakeRequest)
+                            throws GeneralSecurityException {
+                        if (handshakeRequest.requestDiscoverable()) {
+                            logger.log("Requested discoverability");
+                            setScanMode(BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE);
+                        }
+
+                        logger.log(
+                                "KeyBasedPairing: initialBonding=%s, requestDeviceName=%s, retroactivePair=%s",
+                                handshakeRequest.requestProviderInitialBonding(),
+                                handshakeRequest.requestDeviceName(),
+                                handshakeRequest.requestRetroactivePair());
+
+                        byte[] seekerPublicAddress = null;
+                        if (handshakeRequest.requestProviderInitialBonding()
+                                || handshakeRequest.requestRetroactivePair()) {
+                            seekerPublicAddress = handshakeRequest.getSeekerPublicAddress();
+                            logger.log(
+                                    "Seeker sends BR/EDR address %s to provider",
+                                    BluetoothAddress.encode(seekerPublicAddress));
+                        }
+
+                        if (handshakeRequest.requestRetroactivePair()) {
+                            if (bluetoothAdapter.getRemoteDevice(seekerPublicAddress).getBondState()
+                                    != BluetoothDevice.BOND_BONDED) {
+                                throw new GeneralSecurityException(
+                                        "Address (BR/EDR) is not bonded: "
+                                                + BluetoothAddress.encode(seekerPublicAddress));
+                            }
+                        }
+
+                        return seekerPublicAddress;
+                    }
+
+                    @Nullable
+                    private byte[] handleActionOverBleRequest(HandshakeRequest handshakeRequest) {
+                        // TODO(wollohchou): implement action over ble request.
+                        if (handshakeRequest.requestDeviceAction()) {
+                            logger.log("Requesting action over BLE, device action");
+                        } else if (handshakeRequest.requestFollowedByAdditionalData()) {
+                            logger.log(
+                                    "Requesting action over BLE, followed by additional data, type:%s",
+                                    handshakeRequest.getAdditionalDataType());
+                        } else {
+                            logger.log("Requesting action over BLE");
+                        }
+                        return null;
+                    }
+
+                    /**
+                     * @return The encrypted device name from provider for seeker to use. See
+                     *     go/fast-pair-naming-design-doc for the encryption detail to encrypt device name.
+                     */
+                    private byte[] createEncryptedDeviceName() throws GeneralSecurityException {
+                        byte[] deviceName = getDeviceNameInBytes();
+                        String providerName = new String(deviceName, StandardCharsets.UTF_8);
+                        logger.log(
+                                "Sending handshake response for device name %s with size %d",
+                                providerName, deviceName.length);
+                        return NamingEncoder.encodeNamingPacket(secret, providerName);
+                    }
+                };
+
+        beaconActionsServlet =
+                new NotifiableGattServlet() {
+                    private static final int GATT_ERROR_UNAUTHENTICATED = 0x80;
+                    private static final int GATT_ERROR_INVALID_VALUE = 0x81;
+                    private static final int NONCE_LENGTH = 8;
+                    private static final int ONE_TIME_AUTH_KEY_OFFSET = 2;
+                    private static final int ONE_TIME_AUTH_KEY_LENGTH = 8;
+                    private static final int IDENTITY_KEY_LENGTH = 32;
+                    private static final byte TRANSMISSION_POWER = 0;
+
+                    private final SecureRandom random = new SecureRandom();
+                    private final MessageDigest sha256;
+                    @Nullable
+                    private byte[] lastNonce;
+                    @Nullable
+                    private ByteString identityKey = options.getEddystoneIdentityKey();
+
+                    {
+                        try {
+                            sha256 = MessageDigest.getInstance("SHA-256");
+                            sha256.reset();
+                        } catch (NoSuchAlgorithmException e) {
+                            throw new IllegalStateException("System missing SHA-256 implementation.", e);
+                        }
+                    }
+
+                    @Override
+                    // Simulating deprecated API {@code BeaconActionsCharacteristic.ID} for testing.
+                    @SuppressWarnings("deprecation")
+                    public BluetoothGattCharacteristic getBaseCharacteristic() {
+                        return new BluetoothGattCharacteristic(
+                                BeaconActionsCharacteristic.CUSTOM_128_BIT_UUID,
+                                PROPERTY_READ | PROPERTY_WRITE | PROPERTY_NOTIFY,
+                                PERMISSION_READ | PERMISSION_WRITE);
+                    }
+
+                    @Override
+                    public byte[] read(BluetoothGattServerConnection connection, int offset) {
+                        lastNonce = new byte[NONCE_LENGTH];
+                        random.nextBytes(lastNonce);
+                        return lastNonce;
+                    }
+
+                    @Override
+                    public void write(BluetoothGattServerConnection connection, int offset, byte[] value)
+                            throws BluetoothGattException {
+                        logger.log("Got value from beacon actions servlet: %s", base16().encode(value));
+                        if (value.length == 0) {
+                            logger.log("Packet length invalid, %d", value.length);
+                            throw new BluetoothGattException("Packet length invalid", GATT_ERROR_INVALID_VALUE);
+                        }
+                        switch (value[0]) {
+                            case BeaconActionType.READ_BEACON_PARAMETERS:
+                                handleReadBeaconParameters(value);
+                                break;
+                            case BeaconActionType.READ_PROVISIONING_STATE:
+                                handleReadProvisioningState(value);
+                                break;
+                            case BeaconActionType.SET_EPHEMERAL_IDENTITY_KEY:
+                                handleSetEphemeralIdentityKey(value);
+                                break;
+                            case BeaconActionType.CLEAR_EPHEMERAL_IDENTITY_KEY:
+                            case BeaconActionType.READ_EPHEMERAL_IDENTITY_KEY:
+                            case BeaconActionType.RING:
+                            case BeaconActionType.READ_RINGING_STATE:
+                                throw new BluetoothGattException(
+                                        "Unimplemented beacon action", BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED);
+                            default:
+                                throw new BluetoothGattException(
+                                        "Unknown beacon action", BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED);
+                        }
+                    }
+
+                    private boolean verifyAccountKeyToken(byte[] value, boolean ownerOnly)
+                            throws BluetoothGattException {
+                        if (value.length < ONE_TIME_AUTH_KEY_LENGTH + ONE_TIME_AUTH_KEY_OFFSET) {
+                            logger.log("Packet length invalid, %d", value.length);
+                            throw new BluetoothGattException("Packet length invalid", GATT_ERROR_INVALID_VALUE);
+                        }
+                        byte[] hashedAccountKey =
+                                Arrays.copyOfRange(
+                                        value,
+                                        ONE_TIME_AUTH_KEY_OFFSET,
+                                        ONE_TIME_AUTH_KEY_LENGTH + ONE_TIME_AUTH_KEY_OFFSET);
+                        if (lastNonce == null) {
+                            throw new BluetoothGattException("Nonce wasn't set", GATT_ERROR_UNAUTHENTICATED);
+                        }
+                        if (ownerOnly) {
+                            ByteString accountKey = getOwnerAccountKey();
+                            if (accountKey != null) {
+                                sha256.update(accountKey.toByteArray());
+                                sha256.update(lastNonce);
+                                return Arrays.equals(
+                                        hashedAccountKey, Arrays.copyOf(sha256.digest(), ONE_TIME_AUTH_KEY_LENGTH));
+                            }
+                        } else {
+                            Set<ByteString> accountKeys = getAccountKeys();
+                            for (ByteString accountKey : accountKeys) {
+                                sha256.update(accountKey.toByteArray());
+                                sha256.update(lastNonce);
+                                if (Arrays.equals(
+                                        hashedAccountKey, Arrays.copyOf(sha256.digest(), ONE_TIME_AUTH_KEY_LENGTH))) {
+                                    return true;
+                                }
+                            }
+                        }
+                        return false;
+                    }
+
+                    private int getBeaconClock() {
+                        return (int) TimeUnit.MILLISECONDS.toSeconds(SystemClock.elapsedRealtime());
+                    }
+
+                    private ByteString fromBytes(byte... bytes) {
+                        return ByteString.copyFrom(bytes);
+                    }
+
+                    private byte[] intToByteArray(int value) {
+                        byte[] data = new byte[4];
+                        data[3] = (byte) value;
+                        data[2] = (byte) (value >>> 8);
+                        data[1] = (byte) (value >>> 16);
+                        data[0] = (byte) (value >>> 24);
+                        return data;
+                    }
+
+                    private void handleReadBeaconParameters(byte[] value) throws BluetoothGattException {
+                        if (!verifyAccountKeyToken(value, /* ownerOnly= */ false)) {
+                            throw new BluetoothGattException(
+                                    "failed to authenticate account key", GATT_ERROR_UNAUTHENTICATED);
+                        }
+                        sendNotification(
+                                fromBytes(
+                                        (byte) BeaconActionType.READ_BEACON_PARAMETERS,
+                                        (byte) 5 /* data length */,
+                                        TRANSMISSION_POWER)
+                                        .concat(ByteString.copyFrom(intToByteArray(getBeaconClock())))
+                                        .toByteArray());
+                    }
+
+                    private void handleReadProvisioningState(byte[] value) throws BluetoothGattException {
+                        if (!verifyAccountKeyToken(value, /* ownerOnly= */ false)) {
+                            throw new BluetoothGattException(
+                                    "failed to authenticate account key", GATT_ERROR_UNAUTHENTICATED);
+                        }
+                        byte flags = 0;
+                        if (verifyAccountKeyToken(value, /* ownerOnly= */ true)) {
+                            flags |= (byte) (1 << 1);
+                        }
+                        if (identityKey == null) {
+                            sendNotification(
+                                    fromBytes(
+                                            (byte) BeaconActionType.READ_PROVISIONING_STATE,
+                                            (byte) 1 /* data length */,
+                                            flags)
+                                            .toByteArray());
+                        } else {
+                            flags |= (byte) 1;
+                            sendNotification(
+                                    fromBytes(
+                                            (byte) BeaconActionType.READ_PROVISIONING_STATE,
+                                            (byte) 21 /* data length */,
+                                            flags)
+                                            .concat(
+                                                    E2eeCalculator.computeE2eeEid(
+                                                            identityKey, /* exponent= */ 10, getBeaconClock()))
+                                            .toByteArray());
+                        }
+                    }
+
+                    private void handleSetEphemeralIdentityKey(byte[] value) throws BluetoothGattException {
+                        if (!verifyAccountKeyToken(value, /* ownerOnly= */ true)) {
+                            throw new BluetoothGattException(
+                                    "failed to authenticate owner account key", GATT_ERROR_UNAUTHENTICATED);
+                        }
+                        if (value.length
+                                != ONE_TIME_AUTH_KEY_LENGTH + ONE_TIME_AUTH_KEY_OFFSET + IDENTITY_KEY_LENGTH) {
+                            logger.log("Packet length invalid, %d", value.length);
+                            throw new BluetoothGattException("Packet length invalid", GATT_ERROR_INVALID_VALUE);
+                        }
+                        if (identityKey != null) {
+                            throw new BluetoothGattException(
+                                    "Device is already provisioned as Eddystone", GATT_ERROR_UNAUTHENTICATED);
+                        }
+                        identityKey =
+                                Crypto.aesEcbNoPaddingDecrypt(
+                                        ByteString.copyFrom(ownerAccountKey),
+                                        ByteString.copyFrom(value)
+                                                .substring(ONE_TIME_AUTH_KEY_LENGTH + ONE_TIME_AUTH_KEY_OFFSET));
+                    }
+                };
+
+        ServiceConfig fastPairServiceConfig =
+                new ServiceConfig()
+                        .addCharacteristic(accountKeyServlet)
+                        .addCharacteristic(keyBasedPairingServlet)
+                        .addCharacteristic(passkeyServlet)
+                        .addCharacteristic(firmwareVersionServlet);
+        if (options.getEnableBeaconActionsCharacteristic()) {
+            fastPairServiceConfig.addCharacteristic(beaconActionsServlet);
+        }
+
+        BluetoothGattServerConfig config =
+                new BluetoothGattServerConfig()
+                        .addService(
+                                TransportDiscoveryService.ID,
+                                new ServiceConfig()
+                                        .addCharacteristic(tdsControlPointServlet)
+                                        .addCharacteristic(brHandoverDataServlet)
+                                        .addCharacteristic(bluetoothSigServlet))
+                        .addService(
+                                FastPairService.ID,
+                                options.getEnableNameCharacteristic()
+                                        ? fastPairServiceConfig.addCharacteristic(deviceNameServlet)
+                                        : fastPairServiceConfig);
+
+        logger.log(
+                "Starting GATT server, support name characteristic %b",
+                options.getEnableNameCharacteristic());
+        try {
+            helper.open(config);
+        } catch (BluetoothException e) {
+            logger.log(e, "Error starting GATT server");
+        }
+    }
+
+    /** Callback for passkey/pin input. */
+    public interface KeyInputCallback {
+        void onKeyInput(int key);
+    }
+
+    public void enterPassKey(int passkey) {
+        logger.log("enterPassKey called with passkey %d.", passkey);
+        try {
+            boolean result =
+                    (Boolean) Reflect.on(pairingDevice).withMethod("setPasskey", int.class).get(passkey);
+            logger.log("enterPassKey called with result %b", result);
+        } catch (ReflectionException e) {
+            logger.log("enterPassKey meet Exception %s.", e.getMessage());
+        }
+    }
+
+    private void checkPasskey() {
+        // There's a race between the PAIRING_REQUEST broadcast from the OS giving us the local
+        // passkey, and the remote passkey received over GATT. Skip the check until we have both.
+        if (localPasskey == 0 || remotePasskey == 0) {
+            logger.log(
+                    "Skipping passkey check, missing local (%s) or remote (%s).",
+                    localPasskey, remotePasskey);
+            return;
+        }
+
+        // Regardless of whether it matches, send our (encrypted) passkey to the seeker.
+        sendPasskeyToRemoteDevice(localPasskey);
+
+        logger.log("Checking localPasskey %s == remotePasskey %s", localPasskey, remotePasskey);
+        boolean passkeysMatched = localPasskey == remotePasskey;
+        if (options.getShowsPasskeyConfirmation() && passkeysMatched && passkeyEventCallback != null) {
+            logger.log("callbacks the UI for passkey confirmation.");
+            passkeyEventCallback.onPasskeyConfirmation(localPasskey, this::setPasskeyConfirmation);
+        } else {
+            setPasskeyConfirmation(passkeysMatched);
+        }
+    }
+
+    private void sendPasskeyToRemoteDevice(int passkey) {
+        try {
+            passkeyServlet.sendNotification(
+                    PasskeyCharacteristic.encrypt(PasskeyCharacteristic.Type.PROVIDER, secret, passkey));
+        } catch (GeneralSecurityException e) {
+            logger.log(e, "Failed to encrypt passkey response.");
+        }
+    }
+
+    public void setFirmwareVersion(String versionNumber) {
+        deviceFirmwareVersion = versionNumber;
+    }
+
+    public void setDynamicBufferSize(boolean support) {
+        if (supportDynamicBufferSize != support) {
+            supportDynamicBufferSize = support;
+            sendCapabilitySync();
+        }
+    }
+
+    @VisibleForTesting
+    void setPasskeyConfirmationCallback(PasskeyConfirmationCallback callback) {
+        this.passkeyConfirmationCallback = callback;
+    }
+
+    public void setDeviceNameCallback(DeviceNameCallback callback) {
+        this.deviceNameCallback = callback;
+    }
+
+    public void setPasskeyEventCallback(PasskeyEventCallback passkeyEventCallback) {
+        this.passkeyEventCallback = passkeyEventCallback;
+    }
+
+    private void setPasskeyConfirmation(boolean confirm) {
+        pairingDevice.setPairingConfirmation(confirm);
+        if (passkeyConfirmationCallback != null) {
+            passkeyConfirmationCallback.onPasskeyConfirmation(confirm);
+        }
+        localPasskey = 0;
+        remotePasskey = 0;
+    }
+
+    private void becomeDiscoverable() throws InterruptedException, TimeoutException {
+        setDiscoverable(true);
+    }
+
+    public void cancelDiscovery() throws InterruptedException, TimeoutException {
+        setDiscoverable(false);
+    }
+
+    private void setDiscoverable(boolean discoverable) throws InterruptedException, TimeoutException {
+        isDiscoverableLatch = new CountDownLatch(1);
+        setScanMode(discoverable ? SCAN_MODE_CONNECTABLE_DISCOVERABLE : SCAN_MODE_CONNECTABLE);
+        // If we're already discoverable, count down the latch right away. Otherwise,
+        // we'll get a broadcast when we successfully become discoverable.
+        if (isDiscoverable()) {
+            isDiscoverableLatch.countDown();
+        }
+        if (isDiscoverableLatch.await(3, TimeUnit.SECONDS)) {
+            logger.log("Successfully became switched discoverable mode %s", discoverable);
+        } else {
+            throw new TimeoutException();
+        }
+    }
+
+    private void setScanMode(int scanMode) {
+        if (revertDiscoverableFuture != null) {
+            revertDiscoverableFuture.cancel(false /* may interrupt if running */);
+        }
+
+        logger.log("Setting scan mode to %s", scanModeToString(scanMode));
+        try {
+            Method method = bluetoothAdapter.getClass().getMethod("setScanMode", Integer.TYPE);
+            method.invoke(bluetoothAdapter, scanMode);
+
+            if (scanMode == SCAN_MODE_CONNECTABLE_DISCOVERABLE) {
+                revertDiscoverableFuture =
+                        executor.schedule(
+                                () -> setScanMode(SCAN_MODE_CONNECTABLE),
+                                options.getIsMemoryTest() ? 300 : 30,
+                                TimeUnit.SECONDS);
+            }
+        } catch (Exception e) {
+            logger.log(e, "Error setting scan mode to %d", scanMode);
+        }
+    }
+
+    public static String scanModeToString(int scanMode) {
+        switch (scanMode) {
+            case SCAN_MODE_CONNECTABLE_DISCOVERABLE:
+                return "DISCOVERABLE";
+            case SCAN_MODE_CONNECTABLE:
+                return "CONNECTABLE";
+            case SCAN_MODE_NONE:
+                return "NOT CONNECTABLE";
+            default:
+                return "UNKNOWN(" + scanMode + ")";
+        }
+    }
+
+    private ResultCode checkTdsControlPointRequest(byte[] request) {
+        if (request.length < 2) {
+            logger.log(
+                    new IllegalArgumentException(), "Expected length >= 2 for %s", base16().encode(request));
+            return ResultCode.INVALID_PARAMETER;
+        }
+        byte opCode = getTdsControlPointOpCode(request);
+        if (opCode != TransportDiscoveryService.ControlPointCharacteristic.ACTIVATE_TRANSPORT_OP_CODE) {
+            logger.log(
+                    new IllegalArgumentException(),
+                    "Expected Activate Transport op code (0x01), got %d",
+                    opCode);
+            return ResultCode.OP_CODE_NOT_SUPPORTED;
+        }
+        if (request[1] != BLUETOOTH_SIG_ORGANIZATION_ID) {
+            logger.log(
+                    new IllegalArgumentException(),
+                    "Expected Bluetooth SIG organization ID (0x01), got %d",
+                    request[1]);
+            return ResultCode.UNSUPPORTED_ORGANIZATION_ID;
+        }
+        // TODO(jfarfel): Parse out the requested service UUIDs, and if they don't include A2DP, fail.
+        return ResultCode.SUCCESS;
+    }
+
+    private static byte getTdsControlPointOpCode(byte[] request) {
+        return request.length < 1 ? 0x00 : request[0];
+    }
+
+    private boolean isDiscoverable() {
+        return bluetoothAdapter.getScanMode() == SCAN_MODE_CONNECTABLE_DISCOVERABLE;
+    }
+
+    private byte[] modelIdServiceData(boolean forAdvertising) {
+        // Note: This used to be little-endian but is now big-endian. See b/78229467 for details.
+        byte[] modelIdPacket =
+                base16().decode(forAdvertising ? options.getAdvertisingModelId() : options.getModelId());
+        if (!batteryValues.isEmpty()) {
+            // If we are going to advertise battery values with the packet, then switch to the non-3-byte
+            // model ID format from go/fast-pair-service-data.
+            modelIdPacket = concat(new byte[]{0b00000110}, modelIdPacket);
+        }
+        return modelIdPacket;
+    }
+
+    private byte[] accountKeysServiceData() {
+        try {
+            return concat(new byte[]{0x00}, generateBloomFilterFields());
+        } catch (NoSuchAlgorithmException e) {
+            throw new IllegalStateException("Unable to build bloom filter.", e);
+        }
+    }
+
+    private byte[] transportDiscoveryData() {
+        byte[] transportData = SUPPORTED_SERVICES_LTV;
+        return Bytes.concat(
+                new byte[]{BLUETOOTH_SIG_ORGANIZATION_ID},
+                new byte[]{tdsFlags(isDiscoverable() ? TransportState.ON : TransportState.OFF)},
+                new byte[]{(byte) transportData.length},
+                transportData);
+    }
+
+    private byte[] generateBloomFilterFields() throws NoSuchAlgorithmException {
+        Set<ByteString> accountKeys = getAccountKeys();
+        if (accountKeys.isEmpty()) {
+            return new byte[0];
+        }
+        BloomFilter bloomFilter =
+                new BloomFilter(
+                        new byte[(int) (1.2 * accountKeys.size()) + 3], new FastPairBloomFilterHasher());
+        String address = bleAddress == null ? SIMULATOR_FAKE_BLE_ADDRESS : bleAddress;
+
+        // Simulator supports Central Address Resolution characteristic, so when paired, the BLE address
+        // in Seeker will be resolved to BR/EDR address. This caused Seeker fails on checking the bloom
+        // filter due to different address is used for salting. In order to let battery values
+        // notification be shown on paired device, we use random salt to workaround it.
+        // TODO(tonyysliu): Remove this workaround when simulator does not support Central Address
+        // Resolution characteristic.
+        boolean advertisingBatteryValues = !batteryValues.isEmpty();
+        byte[] salt;
+        if (options.getUseRandomSaltForAccountKeyRotation() || advertisingBatteryValues) {
+            salt = new byte[1];
+            new SecureRandom().nextBytes(salt);
+            logger.log("Using random salt %s for bloom filter", base16().encode(salt));
+        } else {
+            salt = BluetoothAddress.decode(address);
+            logger.log("Using address %s for bloom filter", address);
+        }
+
+        // To prevent tampering, account filter shall be slightly modified to include battery data
+        // when the battery values are included in the advertisement. Normally, when building the
+        // account filter, a value V is produce by combining the account key with a salt. Instead,
+        // when battery values are also being advertised, it be constructed as follows:
+        // - the first 16 bytes are account key.
+        // - the next bytes are the salt.
+        // - the remaining bytes are the battery data.
+        byte[] saltAndBatteryData =
+                advertisingBatteryValues ? concat(salt, generateBatteryData()) : salt;
+
+        for (ByteString accountKey : accountKeys) {
+            bloomFilter.add(concat(accountKey.toByteArray(), saltAndBatteryData));
+        }
+        byte[] packet = generateAccountKeyData(bloomFilter);
+        return options.getUseRandomSaltForAccountKeyRotation() || advertisingBatteryValues
+                // Create a header with length 1 and type 1 for a random salt.
+                ? concat(packet, createField((byte) 0x11, salt))
+                // Exclude the salt from the packet, BLE address will be assumed by the client.
+                : packet;
+    }
+
+    /**
+     * Creates a new field for the packet. The header is formatted 0xLLLLTTTT where LLLL is the length
+     * of the field and TTTT is the type (0 for bloom filter, 1 for salt). See go/fast-pair-2-spec for
+     * more information.
+     */
+    private byte[] createField(byte header, byte[] value) {
+        return concat(new byte[]{header}, value);
+    }
+
+    public int getTxPower() {
+        return options.getTxPowerLevel();
+    }
+
+    @Nullable
+    byte[] getServiceData() {
+        byte[] packet =
+                isDiscoverable()
+                        ? modelIdServiceData(/* forAdvertising= */ true)
+                        : !getAccountKeys().isEmpty() ? accountKeysServiceData() : null;
+        return addBatteryValues(packet);
+    }
+
+    @Nullable
+    private byte[] addBatteryValues(byte[] packet) {
+        if (batteryValues.isEmpty() || packet == null) {
+            return packet;
+        }
+
+        return concat(packet, generateBatteryData());
+    }
+
+    private byte[] generateBatteryData() {
+        // Byte 0: Battery length and type, first 4 bits are the number of battery values, second 4 are
+        // the type. See go/fast-pair-2-service-data for the battery type definitions.
+        // Byte 1 - length: Battery values, the first bit is charging status, the remaining bits are
+        // the actual value between 0 and 100, or -1 for unknown.
+        byte[] batteryData = new byte[batteryValues.size() + 1];
+        batteryData[0] =
+                (byte) (batteryValues.size() << 4 | (suppressBatteryNotification ? 0b0100 : 0b0011));
+
+        int batteryValueIndex = 1;
+        for (BatteryValue batteryValue : batteryValues) {
+            batteryData[batteryValueIndex++] =
+                    (byte)
+                            ((batteryValue.charging ? 0b10000000 : 0b00000000)
+                                    | (0b01111111 & batteryValue.level));
+        }
+
+        return batteryData;
+    }
+
+    private byte[] generateAccountKeyData(BloomFilter bloomFilter) {
+        // Byte 0: length and type, first 4 bits are the length of bloom filter, second 4 are the type
+        // which inditcating the subsequent pairing notification is suppressed or not.
+        // The following bytes are the data of bloom filter.
+        byte[] filterBytes = bloomFilter.asBytes();
+        byte lengthAndType =
+                (byte)
+                        (filterBytes.length << 4 | (suppressSubsequentPairingNotification ? 0b0010 : 0b0000));
+        logger.log(
+                "Generate bloom filter with suppress subsequent pairing notification:%b",
+                suppressSubsequentPairingNotification);
+        return createField(lengthAndType, filterBytes);
+    }
+
+    /** Disconnects all connected devices. */
+    private void disconnect() {
+        for (BluetoothDevice device : bluetoothAdapter.getBondedDevices()) {
+            if (device.getBluetoothClass().getMajorDeviceClass() == Major.PHONE) {
+                removeBond(device);
+            }
+        }
+    }
+
+    public void disconnect(BluetoothProfile profile, BluetoothDevice device) {
+        try {
+            Reflect.on(profile).withMethod("disconnect", BluetoothDevice.class).invoke(device);
+        } catch (ReflectionException e) {
+            logger.log(e, "Error disconnecting device=%s from profile=%s", device, profile);
+        }
+    }
+
+    public void removeBond(BluetoothDevice device) {
+        try {
+            Reflect.on(device).withMethod("removeBond").invoke();
+        } catch (ReflectionException e) {
+            logger.log(e, "Error removing bond for device=%s", device);
+        }
+    }
+
+    public void resetAccountKeys() {
+        fastPairSimulatorDatabase.setAccountKeys(new HashSet<>());
+        fastPairSimulatorDatabase.setFastPairSeekerDevices(new HashSet<>());
+        accountKey = null;
+        ownerAccountKey = null;
+        logger.log("Remove all account keys");
+    }
+
+    public void addAccountKey(byte[] key) {
+        addAccountKey(key, /* device= */ null);
+    }
+
+    private void addAccountKey(byte[] key, @Nullable BluetoothDevice device) {
+        accountKey = key;
+        if (ownerAccountKey == null) {
+            ownerAccountKey = key;
+        }
+
+        fastPairSimulatorDatabase.addAccountKey(key);
+        fastPairSimulatorDatabase.addFastPairSeekerDevice(device, key);
+        logger.log("Add account key: key=%s, device=%s", base16().encode(key), device);
+    }
+
+    private Set<ByteString> getAccountKeys() {
+        return fastPairSimulatorDatabase.getAccountKeys();
+    }
+
+    /** Get the latest account key. */
+    @Nullable
+    public ByteString getAccountKey() {
+        if (accountKey == null) {
+            return null;
+        }
+        return ByteString.copyFrom(accountKey);
+    }
+
+    /** Get the owner account key (the first account key registered). */
+    @Nullable
+    public ByteString getOwnerAccountKey() {
+        if (ownerAccountKey == null) {
+            return null;
+        }
+        return ByteString.copyFrom(ownerAccountKey);
+    }
+
+    public void resetDeviceName() {
+        fastPairSimulatorDatabase.setLocalDeviceName(null);
+        // Trigger simulator to update device name text view.
+        if (deviceNameCallback != null) {
+            deviceNameCallback.onNameChanged(getDeviceName());
+        }
+    }
+
+    // This method is used in test case with default name in provider.
+    public void setDeviceName(String deviceName) {
+        setDeviceName(deviceName.getBytes(StandardCharsets.UTF_8));
+    }
+
+    private void setDeviceName(@Nullable byte[] deviceName) {
+        fastPairSimulatorDatabase.setLocalDeviceName(deviceName);
+
+        logger.log("Save device name : %s", getDeviceName());
+        // Trigger simulator to update device name text view.
+        if (deviceNameCallback != null) {
+            deviceNameCallback.onNameChanged(getDeviceName());
+        }
+    }
+
+    @Nullable
+    private byte[] getDeviceNameInBytes() {
+        return fastPairSimulatorDatabase.getLocalDeviceName();
+    }
+
+    @Nullable
+    public String getDeviceName() {
+        String providerDeviceName =
+                getDeviceNameInBytes() != null
+                        ? new String(getDeviceNameInBytes(), StandardCharsets.UTF_8)
+                        : null;
+        logger.log("get device name = %s", providerDeviceName);
+        return providerDeviceName;
+    }
+
+    /**
+     * Bit index: Description - Value
+     *
+     * <ul>
+     *   <li>0-1: Role - 0b10 (Provider only)
+     *   <li>2: Transport Data Incomplete: 0 (false)
+     *   <li>3-4: Transport State (0b00: Off, 0b01: On, 0b10: Temporarily Unavailable)
+     *   <li>5-7: Reserved for future use
+     * </ul>
+     */
+    private static byte tdsFlags(TransportState transportState) {
+        return (byte) (0b00000010 & (transportState.byteValue << 3));
+    }
+
+    /** Detailed information about battery value. */
+    public static class BatteryValue {
+        boolean charging;
+
+        // The range is 0 ~ 100, and -1 represents the battery level is unknown.
+        int level;
+
+        public BatteryValue(boolean charging, int level) {
+            this.charging = charging;
+            this.level = level;
+        }
+    }
+}
diff --git a/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/FastPairSimulatorDatabase.java b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/FastPairSimulatorDatabase.java
new file mode 100644
index 0000000..4d2a9e8
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/FastPairSimulatorDatabase.java
@@ -0,0 +1,264 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.nearby.common.bluetooth.fastpair.testing;
+
+import static com.google.common.io.BaseEncoding.base16;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.content.Context;
+import android.content.SharedPreferences;
+
+import androidx.annotation.Nullable;
+
+import com.google.protobuf.ByteString;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.StringTokenizer;
+
+/** Stores fast pair related information for each paired device */
+public class FastPairSimulatorDatabase {
+
+    private static final String SHARED_PREF_NAME =
+            "android.nearby.multidevices.fastpair.provider.fastpairsimulator";
+    private static final String KEY_DEVICE_NAME = "DEVICE_NAME";
+    private static final String KEY_ACCOUNT_KEYS = "ACCOUNT_KEYS";
+    private static final int MAX_NUMBER_OF_ACCOUNT_KEYS = 8;
+
+    // [for SASS]
+    private static final String KEY_FAST_PAIR_SEEKER_DEVICE = "FAST_PAIR_SEEKER_DEVICE";
+
+    private final SharedPreferences sharedPreferences;
+
+    public FastPairSimulatorDatabase(Context context) {
+        sharedPreferences = context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE);
+    }
+
+    /** Adds single account key. */
+    public void addAccountKey(byte[] accountKey) {
+        if (sharedPreferences == null) {
+            return;
+        }
+
+        Set<ByteString> accountKeys = new HashSet<>(getAccountKeys());
+        if (accountKeys.size() >= MAX_NUMBER_OF_ACCOUNT_KEYS) {
+            Set<ByteString> removedKeys = new HashSet<>();
+            int removedCount = accountKeys.size() - MAX_NUMBER_OF_ACCOUNT_KEYS + 1;
+            for (ByteString key : accountKeys) {
+                if (removedKeys.size() == removedCount) {
+                    break;
+                }
+                removedKeys.add(key);
+            }
+
+            accountKeys.removeAll(removedKeys);
+        }
+
+        // Just make sure the newest key will not be removed.
+        accountKeys.add(ByteString.copyFrom(accountKey));
+        setAccountKeys(accountKeys);
+    }
+
+    /** Sets account keys, overrides all. */
+    public void setAccountKeys(Set<ByteString> accountKeys) {
+        if (sharedPreferences == null) {
+            return;
+        }
+
+        Set<String> keys = new HashSet<>();
+        for (ByteString item : accountKeys) {
+            keys.add(base16().encode(item.toByteArray()));
+        }
+
+        sharedPreferences.edit().putStringSet(KEY_ACCOUNT_KEYS, keys).apply();
+    }
+
+    /** Gets all account keys. */
+    public Set<ByteString> getAccountKeys() {
+        if (sharedPreferences == null) {
+            return new HashSet<>();
+        }
+
+        Set<String> keys = sharedPreferences.getStringSet(KEY_ACCOUNT_KEYS, new HashSet<>());
+        Set<ByteString> accountKeys = new HashSet<>();
+        // Add new account keys one by one.
+        for (String key : keys) {
+            accountKeys.add(ByteString.copyFrom(base16().decode(key)));
+        }
+
+        return accountKeys;
+    }
+
+    /** Sets local device name. */
+    public void setLocalDeviceName(byte[] deviceName) {
+        if (sharedPreferences == null) {
+            return;
+        }
+
+        String humanReadableName = deviceName != null ? new String(deviceName, UTF_8) : null;
+        if (humanReadableName == null) {
+            sharedPreferences.edit().remove(KEY_DEVICE_NAME).apply();
+        } else {
+            sharedPreferences.edit().putString(KEY_DEVICE_NAME, humanReadableName).apply();
+        }
+    }
+
+    /** Gets local device name. */
+    @Nullable
+    public byte[] getLocalDeviceName() {
+        if (sharedPreferences == null) {
+            return null;
+        }
+
+        String deviceName = sharedPreferences.getString(KEY_DEVICE_NAME, null);
+        return deviceName != null ? deviceName.getBytes(UTF_8) : null;
+    }
+
+    /**
+     * [for SASS] Adds seeker device info. <a
+     * href="http://go/smart-audio-source-switching-design">Sass design doc</a>
+     */
+    public void addFastPairSeekerDevice(@Nullable BluetoothDevice device, byte[] accountKey) {
+        if (sharedPreferences == null) {
+            return;
+        }
+
+        if (device == null) {
+            return;
+        }
+
+        // When hitting size limitation, choose the existing items to delete.
+        Set<FastPairSeekerDevice> fastPairSeekerDevices = getFastPairSeekerDevices();
+        if (fastPairSeekerDevices.size() > MAX_NUMBER_OF_ACCOUNT_KEYS) {
+            int removedCount = fastPairSeekerDevices.size() - MAX_NUMBER_OF_ACCOUNT_KEYS + 1;
+            Set<FastPairSeekerDevice> removedFastPairDevices = new HashSet<>();
+            for (FastPairSeekerDevice fastPairDevice : fastPairSeekerDevices) {
+                if (removedFastPairDevices.size() == removedCount) {
+                    break;
+                }
+                removedFastPairDevices.add(fastPairDevice);
+            }
+            fastPairSeekerDevices.removeAll(removedFastPairDevices);
+        }
+
+        fastPairSeekerDevices.add(new FastPairSeekerDevice(device, accountKey));
+        setFastPairSeekerDevices(fastPairSeekerDevices);
+    }
+
+    /** [for SASS] Sets all seeker device info, overrides all. */
+    public void setFastPairSeekerDevices(Set<FastPairSeekerDevice> fastPairSeekerDeviceSet) {
+        if (sharedPreferences == null) {
+            return;
+        }
+
+        Set<String> rawStringSet = new HashSet<>();
+        for (FastPairSeekerDevice item : fastPairSeekerDeviceSet) {
+            rawStringSet.add(item.toRawString());
+        }
+
+        sharedPreferences.edit().putStringSet(KEY_FAST_PAIR_SEEKER_DEVICE, rawStringSet).apply();
+    }
+
+    /** [for SASS] Gets all seeker device info. */
+    public Set<FastPairSeekerDevice> getFastPairSeekerDevices() {
+        if (sharedPreferences == null) {
+            return new HashSet<>();
+        }
+
+        Set<FastPairSeekerDevice> fastPairSeekerDevices = new HashSet<>();
+        Set<String> rawStringSet =
+                sharedPreferences.getStringSet(KEY_FAST_PAIR_SEEKER_DEVICE, new HashSet<>());
+        for (String rawString : rawStringSet) {
+            FastPairSeekerDevice fastPairDevice = FastPairSeekerDevice.fromRawString(rawString);
+            if (fastPairDevice == null) {
+                continue;
+            }
+            fastPairSeekerDevices.add(fastPairDevice);
+        }
+
+        return fastPairSeekerDevices;
+    }
+
+    /** Defines data structure for the paired Fast Pair device. */
+    public static class FastPairSeekerDevice {
+        private static final int INDEX_DEVICE = 0;
+        private static final int INDEX_ACCOUNT_KEY = 1;
+
+        private final BluetoothDevice device;
+        private final byte[] accountKey;
+
+        private FastPairSeekerDevice(BluetoothDevice device, byte[] accountKey) {
+            this.device = device;
+            this.accountKey = accountKey;
+        }
+
+        public BluetoothDevice getBluetoothDevice() {
+            return device;
+        }
+
+        public byte[] getAccountKey() {
+            return accountKey;
+        }
+
+        public String toRawString() {
+            return String.format("%s,%s", device, base16().encode(accountKey));
+        }
+
+        /** Decodes the raw string if possible. */
+        @Nullable
+        public static FastPairSeekerDevice fromRawString(String rawString) {
+            BluetoothDevice device = null;
+            byte[] accountKey = null;
+            int step = INDEX_DEVICE;
+
+            StringTokenizer tokenizer = new StringTokenizer(rawString, ",");
+            while (tokenizer.hasMoreElements()) {
+                boolean shouldStop = false;
+                String token = tokenizer.nextToken();
+                switch (step) {
+                    case INDEX_DEVICE:
+                        try {
+                            device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(token);
+                        } catch (IllegalArgumentException e) {
+                            device = null;
+                        }
+                        break;
+                    case INDEX_ACCOUNT_KEY:
+                        accountKey = base16().decode(token);
+                        if (accountKey.length != 16) {
+                            accountKey = null;
+                        }
+                        break;
+                    default:
+                        shouldStop = true;
+                }
+
+                if (shouldStop) {
+                    break;
+                }
+                step++;
+            }
+            if (device != null && accountKey != null) {
+                return new FastPairSeekerDevice(device, accountKey);
+            }
+            return null;
+        }
+    }
+}
diff --git a/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/HandshakeRequest.java b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/HandshakeRequest.java
new file mode 100644
index 0000000..5453a87
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/HandshakeRequest.java
@@ -0,0 +1,150 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.nearby.common.bluetooth.fastpair.testing;
+
+import static com.android.server.nearby.common.bluetooth.fastpair.AesEcbSingleBlockEncryption.decrypt;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.BLUETOOTH_ADDRESS_LENGTH;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.ActionOverBleFlag.ADDITIONAL_DATA_CHARACTERISTIC;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.ActionOverBleFlag.DEVICE_ACTION;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.KeyBasedPairingRequestFlag.PROVIDER_INITIATES_BONDING;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.KeyBasedPairingRequestFlag.REQUEST_DEVICE_NAME;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.KeyBasedPairingRequestFlag.REQUEST_DISCOVERABLE;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.KeyBasedPairingRequestFlag.REQUEST_RETROACTIVE_PAIR;
+import static com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.Request.ADDITIONAL_DATA_TYPE_INDEX;
+
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.AdditionalDataCharacteristic.AdditionalDataType;
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService.KeyBasedPairingCharacteristic.Request;
+
+import java.security.GeneralSecurityException;
+import java.util.Arrays;
+
+/**
+ * A wrapper for Fast Pair Provider to access decoded handshake request from the Seeker.
+ *
+ * @see {go/fast-pair-early-spec-handshake}
+ */
+public class HandshakeRequest {
+
+    /**
+     * 16 bytes data: 1-byte for type, 1-byte for flags, 6-bytes for provider's BLE address, 8 bytes
+     * optional data.
+     *
+     * @see {go/fast-pair-spec-handshake-message1}
+     */
+    private final byte[] decryptedMessage;
+
+    /** Enumerates the handshake message types. */
+    public enum Type {
+        KEY_BASED_PAIRING_REQUEST(Request.TYPE_KEY_BASED_PAIRING_REQUEST),
+        ACTION_OVER_BLE(Request.TYPE_ACTION_OVER_BLE),
+        UNKNOWN((byte) 0xFF);
+
+        private final byte value;
+
+        Type(byte type) {
+            value = type;
+        }
+
+        public byte getValue() {
+            return value;
+        }
+
+        public static Type valueOf(byte value) {
+            for (Type type : Type.values()) {
+                if (type.getValue() == value) {
+                    return type;
+                }
+            }
+            return UNKNOWN;
+        }
+    }
+
+    public HandshakeRequest(byte[] key, byte[] encryptedPairingRequest)
+            throws GeneralSecurityException {
+        decryptedMessage = decrypt(key, encryptedPairingRequest);
+    }
+
+    /**
+     * Gets the type of this handshake request. Currently, we have 2 types: 0x00 for Key-based Pairing
+     * Request and 0x10 for Action Request.
+     */
+    public Type getType() {
+        return Type.valueOf(decryptedMessage[Request.TYPE_INDEX]);
+    }
+
+    /** Gets verification data of this handshake request, currently, we use Provider's BLE address. */
+    public byte[] getVerificationData() {
+        return Arrays.copyOfRange(
+                decryptedMessage,
+                Request.VERIFICATION_DATA_INDEX,
+                Request.VERIFICATION_DATA_INDEX + Request.VERIFICATION_DATA_LENGTH);
+    }
+
+    /** Gets Seeker's public address of the handshake request. */
+    public byte[] getSeekerPublicAddress() {
+        return Arrays.copyOfRange(
+                decryptedMessage,
+                Request.SEEKER_PUBLIC_ADDRESS_INDEX,
+                Request.SEEKER_PUBLIC_ADDRESS_INDEX + BLUETOOTH_ADDRESS_LENGTH);
+    }
+
+    /** Checks whether the Seeker request discoverability from flags byte. */
+    public boolean requestDiscoverable() {
+        return (getFlags() & REQUEST_DISCOVERABLE) != 0;
+    }
+
+    /**
+     * Checks whether the Seeker requests that the Provider shall initiate bonding from flags byte.
+     */
+    public boolean requestProviderInitialBonding() {
+        return (getFlags() & PROVIDER_INITIATES_BONDING) != 0;
+    }
+
+    /** Checks whether the Seeker requests that the Provider shall notify the existing name. */
+    public boolean requestDeviceName() {
+        return (getFlags() & REQUEST_DEVICE_NAME) != 0;
+    }
+
+    /** Checks whether this is for retroactively writing account key. */
+    public boolean requestRetroactivePair() {
+        return (getFlags() & REQUEST_RETROACTIVE_PAIR) != 0;
+    }
+
+    /** Gets the flags of this handshake request. */
+    private byte getFlags() {
+        return decryptedMessage[Request.FLAGS_INDEX];
+    }
+
+    /** Checks whether the Seeker requests a device action. */
+    public boolean requestDeviceAction() {
+        return (getFlags() & DEVICE_ACTION) != 0;
+    }
+
+    /** Checks whether the Seeker requests an action which will be followed by an additional data. */
+    public boolean requestFollowedByAdditionalData() {
+        return (getFlags() & ADDITIONAL_DATA_CHARACTERISTIC) != 0;
+    }
+
+    /** Gets the {@link AdditionalDataType} of this handshake request. */
+    public @AdditionalDataType int getAdditionalDataType() {
+        if (!requestFollowedByAdditionalData()
+                || decryptedMessage.length <= ADDITIONAL_DATA_TYPE_INDEX) {
+            return AdditionalDataType.UNKNOWN;
+        }
+        return decryptedMessage[ADDITIONAL_DATA_TYPE_INDEX];
+    }
+}
diff --git a/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/Logger.java b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/Logger.java
new file mode 100644
index 0000000..37b065f
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/Logger.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.nearby.common.bluetooth.fastpair.testing;
+
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+
+import com.google.errorprone.annotations.FormatMethod;
+
+/**
+ * The base context for a logging statement.
+ */
+public class Logger {
+    private final String tag;
+
+    public Logger(String tag) {
+        this.tag = tag;
+    }
+
+    @FormatMethod
+    public void log(String message, Object... objects) {
+        log(null, message, objects);
+    }
+
+    /** Logs to the console. */
+    @FormatMethod
+    public void log(@Nullable Throwable exception, String message, Object... objects) {
+        if (exception == null) {
+            Log.i(tag, String.format(message, objects));
+        } else {
+            Log.w(tag, String.format(message, objects));
+            Log.w(tag, String.format("Cause: %s", exception));
+        }
+    }
+}
diff --git a/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/OreoFastPairAdvertiser.java b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/OreoFastPairAdvertiser.java
new file mode 100644
index 0000000..6913356
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/OreoFastPairAdvertiser.java
@@ -0,0 +1,172 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.nearby.common.bluetooth.fastpair.testing;
+
+import static com.google.common.io.BaseEncoding.base16;
+
+import android.annotation.TargetApi;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.le.AdvertiseData;
+import android.bluetooth.le.AdvertiseSettings;
+import android.bluetooth.le.AdvertisingSet;
+import android.bluetooth.le.AdvertisingSetCallback;
+import android.bluetooth.le.AdvertisingSetParameters;
+import android.bluetooth.le.BluetoothLeAdvertiser;
+import android.os.Build.VERSION_CODES;
+import android.os.ParcelUuid;
+
+import androidx.annotation.Nullable;
+
+import com.android.server.nearby.common.bluetooth.fastpair.Constants.FastPairService;
+import com.android.server.nearby.common.bluetooth.fastpair.Reflect;
+import com.android.server.nearby.common.bluetooth.fastpair.ReflectionException;
+
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Locale;
+
+/** Fast Pair advertiser taking advantage of new Android Oreo advertising features. */
+@TargetApi(VERSION_CODES.O)
+public final class OreoFastPairAdvertiser implements FastPairAdvertiser {
+    private static final String TAG = "OreoFastPairAdvertiser";
+    private final Logger logger = new Logger(TAG);
+
+    private final FastPairSimulator simulator;
+    private final BluetoothLeAdvertiser advertiser;
+    private final AdvertisingSetCallback advertisingSetCallback;
+    private AdvertisingSet advertisingSet;
+
+    public OreoFastPairAdvertiser(FastPairSimulator simulator) {
+        this.simulator = simulator;
+        this.advertiser = BluetoothAdapter.getDefaultAdapter().getBluetoothLeAdvertiser();
+        this.advertisingSetCallback =
+                new AdvertisingSetCallback() {
+                    @Override
+                    public void onAdvertisingSetStarted(AdvertisingSet set, int txPower, int status) {
+                        if (status == AdvertisingSetCallback.ADVERTISE_SUCCESS) {
+                            logger.log("Advertising succeeded, advertising at %s dBm", txPower);
+                            simulator.setIsAdvertising(true);
+                            advertisingSet = set;
+
+                            try {
+                                // Requires custom Android build, see callback below.
+                                Reflect.on(set).withMethod("getOwnAddress").invoke();
+                            } catch (ReflectionException e) {
+                                logger.log(e, "Error calling getOwnAddress for AdvertisingSet");
+                            }
+                        } else {
+                            logger.log(
+                                    new IllegalStateException(), "Advertising failed, error code=%d", status);
+                        }
+                    }
+
+                    @Override
+                    public void onAdvertisingDataSet(AdvertisingSet set, int status) {
+                        if (status != AdvertisingSetCallback.ADVERTISE_SUCCESS) {
+                            logger.log(
+                                    new IllegalStateException(),
+                                    "Updating advertisement failed, error code=%d",
+                                    status);
+                            stopAdvertising();
+                        }
+                    }
+
+                    // Called via reflection with AdvertisingSet.getOwnAddress().
+                    public void onOwnAddressRead(AdvertisingSet set, int addressType, String address) {
+                        if (!address.equals(simulator.getBleAddress())) {
+                            logger.log(
+                                    "Read own BLE address=%s at %s",
+                                    address,
+                                    new SimpleDateFormat("HH:mm:ss:SSS", Locale.US)
+                                            .format(Calendar.getInstance().getTime()));
+                            simulator.setBleAddress(address);
+                        }
+                    }
+                };
+    }
+
+    @Override
+    public void startAdvertising(@Nullable byte[] serviceData) {
+        // To be informed that BLE address is rotated, we need to polling query it asynchronously.
+        if (advertisingSet != null) {
+            try {
+                // Requires custom Android build, see callback: onOwnAddressRead.
+                Reflect.on(advertisingSet).withMethod("getOwnAddress").invoke();
+            } catch (ReflectionException ignored) {
+                // Ignore it due to user already knows it when setting advertisingSet.
+            }
+        }
+
+        if (simulator.isDestroyed()) {
+            return;
+        }
+
+        if (serviceData == null) {
+            logger.log("Service data is null, stop advertising");
+            stopAdvertising();
+            return;
+        }
+
+        AdvertiseData data =
+                new AdvertiseData.Builder()
+                        .addServiceData(new ParcelUuid(FastPairService.ID), serviceData)
+                        .setIncludeTxPowerLevel(true)
+                        .build();
+
+        logger.log("Advertising FE2C service data=%s", base16().encode(serviceData));
+
+        if (advertisingSet != null) {
+            advertisingSet.setAdvertisingData(data);
+            return;
+        }
+
+        stopAdvertising();
+        AdvertisingSetParameters parameters =
+                new AdvertisingSetParameters.Builder()
+                        .setLegacyMode(true)
+                        .setConnectable(true)
+                        .setScannable(true)
+                        .setInterval(AdvertisingSetParameters.INTERVAL_LOW)
+                        .setTxPowerLevel(convertAdvertiseSettingsTxPower(simulator.getTxPower()))
+                        .build();
+        advertiser.startAdvertisingSet(parameters, data, null, null, null, advertisingSetCallback);
+    }
+
+    private static int convertAdvertiseSettingsTxPower(int txPower) {
+        switch (txPower) {
+            case AdvertiseSettings.ADVERTISE_TX_POWER_ULTRA_LOW:
+                return AdvertisingSetParameters.TX_POWER_ULTRA_LOW;
+            case AdvertiseSettings.ADVERTISE_TX_POWER_LOW:
+                return AdvertisingSetParameters.TX_POWER_LOW;
+            case AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM:
+                return AdvertisingSetParameters.TX_POWER_MEDIUM;
+            default:
+                return AdvertisingSetParameters.TX_POWER_HIGH;
+        }
+    }
+
+    @Override
+    public void stopAdvertising() {
+        if (simulator.isDestroyed()) {
+            return;
+        }
+
+        advertiser.stopAdvertisingSet(advertisingSetCallback);
+        advertisingSet = null;
+        simulator.setIsAdvertising(false);
+    }
+}
diff --git a/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/RfcommServer.java b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/RfcommServer.java
new file mode 100644
index 0000000..7846226
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/fastpair/testing/RfcommServer.java
@@ -0,0 +1,412 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.nearby.common.bluetooth.fastpair.testing;
+
+import static com.android.server.nearby.common.bluetooth.fastpair.testing.RfcommServer.State.ACCEPTING;
+import static com.android.server.nearby.common.bluetooth.fastpair.testing.RfcommServer.State.CONNECTED;
+import static com.android.server.nearby.common.bluetooth.fastpair.testing.RfcommServer.State.RESTARTING;
+import static com.android.server.nearby.common.bluetooth.fastpair.testing.RfcommServer.State.STARTING;
+import static com.android.server.nearby.common.bluetooth.fastpair.testing.RfcommServer.State.STOPPED;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothServerSocket;
+import android.bluetooth.BluetoothSocket;
+import android.nearby.multidevices.fastpair.EventStreamProtocol;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/**
+ * Listens for a rfcomm client to connect and supports both sending messages to the client and
+ * receiving messages from the client.
+ */
+public class RfcommServer {
+    private static final String TAG = "RfcommServer";
+    private final Logger logger = new Logger(TAG);
+
+    private static final String FAST_PAIR_RFCOMM_SERVICE_NAME = "FastPairServer";
+    public static final UUID FAST_PAIR_RFCOMM_UUID =
+            UUID.fromString("df21fe2c-2515-4fdb-8886-f12c4d67927c");
+
+    /** A single thread executor where all state checks are performed. */
+    private final ExecutorService controllerExecutor = Executors.newSingleThreadExecutor();
+
+    private final ExecutorService sendMessageExecutor = Executors.newSingleThreadExecutor();
+    private final ExecutorService receiveMessageExecutor = Executors.newSingleThreadExecutor();
+
+    @Nullable
+    private BluetoothServerSocket serverSocket;
+    @Nullable
+    private BluetoothSocket socket;
+
+    private State state = STOPPED;
+    private boolean isStopRequested = false;
+
+    @Nullable
+    private RequestHandler requestHandler;
+
+    @Nullable
+    private CountDownLatch countDownLatch;
+    @Nullable
+    private StateMonitor stateMonitor;
+
+    /**
+     * Manages RfcommServer status.
+     *
+     * <pre>{@code
+     *      +------------------------------------------------+
+     *      +-------------------------------+                |
+     *      v                               |                |
+     * +---------+    +----------+    +-----+-----+    +-----+-----+
+     * | STOPPED +--> | STARTING +--> | ACCEPTING +--> | CONNECTED |
+     * +---------+    +-----+----+    +-------+---+    +-----+-----+
+     *      ^               |             ^   v              |
+     *      +---------------+         +---+--------+         |
+     *                                | RESTARTING | <-------+
+     *                                +------------+
+     * }</pre>
+     *
+     * If Stop action is not requested, the server will restart forever. Otherwise, go stopped.
+     */
+    public enum State {
+        STOPPED,
+        STARTING,
+        RESTARTING,
+        ACCEPTING,
+        CONNECTED,
+    }
+
+    /** Starts the rfcomm server. */
+    public void start() {
+        runInControllerExecutor(this::startServer);
+    }
+
+    private void startServer() {
+        log("Start RfcommServer");
+
+        if (!state.equals(STOPPED)) {
+            log("Server is not stopped, skip start request.");
+            return;
+        }
+        updateState(STARTING);
+        isStopRequested = false;
+
+        startAccept();
+    }
+
+    private void restartServer() {
+        log("Restart RfcommServer");
+        updateState(RESTARTING);
+        startAccept();
+    }
+
+    private void startAccept() {
+        try {
+            // Gets server socket in controller thread for stop() API.
+            serverSocket =
+                    BluetoothAdapter.getDefaultAdapter()
+                            .listenUsingRfcommWithServiceRecord(
+                                    FAST_PAIR_RFCOMM_SERVICE_NAME, FAST_PAIR_RFCOMM_UUID);
+        } catch (IOException e) {
+            log("Create service record failed, stop server");
+            stopServer();
+            return;
+        }
+
+        updateState(ACCEPTING);
+        new Thread(() -> accept(serverSocket)).start();
+    }
+
+    private void accept(BluetoothServerSocket serverSocket) {
+        triggerCountdownLatch();
+
+        try {
+            BluetoothSocket socket = serverSocket.accept();
+            serverSocket.close();
+
+            runInControllerExecutor(() -> startListen(socket));
+        } catch (IOException e) {
+            log("IOException when accepting new connection");
+            runInControllerExecutor(() -> handleAcceptException(serverSocket));
+        }
+    }
+
+    private void handleAcceptException(BluetoothServerSocket serverSocket) {
+        if (isStopRequested) {
+            stopServer();
+        } else {
+            closeServerSocket(serverSocket);
+            restartServer();
+        }
+    }
+
+    private void startListen(BluetoothSocket bluetoothSocket) {
+        if (isStopRequested) {
+            closeSocket(bluetoothSocket);
+            stopServer();
+            return;
+        }
+
+        updateState(CONNECTED);
+        // Sets method parameter to global socket for stop() API.
+        this.socket = bluetoothSocket;
+        new Thread(() -> listen(bluetoothSocket)).start();
+    }
+
+    private void listen(BluetoothSocket bluetoothSocket) {
+        triggerCountdownLatch();
+
+        try {
+            DataInputStream dataInputStream = new DataInputStream(bluetoothSocket.getInputStream());
+            while (true) {
+                int eventGroup = dataInputStream.readUnsignedByte();
+                int eventCode = dataInputStream.readUnsignedByte();
+                int additionalLength = dataInputStream.readUnsignedShort();
+
+                byte[] data = new byte[additionalLength];
+                if (additionalLength > 0) {
+                    int count = 0;
+                    do {
+                        count += dataInputStream.read(data, count, additionalLength - count);
+                    } while (count < additionalLength);
+                }
+
+                if (requestHandler != null) {
+                    // In order not to block listening thread, use different thread to dispatch message.
+                    receiveMessageExecutor.execute(
+                            () -> {
+                                requestHandler.handleRequest(eventGroup, eventCode, data);
+                                triggerCountdownLatch();
+                            });
+                }
+            }
+        } catch (IOException e) {
+            log(
+                    String.format(
+                            "IOException when listening to %s", bluetoothSocket.getRemoteDevice().getAddress()));
+            runInControllerExecutor(() -> handleListenException(bluetoothSocket));
+        }
+    }
+
+    private void handleListenException(BluetoothSocket bluetoothSocket) {
+        if (isStopRequested) {
+            stopServer();
+        } else {
+            closeSocket(bluetoothSocket);
+            restartServer();
+        }
+    }
+
+    public void sendFakeEventStreamMessage(EventStreamProtocol.EventGroup eventGroup) {
+        switch (eventGroup) {
+            case BLUETOOTH:
+                send(EventStreamProtocol.EventGroup.BLUETOOTH_VALUE,
+                        EventStreamProtocol.BluetoothEventCode.BLUETOOTH_ENABLE_SILENCE_MODE_VALUE, new byte[0]);
+                break;
+            case LOGGING:
+                send(EventStreamProtocol.EventGroup.LOGGING_VALUE, EventStreamProtocol.LoggingEventCode.LOG_FULL_VALUE,
+                        new byte[0]);
+                break;
+            case DEVICE:
+                send(EventStreamProtocol.EventGroup.DEVICE_VALUE,
+                        EventStreamProtocol.DeviceEventCode.DEVICE_BATTERY_INFO_VALUE, new byte[]{0x11, 0x12, 0x13});
+                break;
+            default: // fall out
+        }
+    }
+
+    public void sendFakeEventStreamLoggingMessage(@Nullable String logContent) {
+        send(EventStreamProtocol.EventGroup.LOGGING_VALUE,
+                EventStreamProtocol.LoggingEventCode.LOG_SAVE_TO_BUFFER_VALUE,
+                logContent != null ? logContent.getBytes(UTF_8) : new byte[0]);
+    }
+
+    public void send(int eventGroup, int eventCode, byte[] data) {
+        runInControllerExecutor(
+                () -> {
+                    if (!CONNECTED.equals(state)) {
+                        log("Server is not in CONNECTED state, skip send request");
+                        return;
+                    }
+                    BluetoothSocket bluetoothSocket = this.socket;
+                    sendMessageExecutor.execute(() -> {
+                        String address = bluetoothSocket.getRemoteDevice().getAddress();
+                        try {
+                            DataOutputStream dataOutputStream =
+                                    new DataOutputStream(bluetoothSocket.getOutputStream());
+                            dataOutputStream.writeByte(eventGroup);
+                            dataOutputStream.writeByte(eventCode);
+                            dataOutputStream.writeShort(data.length);
+                            if (data.length > 0) {
+                                dataOutputStream.write(data);
+                            }
+                            dataOutputStream.flush();
+                            log(
+                                    String.format(
+                                            "Send message to %s: %s, %s, %s.",
+                                            address, eventGroup, eventCode, data.length));
+                        } catch (IOException e) {
+                            log(
+                                    String.format(
+                                            "Failed to send message to %s: %s, %s, %s.",
+                                            address, eventGroup, eventCode, data.length),
+                                    e);
+                        }
+                    });
+                });
+    }
+
+    /** Stops the rfcomm server. */
+    public void stop() {
+        runInControllerExecutor(() -> {
+            log("Stop RfcommServer");
+
+            if (STOPPED.equals(state)) {
+                log("Server is stopped, skip stop request.");
+                return;
+            }
+
+            if (isStopRequested) {
+                log("Stop is already requested, skip stop request.");
+                return;
+            }
+            isStopRequested = true;
+
+            if (ACCEPTING.equals(state)) {
+                closeServerSocket(serverSocket);
+            }
+
+            if (CONNECTED.equals(state)) {
+                closeSocket(socket);
+            }
+        });
+    }
+
+    private void stopServer() {
+        updateState(STOPPED);
+        triggerCountdownLatch();
+    }
+
+    private void updateState(State newState) {
+        log(String.format("Change state from %s to %s", state, newState));
+        if (stateMonitor != null) {
+            stateMonitor.onStateChanged(newState);
+        }
+        state = newState;
+    }
+
+    private void closeServerSocket(BluetoothServerSocket serverSocket) {
+        try {
+            if (serverSocket != null) {
+                log(String.format("Close server socket: %s", serverSocket));
+                serverSocket.close();
+            }
+        } catch (IOException | NullPointerException e) {
+            // NullPointerException is used to skip robolectric test failure.
+            // In unit test, different virtual devices are set up in different threads, calling
+            // ServerSocket.close() in wrong thread will result in NullPointerException since there
+            // is no corresponding service record.
+            // TODO(hylo): Remove NullPointerException when the solution is submitted to test cases.
+            log("Failed to stop server", e);
+        }
+    }
+
+    private void closeSocket(BluetoothSocket socket) {
+        try {
+            if (socket != null && socket.isConnected()) {
+                log(String.format("Close socket: %s", socket.getRemoteDevice().getAddress()));
+                socket.close();
+            }
+        } catch (IOException e) {
+            log(String.format("IOException when close socket %s", socket.getRemoteDevice().getAddress()));
+        }
+    }
+
+    private void runInControllerExecutor(Runnable runnable) {
+        controllerExecutor.execute(runnable);
+    }
+
+    private void log(String message) {
+        logger.log("Server=%s, %s", FAST_PAIR_RFCOMM_SERVICE_NAME, message);
+    }
+
+    private void log(String message, Throwable e) {
+        logger.log(e, "Server=%s, %s", FAST_PAIR_RFCOMM_SERVICE_NAME, message);
+    }
+
+    private void triggerCountdownLatch() {
+        if (countDownLatch != null) {
+            countDownLatch.countDown();
+        }
+    }
+
+    /** Interface to handle incoming request from clients. */
+    public interface RequestHandler {
+        void handleRequest(int eventGroup, int eventCode, byte[] data);
+    }
+
+    public void setRequestHandler(@Nullable RequestHandler requestHandler) {
+        this.requestHandler = requestHandler;
+    }
+
+    /** A state monitor to send signal when state is changed. */
+    public interface StateMonitor {
+        void onStateChanged(State state);
+    }
+
+    public void setStateMonitor(@Nullable StateMonitor stateMonitor) {
+        this.stateMonitor = stateMonitor;
+    }
+
+    @VisibleForTesting
+    void setCountDownLatch(@Nullable CountDownLatch countDownLatch) {
+        this.countDownLatch = countDownLatch;
+    }
+
+    @VisibleForTesting
+    void setIsStopRequested(boolean isStopRequested) {
+        this.isStopRequested = isStopRequested;
+    }
+
+    @VisibleForTesting
+    void simulateAcceptIOException() {
+        runInControllerExecutor(() -> {
+            if (ACCEPTING.equals(state)) {
+                closeServerSocket(serverSocket);
+            }
+        });
+    }
+
+    @VisibleForTesting
+    void simulateListenIOException() {
+        runInControllerExecutor(() -> {
+            if (CONNECTED.equals(state)) {
+                closeSocket(socket);
+            }
+        });
+    }
+}
diff --git a/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/gatt/server/BluetoothGattServerConfig.java b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/gatt/server/BluetoothGattServerConfig.java
new file mode 100644
index 0000000..132e026
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/gatt/server/BluetoothGattServerConfig.java
@@ -0,0 +1,132 @@
+
+package com.android.server.nearby.common.bluetooth.gatt.server;
+
+import android.annotation.TargetApi;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattService;
+import com.android.server.nearby.common.bluetooth.BluetoothConsts;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.UUID;
+import javax.annotation.Nullable;
+
+/** Configuration of a GATT server. */
+@TargetApi(18)
+public class BluetoothGattServerConfig {
+    private final Map<UUID, ServiceConfig> mServiceConfigs = new HashMap<UUID, ServiceConfig>();
+
+    @Nullable
+    private BluetoothGattServerHelper.Listener mServerlistener = null;
+
+    public BluetoothGattServerConfig addService(UUID uuid, ServiceConfig serviceConfig) {
+        mServiceConfigs.put(uuid, serviceConfig);
+        return this;
+    }
+
+    public BluetoothGattServerConfig setServerConnectionListener(
+            BluetoothGattServerHelper.Listener listener) {
+        mServerlistener = listener;
+        return this;
+    }
+
+    @Nullable
+    public BluetoothGattServerHelper.Listener getServerListener() {
+        return mServerlistener;
+    }
+
+    /**
+     * Adds a service and a characteristic to indicate that the server has dynamic services.
+     * This is a workaround for b/21587710.
+     * TODO(lingjunl): remove them when b/21587710 is fixed.
+     */
+    public BluetoothGattServerConfig addSelfDefinedDynamicService() {
+        ServiceConfig serviceConfig = new ServiceConfig().addCharacteristic(new BluetoothGattServlet() {
+            @Override
+            public BluetoothGattCharacteristic getCharacteristic() {
+                return new BluetoothGattCharacteristic(
+                        BluetoothConsts.SERVICE_DYNAMIC_CHARACTERISTIC,
+                        BluetoothGattCharacteristic.PROPERTY_READ,
+                        BluetoothGattCharacteristic.PERMISSION_READ);
+            }
+        });
+        return addService(BluetoothConsts.SERVICE_DYNAMIC_SERVICE, serviceConfig);
+    }
+
+    public List<BluetoothGattService> getBluetoothGattServices() {
+        List<BluetoothGattService> result = new ArrayList<BluetoothGattService>();
+        for (Entry<UUID, ServiceConfig> serviceEntry : mServiceConfigs.entrySet()) {
+            UUID serviceUuid = serviceEntry.getKey();
+            ServiceConfig serviceConfig = serviceEntry.getValue();
+            if (serviceUuid == null || serviceConfig == null) {
+                // This is not supposed to happen
+                throw new IllegalStateException();
+            }
+            BluetoothGattService gattService =
+                    new BluetoothGattService(serviceUuid, BluetoothGattService.SERVICE_TYPE_PRIMARY);
+            for (Entry<BluetoothGattCharacteristic, BluetoothGattServlet> servletEntry :
+                    serviceConfig.getServlets().entrySet()) {
+                BluetoothGattCharacteristic characteristic = servletEntry.getKey();
+                if (characteristic == null) {
+                    // This is not supposed to happen
+                    throw new IllegalStateException();
+                }
+                gattService.addCharacteristic(characteristic);
+            }
+            result.add(gattService);
+        }
+        return result;
+    }
+
+    public List<UUID> getAdvertisedUuids() {
+        List<UUID> result = new ArrayList<UUID>();
+        for (Entry<UUID, ServiceConfig> serviceEntry : mServiceConfigs.entrySet()) {
+            UUID serviceUuid = serviceEntry.getKey();
+            ServiceConfig serviceConfig = serviceEntry.getValue();
+            if (serviceUuid == null || serviceConfig == null) {
+                // This is not supposed to happen
+                throw new IllegalStateException();
+            }
+            if (serviceConfig.isAdvertised()) {
+                result.add(serviceUuid);
+            }
+        }
+        return result;
+    }
+
+    public Map<BluetoothGattCharacteristic, BluetoothGattServlet> getServlets() {
+        Map<BluetoothGattCharacteristic, BluetoothGattServlet> result =
+                new HashMap<BluetoothGattCharacteristic, BluetoothGattServlet>();
+        for (ServiceConfig serviceConfig : mServiceConfigs.values()) {
+            result.putAll(serviceConfig.getServlets());
+        }
+        return result;
+    }
+
+    /** Configuration of a GATT service. */
+    public static class ServiceConfig {
+        private final Map<BluetoothGattCharacteristic, BluetoothGattServlet> mServlets =
+                new HashMap<BluetoothGattCharacteristic, BluetoothGattServlet>();
+        private boolean mAdvertise = false;
+
+        public ServiceConfig addCharacteristic(BluetoothGattServlet servlet) {
+            mServlets.put(servlet.getCharacteristic(), servlet);
+            return this;
+        }
+
+        public ServiceConfig setAdvertise(boolean advertise) {
+            mAdvertise = advertise;
+            return this;
+        }
+
+        public Map<BluetoothGattCharacteristic, BluetoothGattServlet> getServlets() {
+            return mServlets;
+        }
+
+        public boolean isAdvertised() {
+            return mAdvertise;
+        }
+    }
+}
diff --git a/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/gatt/server/BluetoothGattServerConnection.java b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/gatt/server/BluetoothGattServerConnection.java
new file mode 100644
index 0000000..df1a832
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/gatt/server/BluetoothGattServerConnection.java
@@ -0,0 +1,439 @@
+package com.android.server.nearby.common.bluetooth.gatt.server;
+
+import android.annotation.TargetApi;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.util.Log;
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+import com.android.server.nearby.common.bluetooth.BluetoothGattException;
+import com.android.server.nearby.common.bluetooth.ReservedUuids;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothDevice;
+import com.android.server.nearby.common.bluetooth.util.BluetoothGattUtils;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor.Operation;
+import com.google.common.annotations.VisibleForTesting;
+// import com.google.common.annotations.VisibleForTesting.Visibility;
+import com.android.internal.annotations.VisibleForTesting.Visibility;
+import com.google.common.base.Objects;
+import com.google.common.base.Preconditions;
+import com.google.common.io.BaseEncoding;
+import java.io.ByteArrayOutputStream;
+import java.io.Closeable;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import java.util.concurrent.TimeUnit;
+import javax.annotation.Nullable;
+
+/**
+ * Connection to a bluetooth LE device over Gatt.
+ */
+@TargetApi(18)
+public class BluetoothGattServerConnection implements Closeable {
+    @SuppressWarnings("unused")
+    private static final String TAG = BluetoothGattServerConnection.class.getSimpleName();
+
+    /** See {@link BluetoothGattDescriptor#DISABLE_NOTIFICATION_VALUE}. */
+    private static final short DISABLE_NOTIFICATION_VALUE = 0x0000;
+
+    /** See {@link BluetoothGattDescriptor#ENABLE_NOTIFICATION_VALUE}. */
+    private static final short ENABLE_NOTIFICATION_VALUE = 0x0001;
+
+    /** See {@link BluetoothGattDescriptor#ENABLE_INDICATION_VALUE}. */
+    private static final short ENABLE_INDICATION_VALUE = 0x0002;
+
+    /** Default MTU when value is unknown. */
+    public static final int DEFAULT_MTU = 23;
+
+    @VisibleForTesting static final long OPERATION_TIMEOUT = TimeUnit.SECONDS.toMillis(1);
+
+    /** Notification types as defined by the BLE spec vol 4, sec G, part 3.3.3.3 */
+    public enum NotificationType {
+        NOTIFICATION,
+        INDICATION
+    }
+
+    /** BT operation types that can be in flight. */
+    public enum OperationType {
+        SEND_NOTIFICATION
+    }
+
+    private final Map<ScopedKey, Object> mContextValues = new HashMap<ScopedKey, Object>();
+    private final List<Listener> mCloseListeners = new ArrayList<Listener>();
+
+    private final BluetoothGattServerHelper mBluetoothGattServerHelper;
+    private final BluetoothDevice mBluetoothDevice;
+
+    @VisibleForTesting BluetoothOperationExecutor mBluetoothOperationScheduler =
+            new BluetoothOperationExecutor(1);
+
+    /** Stores pending writes. For each UUID, we store an offset and a byte[] of data. */
+    @VisibleForTesting
+    final Map<BluetoothGattServlet, SortedMap<Integer, byte[]>> mQueuedCharacteristicWrites =
+            new HashMap<BluetoothGattServlet, SortedMap<Integer, byte[]>>();
+
+    @VisibleForTesting
+    final Map<BluetoothGattCharacteristic, Notifier> mRegisteredNotifications =
+            new HashMap<BluetoothGattCharacteristic, Notifier>();
+
+    private final Map<BluetoothGattCharacteristic, BluetoothGattServlet> mServlets;
+
+    public BluetoothGattServerConnection(
+            BluetoothGattServerHelper bluetoothGattServerHelper,
+            BluetoothDevice device,
+            BluetoothGattServerConfig serverConfig) {
+        mBluetoothGattServerHelper = bluetoothGattServerHelper;
+        mBluetoothDevice = device;
+        mServlets = serverConfig.getServlets();
+    }
+
+    public void setContextValue(Object scope, String key, @Nullable Object value) {
+        mContextValues.put(new ScopedKey(scope, key), value);
+    }
+
+    @Nullable
+    public Object getContextValue(Object scope, String key) {
+        return mContextValues.get(new ScopedKey(scope, key));
+    }
+
+    public BluetoothDevice getDevice() {
+        return mBluetoothDevice;
+    }
+
+    public int getMtu() {
+        return DEFAULT_MTU;
+    }
+
+    public int getMaxDataPacketSize() {
+        // Per BT specs (3.2.9), only MTU - 3 bytes can be used to transmit data
+        return getMtu() - 3;
+    }
+
+    public void addCloseListener(Listener listener) {
+        synchronized (mCloseListeners) {
+            mCloseListeners.add(listener);
+        }
+    }
+
+    public void removeCloseListener(Listener listener) {
+        synchronized (mCloseListeners) {
+            mCloseListeners.remove(listener);
+        }
+    }
+
+    private final BluetoothGattServlet getServlet(BluetoothGattCharacteristic characteristic)
+            throws BluetoothGattException {
+        BluetoothGattServlet servlet = mServlets.get(characteristic);
+        if (servlet == null) {
+            throw new BluetoothGattException(
+                    String.format("No handler registered for characteristic %s.", characteristic.getUuid()),
+                    BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED);
+        }
+        return servlet;
+    }
+
+    public byte[] readCharacteristic(int offset, BluetoothGattCharacteristic characteristic)
+            throws BluetoothGattException {
+        return getServlet(characteristic).read(this, offset);
+    }
+
+    public void writeCharacteristic(BluetoothGattCharacteristic characteristic, boolean preparedWrite,
+            int offset, byte[] value) throws BluetoothGattException {
+        Log.d(TAG, String.format(
+                "Received %d bytes at offset %d on %s from device %s, prepareWrite=%s.",
+                value.length,
+                offset,
+                BluetoothGattUtils.toString(characteristic),
+                mBluetoothDevice,
+                preparedWrite));
+        BluetoothGattServlet servlet = getServlet(characteristic);
+        if (preparedWrite) {
+            SortedMap<Integer, byte[]> bytePackets = mQueuedCharacteristicWrites.get(servlet);
+            if (bytePackets == null) {
+                bytePackets = new TreeMap<Integer, byte[]>();
+                mQueuedCharacteristicWrites.put(servlet, bytePackets);
+            }
+            bytePackets.put(offset, value);
+            return;
+        }
+
+        Log.d(TAG, servlet.toString());
+        servlet.write(this, offset, value);
+    }
+
+    public byte[] readDescriptor(int offset, BluetoothGattDescriptor descriptor)
+            throws BluetoothGattException {
+        BluetoothGattCharacteristic characteristic = descriptor.getCharacteristic();
+        if (characteristic == null) {
+            throw new BluetoothGattException(String.format(
+                    "Descriptor %s not associated with a characteristics!",
+                    BluetoothGattUtils.toString(descriptor)), BluetoothGatt.GATT_FAILURE);
+        }
+        return getServlet(characteristic).readDescriptor(this, descriptor, offset);
+    }
+
+    public void writeDescriptor(
+            BluetoothGattDescriptor descriptor,
+            boolean preparedWrite,
+            int offset,
+            byte[] value) throws BluetoothGattException {
+        Log.d(TAG, String.format(
+                "Received %d bytes at offset %d on %s from device %s, prepareWrite=%s.",
+                value.length,
+                offset,
+                BluetoothGattUtils.toString(descriptor),
+                mBluetoothDevice,
+                preparedWrite));
+        if (preparedWrite) {
+            throw new BluetoothGattException(
+                    String.format("Prepare write not supported for descriptor %s.", descriptor),
+                    BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED);
+        }
+
+        BluetoothGattCharacteristic characteristic = descriptor.getCharacteristic();
+        if (characteristic == null) {
+            throw new BluetoothGattException(String.format(
+                    "Descriptor %s not associated with a characteristics!",
+                    BluetoothGattUtils.toString(descriptor)), BluetoothGatt.GATT_FAILURE);
+        }
+        BluetoothGattServlet servlet = getServlet(characteristic);
+        if (descriptor.getUuid().equals(
+                ReservedUuids.Descriptors.CLIENT_CHARACTERISTIC_CONFIGURATION)) {
+            handleCharacteristicConfigurationChange(characteristic, servlet, offset, value);
+            return;
+        }
+        servlet.writeDescriptor(this, descriptor, offset, value);
+    }
+
+    private void handleCharacteristicConfigurationChange(
+            final BluetoothGattCharacteristic characteristic, BluetoothGattServlet servlet, int offset,
+            byte[] value)
+            throws BluetoothGattException {
+        if (offset != 0) {
+            throw new BluetoothGattException(String.format(
+                    "Offset should be 0 when changing the client characteristic config: %d.", offset),
+                    BluetoothGatt.GATT_INVALID_OFFSET);
+        }
+        if (value.length != 2) {
+            throw new BluetoothGattException(String.format(
+                    "Value 0x%s is undefined for the client characteristic config",
+                    BaseEncoding.base16().encode(value)), BluetoothGatt.GATT_INVALID_ATTRIBUTE_LENGTH);
+        }
+
+        boolean notificationRegistered = mRegisteredNotifications.containsKey(characteristic);
+        Notifier notifier;
+        switch (toShort(value)) {
+            case ENABLE_NOTIFICATION_VALUE:
+                if (!notificationRegistered) {
+                    notifier = new Notifier() {
+                        @Override
+                        public void notify(byte[] data) throws BluetoothException {
+                            sendNotification(characteristic, NotificationType.NOTIFICATION, data);
+                        }
+                    };
+                    mRegisteredNotifications.put(characteristic, notifier);
+                    servlet.enableNotification(this, notifier);
+                }
+                break;
+            case ENABLE_INDICATION_VALUE:
+                if (!notificationRegistered) {
+                    notifier = new Notifier() {
+                        @Override
+                        public void notify(byte[] data) throws BluetoothException {
+                            sendNotification(characteristic, NotificationType.INDICATION, data);
+                        }
+                    };
+                    mRegisteredNotifications.put(characteristic, notifier);
+                    servlet.enableNotification(this, notifier);
+                }
+                break;
+            case DISABLE_NOTIFICATION_VALUE:
+                // Note: this disables notifications or indications.
+                if (notificationRegistered) {
+                    notifier = mRegisteredNotifications.remove(characteristic);
+                    if (notifier == null) {
+                        return; // this is not supposed to happen
+                    }
+                    servlet.disableNotification(this, notifier);
+                }
+                break;
+            default:
+                throw new BluetoothGattException(String.format(
+                        "Value 0x%s is undefined for the client characteristic config",
+                        BaseEncoding.base16().encode(value)), BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED);
+        }
+    }
+
+    private static short toShort(byte[] value) {
+        Preconditions.checkNotNull(value);
+        Preconditions.checkArgument(value.length == 2, "Length should be 2 bytes.");
+
+        return (short) ((value[0] & 0x00FF) | (value[1] << 8));
+    }
+
+    public void executeWrite(boolean execute) throws BluetoothGattException {
+        if (!execute) {
+            mQueuedCharacteristicWrites.clear();
+            return;
+        }
+
+        try {
+            for (Entry<BluetoothGattServlet, SortedMap<Integer, byte[]>> queuedWrite :
+                    mQueuedCharacteristicWrites.entrySet()) {
+                BluetoothGattServlet servlet = queuedWrite.getKey();
+                SortedMap<Integer, byte[]> chunks = queuedWrite.getValue();
+                if (servlet == null || chunks == null) {
+                    // This is not supposed to happen
+                    throw new IllegalStateException();
+                }
+                assembleByteChunksAndHandle(servlet, chunks);
+            }
+        } finally {
+            mQueuedCharacteristicWrites.clear();
+        }
+    }
+
+    /**
+     * Assembles the specified queued writes and calls the provided write handler on the assembled
+     * chunks. Tries to assemble all the chunks into one write request. For example, if the content
+     * of byteChunks is:
+     *  <code>
+     *     offset data_size
+     *       0       10
+     *      10        1
+     *      11        5
+     *  </code>
+     *
+     *  then this method would call <code>writeHandler.onWrite(0, byte[16])</code>
+     *
+     * However, if all the chunks cannot be assembled into a continuous byte[], then onWrite() will
+     * be called multiple times with the largest continuous chunks. For example, if the content of
+     * byteChunks is:
+     * <code>
+     *     offset data_size
+     *      10       12
+     *      30        5
+     *      35        9
+     * </code>
+     *
+     *  then this method would call <code>writeHandler.onWrite(10, byte[12)</code> and
+     *  <code>writeHandler.onWrite(30, byte[14]).
+     */
+    private void assembleByteChunksAndHandle(BluetoothGattServlet servlet,
+            SortedMap<Integer, byte[]> byteChunks) throws BluetoothGattException {
+        ByteArrayOutputStream assembledRequest = new ByteArrayOutputStream();
+        Integer startWritingAtOffset = 0;
+
+        while (!byteChunks.isEmpty()) {
+            Integer offset = byteChunks.firstKey();
+
+            if (offset.intValue() < startWritingAtOffset + assembledRequest.size()) {
+                throw new BluetoothGattException("Expected offset of at least " + assembledRequest.size()
+                        + ", but got offset " + offset, BluetoothGatt.GATT_INVALID_OFFSET);
+            }
+
+            // If we have a hole, then write what we've already assembled and start assembling a new
+            // long write
+            if (offset.intValue() > startWritingAtOffset + assembledRequest.size()) {
+                servlet.write(this, startWritingAtOffset.intValue(),
+                        assembledRequest.toByteArray());
+                startWritingAtOffset = offset;
+                assembledRequest.reset();
+            }
+
+            try {
+                byte[] dataChunk = byteChunks.remove(offset);
+                if (dataChunk == null) {
+                    // This is not supposed to happen
+                    throw new IllegalStateException();
+                }
+                assembledRequest.write(dataChunk);
+            } catch (IOException e) {
+                throw new BluetoothGattException("Error assembling request", BluetoothGatt.GATT_FAILURE);
+            }
+        }
+
+        // If there is anything to write, write it
+        if (assembledRequest.size() > 0) {
+            Preconditions.checkNotNull(startWritingAtOffset); // should never be null at this point
+            servlet.write(this, startWritingAtOffset.intValue(), assembledRequest.toByteArray());
+        }
+    }
+
+    private void sendNotification(final BluetoothGattCharacteristic characteristic,
+            final NotificationType notificationType, final byte[] data)
+            throws BluetoothException {
+        mBluetoothOperationScheduler.execute(
+                new Operation<Void>(OperationType.SEND_NOTIFICATION) {
+                    @Override
+                    public void run() throws BluetoothException {
+                        mBluetoothGattServerHelper.sendNotification(mBluetoothDevice, characteristic,
+                                data, notificationType == NotificationType.INDICATION ? true : false);
+                    }
+                },
+                OPERATION_TIMEOUT);
+    }
+
+    @Override
+    public void close() throws IOException {
+        try {
+            mBluetoothGattServerHelper.closeConnection(mBluetoothDevice);
+        } catch (BluetoothException e) {
+            throw new IOException("Failed to close connection", e);
+        }
+    }
+
+    public void notifyNotificationSent(int status) {
+        mBluetoothOperationScheduler.notifyCompletion(
+                new Operation<Void>(OperationType.SEND_NOTIFICATION), status);
+    }
+
+    public void onClose() {
+        synchronized (mCloseListeners) {
+            for (Listener listener : mCloseListeners) {
+                listener.onClose();
+            }
+        }
+    }
+
+    /** Scope/key pair to use to reference contextual values. */
+    private static class ScopedKey {
+        private final Object mScope;
+        private final String mKey;
+
+        public ScopedKey(Object scope, String key) {
+            mScope = scope;
+            mKey = key;
+        }
+
+        @Override
+        public boolean equals(@Nullable Object o) {
+            if (!(o instanceof ScopedKey)) {
+                return false;
+            }
+            ScopedKey other = (ScopedKey) o;
+            return other.mScope.equals(mScope) && other.mKey.equals(mKey);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hashCode(mScope, mKey);
+        }
+    }
+
+    /** Listener to be notified when the connection closes. */
+    public static interface Listener {
+        void onClose();
+    }
+
+    /** Notifier to notify data over notification or indication. */
+    public static interface Notifier {
+        void notify(byte[] data) throws BluetoothException;
+    }
+}
diff --git a/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/gatt/server/BluetoothGattServerHelper.java b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/gatt/server/BluetoothGattServerHelper.java
new file mode 100644
index 0000000..3cec24a
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/gatt/server/BluetoothGattServerHelper.java
@@ -0,0 +1,408 @@
+package com.android.server.nearby.common.bluetooth.gatt.server;
+
+import android.annotation.TargetApi;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothGattService;
+import android.bluetooth.BluetoothProfile;
+import android.content.Context;
+import android.os.Build.VERSION_CODES;
+import android.util.Log;
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.annotations.VisibleForTesting.Visibility;
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+import com.android.server.nearby.common.bluetooth.BluetoothGattException;
+import com.android.server.nearby.common.bluetooth.testability.VersionProvider;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothDevice;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothGattServer;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothGattServerCallback;
+import com.android.server.nearby.common.bluetooth.testability.android.bluetooth.BluetoothManager;
+import com.android.server.nearby.common.bluetooth.util.BluetoothGattUtils;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor;
+import com.android.server.nearby.common.bluetooth.util.BluetoothOperationExecutor.Operation;
+// import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.TimeUnit;
+import javax.annotation.Nullable;
+
+/**
+ * Helper for simplifying operations on {@link BluetoothGattServer}.
+ */
+@TargetApi(18)
+public class BluetoothGattServerHelper {
+    private static final String TAG = BluetoothGattServerHelper.class.getSimpleName();
+
+    @VisibleForTesting static final long OPERATION_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(1);
+    private static final int MAX_PARALLEL_OPERATIONS = 5;
+
+    /** BT operation types that can be in flight. */
+    public enum OperationType {
+        ADD_SERVICE,
+        CLOSE_CONNECTION,
+        START_ADVERTISING
+    }
+
+    private final Object mOperationLock = new Object();
+    @VisibleForTesting final BluetoothGattServerCallback mGattServerCallback =
+            new GattServerCallback();
+    @VisibleForTesting BluetoothOperationExecutor mBluetoothOperationScheduler =
+            new BluetoothOperationExecutor(MAX_PARALLEL_OPERATIONS);
+
+    private final Context mContext;
+    private final BluetoothManager mBluetoothManager;
+    private final VersionProvider mVersionProvider;
+
+    @Nullable
+    @VisibleForTesting volatile BluetoothGattServerConfig mServerConfig = null;
+
+    @Nullable
+    @VisibleForTesting volatile BluetoothGattServer mBluetoothGattServer = null;
+
+    @VisibleForTesting final ConcurrentMap<BluetoothDevice, BluetoothGattServerConnection>
+            mConnections = new ConcurrentHashMap<BluetoothDevice, BluetoothGattServerConnection>();
+
+    public BluetoothGattServerHelper(Context context, BluetoothManager bluetoothManager) {
+        this(
+                Preconditions.checkNotNull(context),
+                Preconditions.checkNotNull(bluetoothManager),
+                new VersionProvider()
+        );
+    }
+
+    @VisibleForTesting BluetoothGattServerHelper(
+            Context context, BluetoothManager bluetoothManager, VersionProvider versionProvider) {
+        mContext = context;
+        mBluetoothManager = bluetoothManager;
+        mVersionProvider = versionProvider;
+    }
+
+    @Nullable
+    public BluetoothGattServerConfig getConfig() {
+        return mServerConfig;
+    }
+
+    public void open(final BluetoothGattServerConfig gattServerConfig) throws BluetoothException {
+        synchronized (mOperationLock) {
+            Preconditions.checkState(mBluetoothGattServer == null, "Gatt server is already open.");
+            final BluetoothGattServer server =
+                    mBluetoothManager.openGattServer(mContext, mGattServerCallback);
+            if (server == null) {
+                throw new BluetoothException(
+                        "Failed to open the GATT server, openGattServer returned null.");
+            }
+
+            try {
+                for (final BluetoothGattService service : gattServerConfig.getBluetoothGattServices()) {
+                    if (service == null) {
+                        continue;
+                    }
+                    mBluetoothOperationScheduler.execute(
+                            new Operation<Void>(OperationType.ADD_SERVICE, service) {
+                                @Override
+                                public void run() throws BluetoothException {
+                                    boolean success = server.addService(service);
+                                    if (!success) {
+                                        throw new BluetoothException("Fails on adding service");
+                                    }
+                                }
+                            }, OPERATION_TIMEOUT_MILLIS);
+                }
+                mBluetoothGattServer = server;
+                mServerConfig = gattServerConfig;
+            } catch (BluetoothException e) {
+                server.close();
+                throw e;
+            }
+        }
+    }
+
+    public boolean isOpen() {
+        synchronized (mOperationLock) {
+            return mBluetoothGattServer != null;
+        }
+    }
+
+    public void close() {
+        synchronized (mOperationLock) {
+            BluetoothGattServer bluetoothGattServer = mBluetoothGattServer;
+            if (bluetoothGattServer == null) {
+                return;
+            }
+            bluetoothGattServer.close();
+            mBluetoothGattServer = null;
+        }
+    }
+
+    private BluetoothGattServerConnection getConnectionByDevice(BluetoothDevice device)
+            throws BluetoothGattException {
+        BluetoothGattServerConnection bluetoothLeConnection = mConnections.get(device);
+        if (bluetoothLeConnection == null) {
+            throw new BluetoothGattException(
+                    String.format("Received operation on an unknown device: %s", device),
+                    BluetoothGatt.GATT_FAILURE);
+        }
+        return bluetoothLeConnection;
+    }
+
+    public void sendNotification(
+            BluetoothDevice device,
+            BluetoothGattCharacteristic characteristic,
+            byte[] data,
+            boolean confirm)
+            throws BluetoothException {
+        Log.d(TAG, String.format("Sending a %s of %d bytes on characteristics %s on device %s.",
+                confirm ? "indication" : "notification",
+                data.length,
+                characteristic.getUuid(),
+                device));
+        synchronized (mOperationLock) {
+            BluetoothGattServer bluetoothGattServer = mBluetoothGattServer;
+            if (bluetoothGattServer == null) {
+                throw new BluetoothException("Server is not open.");
+            }
+            BluetoothGattCharacteristic clonedCharacteristic = BluetoothGattUtils.clone(characteristic);
+            clonedCharacteristic.setValue(data);
+            bluetoothGattServer.notifyCharacteristicChanged(device, clonedCharacteristic, confirm);
+        }
+    }
+
+    public void closeConnection(final BluetoothDevice bluetoothDevice) throws BluetoothException {
+        final BluetoothGattServer bluetoothGattServer = mBluetoothGattServer;
+        if (bluetoothGattServer == null) {
+            throw new BluetoothException("Server is not open.");
+        }
+        int connectionSate =
+                mBluetoothManager.getConnectionState(bluetoothDevice, BluetoothProfile.GATT);
+        if (connectionSate != BluetoothGatt.STATE_CONNECTED) {
+            return;
+        }
+        mBluetoothOperationScheduler.execute(new Operation<Void>(OperationType.CLOSE_CONNECTION) {
+                                                 @Override
+                                                 public void run() throws BluetoothException {
+                                                     bluetoothGattServer.cancelConnection(bluetoothDevice);
+                                                 }
+                                             },
+                OPERATION_TIMEOUT_MILLIS);
+    }
+
+    private class GattServerCallback extends BluetoothGattServerCallback {
+        @Override
+        public void onServiceAdded(int status, BluetoothGattService service) {
+            mBluetoothOperationScheduler.notifyCompletion(
+                    new Operation<Void>(OperationType.ADD_SERVICE, service), status);
+        }
+
+        @Override
+        public void onConnectionStateChange(BluetoothDevice device, int status, int newState) {
+            BluetoothGattServerConfig serverConfig = mServerConfig;
+            BluetoothGattServer bluetoothGattServer = mBluetoothGattServer;
+            BluetoothGattServerConnection bluetoothLeConnection;
+            if (serverConfig == null || bluetoothGattServer == null) {
+                return;
+            }
+            switch (newState) {
+                case BluetoothGattServer.STATE_CONNECTED:
+                    if (status != BluetoothGatt.GATT_SUCCESS) {
+                        Log.e(TAG, String.format("Connection to %s failed: %s", device,
+                                BluetoothGattUtils.getMessageForStatusCode(status)));
+                        return;
+                    }
+                    Log.i(TAG, String.format("Connected to device %s.", device));
+                    if (mConnections.containsKey(device)) {
+                        Log.w(TAG, String.format(
+                                "A connection is already open with device %s. Keeping existing one.", device));
+                        return;
+                    }
+
+                    BluetoothGattServerConnection connection = new BluetoothGattServerConnection(
+                            BluetoothGattServerHelper.this,
+                            device,
+                            serverConfig);
+                    if (serverConfig.getServerListener() != null) {
+                        serverConfig.getServerListener().onConnection(connection);
+                    }
+                    mConnections.put(device, connection);
+
+                    // By default, Android disconnects active GATT server connection if the advertisement is
+                    // stop (or sometime stopScanning also disconnect, see b/62667394). Asking the server to
+                    // reverse connect will tell Android to keep the connection open.
+                    // Code handling connect() on Android OS is: btif_gatt_server.c
+                    // Note: for Android < P, unknown type devices don't connect. See b/62827460.
+                    //           TODO(mfucci): this can be fixed if the GATT server is forced to be LE only.
+                    //       for Android P+, unknown type devices always use LE to connect (see code)
+                    // Note: for Android < N, dual mode devices always connect using BT classic, so connect()
+                    //       should *NOT* be called for those devices. See b/29819614.
+                    if (mVersionProvider.getSdkInt() >= VERSION_CODES.N
+                            || device.getType() != BluetoothDevice.DEVICE_TYPE_DUAL) {
+                        boolean success = bluetoothGattServer.connect(device, /* autoConnect */false);
+                        if (!success) {
+                            Log.w(TAG, String.format(
+                                    "Keeping connection open on stop advertising failed for device %s.", device));
+                        }
+                    }
+                    break;
+                case BluetoothGattServer.STATE_DISCONNECTED:
+                    if (status != BluetoothGatt.GATT_SUCCESS) {
+                        Log.w(TAG, String.format("Disconnection from %s error: %s. Proceeding anyway.", device,
+                                BluetoothGattUtils.getMessageForStatusCode(status)));
+                    }
+                    bluetoothLeConnection = mConnections.remove(device);
+                    if (bluetoothLeConnection != null) {
+                        // Disconnect the server, required after connecting to it.
+                        bluetoothGattServer.cancelConnection(device);
+                        bluetoothLeConnection.onClose();
+                    }
+                    mBluetoothOperationScheduler.notifyCompletion(
+                            new Operation<Void>(OperationType.CLOSE_CONNECTION), status);
+                    break;
+                default:
+                    Log.e(TAG, String.format("Unexpected connection state: %d", newState));
+                    return;
+            }
+        }
+
+        @Override
+        public void onCharacteristicReadRequest(BluetoothDevice device, int requestId, int offset,
+                BluetoothGattCharacteristic characteristic) {
+            BluetoothGattServer bluetoothGattServer = mBluetoothGattServer;
+            if (bluetoothGattServer == null) {
+                return;
+            }
+            try {
+                byte[] value = getConnectionByDevice(device).readCharacteristic(offset, characteristic);
+                bluetoothGattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset,
+                        value);
+            } catch (BluetoothGattException e) {
+                Log.e(TAG,
+                        String.format(
+                                "Could not read  %s on device %s at offset %d",
+                                BluetoothGattUtils.toString(characteristic),
+                                device,
+                                offset),
+                        e);
+                bluetoothGattServer.sendResponse(device, requestId, e.getGattErrorCode(), offset, null);
+            }
+        }
+
+        @Override
+        public void onCharacteristicWriteRequest(BluetoothDevice device,
+                int requestId,
+                BluetoothGattCharacteristic characteristic,
+                boolean preparedWrite,
+                boolean responseNeeded,
+                int offset,
+                byte[] value) {
+            BluetoothGattServer bluetoothGattServer = mBluetoothGattServer;
+            if (bluetoothGattServer == null) {
+                return;
+            }
+            try {
+                getConnectionByDevice(device).writeCharacteristic(characteristic,
+                        preparedWrite,
+                        offset,
+                        value);
+                if (responseNeeded) {
+                    bluetoothGattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset,
+                            null);
+                }
+            } catch (BluetoothGattException e) {
+                Log.e(TAG,
+                        String.format(
+                                "Could not write %s on device %s at offset %d",
+                                BluetoothGattUtils.toString(characteristic),
+                                device,
+                                offset),
+                        e);
+                bluetoothGattServer.sendResponse(device, requestId, e.getGattErrorCode(), offset, null);
+            }
+        }
+
+        @Override
+        public void onDescriptorReadRequest(BluetoothDevice device, int requestId, int offset,
+                BluetoothGattDescriptor descriptor) {
+            BluetoothGattServer bluetoothGattServer = mBluetoothGattServer;
+            if (bluetoothGattServer == null) {
+                return;
+            }
+            try {
+                byte[] value = getConnectionByDevice(device).readDescriptor(offset, descriptor);
+                bluetoothGattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset,
+                        value);
+            } catch (BluetoothGattException e) {
+                Log.e(TAG, String.format(
+                                "Could not read %s on device %s at %d",
+                                BluetoothGattUtils.toString(descriptor),
+                                device,
+                                offset),
+                        e);
+                bluetoothGattServer.sendResponse(device, requestId, e.getGattErrorCode(), offset, null);
+            }
+        }
+
+        @Override
+        public void onDescriptorWriteRequest(BluetoothDevice device,
+                int requestId,
+                BluetoothGattDescriptor descriptor,
+                boolean preparedWrite,
+                boolean responseNeeded,
+                int offset,
+                byte[] value) {
+            BluetoothGattServer bluetoothGattServer = mBluetoothGattServer;
+            if (bluetoothGattServer == null) {
+                return;
+            }
+            try {
+                getConnectionByDevice(device).writeDescriptor(descriptor, preparedWrite, offset, value);
+                if (responseNeeded) {
+                    bluetoothGattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset,
+                            null);
+                }
+                Log.d(TAG, "Operation onDescriptorWriteRequest successful.");
+            } catch (BluetoothGattException e) {
+                Log.e(TAG,
+                        String.format(
+                                "Could not write %s on device %s at %d",
+                                BluetoothGattUtils.toString(descriptor),
+                                device,
+                                offset),
+                        e);
+                bluetoothGattServer.sendResponse(device, requestId, e.getGattErrorCode(), offset, null);
+            }
+        }
+
+        @Override
+        public void onExecuteWrite(BluetoothDevice device, int requestId, boolean execute) {
+            BluetoothGattServer bluetoothGattServer = mBluetoothGattServer;
+            if (bluetoothGattServer == null) {
+                return;
+            }
+            try {
+                getConnectionByDevice(device).executeWrite(execute);
+                bluetoothGattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null);
+            } catch (BluetoothGattException e) {
+                Log.e(TAG, "Could not execute write.", e);
+                bluetoothGattServer.sendResponse(device, requestId, e.getGattErrorCode(), 0, null);
+            }
+        }
+
+        @Override
+        public void onNotificationSent(BluetoothDevice device, int status) {
+            Log.d(TAG, String.format("Received onNotificationSent for device %s with status %s", device,
+                    status));
+            try {
+                getConnectionByDevice(device).notifyNotificationSent(status);
+            } catch (BluetoothGattException e) {
+                Log.e(TAG, String.format("An error occurred when receiving onNotificationSent"), e);
+            }
+        }
+    }
+
+    /** Listener for {@link BluetoothGattServerHelper}'s events. */
+    public interface Listener {
+        /** Called when a new connection to the server is established. */
+        void onConnection(BluetoothGattServerConnection connection);
+    }
+}
diff --git a/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/gatt/server/BluetoothGattServlet.java b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/gatt/server/BluetoothGattServlet.java
new file mode 100644
index 0000000..4c8499c
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/gatt/server/BluetoothGattServlet.java
@@ -0,0 +1,54 @@
+package com.android.server.nearby.common.bluetooth.gatt.server;
+
+import android.annotation.TargetApi;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import com.android.server.nearby.common.bluetooth.BluetoothGattException;
+import com.android.server.nearby.common.bluetooth.gatt.server.BluetoothGattServerConnection.Notifier;
+
+/** Servlet to handle GATT operations on a characteristic. */
+@TargetApi(18)
+public abstract class BluetoothGattServlet {
+    public byte[] read(BluetoothGattServerConnection connection,
+            @SuppressWarnings("unused") int offset) throws BluetoothGattException {
+        throw new BluetoothGattException("Read not supported.",
+                BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED);
+    }
+
+    public void write(BluetoothGattServerConnection connection,
+            @SuppressWarnings("unused") int offset, @SuppressWarnings("unused") byte[] value)
+            throws BluetoothGattException {
+        throw new BluetoothGattException("Write not supported.",
+                BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED);
+    }
+
+    public byte[] readDescriptor(BluetoothGattServerConnection connection,
+            BluetoothGattDescriptor descriptor, @SuppressWarnings("unused") int offset)
+            throws BluetoothGattException {
+        throw new BluetoothGattException("Read not supported.",
+                BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED);
+    }
+
+    public void writeDescriptor(BluetoothGattServerConnection connection,
+            BluetoothGattDescriptor descriptor,
+            @SuppressWarnings("unused") int offset, @SuppressWarnings("unused") byte[] value)
+            throws BluetoothGattException {
+        throw new BluetoothGattException("Write not supported.",
+                BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED);
+    }
+
+    public void enableNotification(BluetoothGattServerConnection connection, Notifier notifier)
+            throws BluetoothGattException {
+        throw new BluetoothGattException("Notification not supported.",
+                BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED);
+    }
+
+    public void disableNotification(BluetoothGattServerConnection connection, Notifier notifier)
+            throws BluetoothGattException {
+        throw new BluetoothGattException("Notification not supported.",
+                BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED);
+    }
+
+    public abstract BluetoothGattCharacteristic getCharacteristic();
+}
diff --git a/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/BluetoothManager.java b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/BluetoothManager.java
new file mode 100644
index 0000000..9f47426
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/testability/android/bluetooth/BluetoothManager.java
@@ -0,0 +1,70 @@
+/*
+ * 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.testability.android.bluetooth;
+
+import android.content.Context;
+import java.util.ArrayList;
+import java.util.List;
+import javax.annotation.Nullable;
+
+/**
+ * Mockable wrapper of {@link android.bluetooth.BluetoothManager}.
+ */
+public class BluetoothManager {
+
+    private android.bluetooth.BluetoothManager mWrappedInstance;
+
+    private BluetoothManager(android.bluetooth.BluetoothManager instance) {
+        mWrappedInstance = instance;
+    }
+
+    /**
+     * See {@link android.bluetooth.BluetoothManager#openGattServer(Context,
+     * android.bluetooth.BluetoothGattServerCallback)}.
+     */
+    @Nullable
+    public BluetoothGattServer openGattServer(Context context, BluetoothGattServerCallback callback) {
+        return BluetoothGattServer.wrap(mWrappedInstance.openGattServer(context, callback.unwrap()));
+    }
+
+    /**
+     * See {@link android.bluetooth.BluetoothManager#getConnectionState(
+     * android.bluetooth.BluetoothDevice, int)}.
+     */
+    public int getConnectionState(BluetoothDevice device, int profile) {
+        return mWrappedInstance.getConnectionState(device.unwrap(), profile);
+    }
+
+    /** See {@link android.bluetooth.BluetoothManager#getConnectedDevices(int)}. */
+    public List<BluetoothDevice> getConnectedDevices(int profile) {
+        List<android.bluetooth.BluetoothDevice> devices = mWrappedInstance.getConnectedDevices(profile);
+        List<BluetoothDevice> wrappedDevices = new ArrayList<>(devices.size());
+        for (android.bluetooth.BluetoothDevice device : devices) {
+            wrappedDevices.add(BluetoothDevice.wrap(device));
+        }
+        return wrappedDevices;
+    }
+
+    /** See {@link android.bluetooth.BluetoothManager#getAdapter()}. */
+    public BluetoothAdapter getAdapter() {
+        return BluetoothAdapter.wrap(mWrappedInstance.getAdapter());
+    }
+
+    public static BluetoothManager wrap(android.bluetooth.BluetoothManager bluetoothManager) {
+        return new BluetoothManager(bluetoothManager);
+    }
+}
\ No newline at end of file
diff --git a/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/util/BluetoothGattUtils.java b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/util/BluetoothGattUtils.java
new file mode 100644
index 0000000..b8e1b08
--- /dev/null
+++ b/nearby/tests/multidevices/clients/src/com/android/server/nearby/common/bluetooth/util/BluetoothGattUtils.java
@@ -0,0 +1,123 @@
+/*
+ * 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.util;
+
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothGattService;
+import com.android.server.nearby.common.bluetooth.BluetoothException;
+import java.lang.reflect.Field;
+import java.util.Arrays;
+
+import javax.annotation.Nullable;
+
+/**
+ * Utils for Gatt profile.
+ */
+public class BluetoothGattUtils {
+
+    /**
+     * Returns a string message for a BluetoothGatt status codes.
+     */
+    public static String getMessageForStatusCode(int statusCode) {
+        switch (statusCode) {
+            case BluetoothGatt.GATT_SUCCESS:
+                return "GATT_SUCCESS";
+            case BluetoothGatt.GATT_FAILURE:
+                return "GATT_FAILURE";
+            case BluetoothGatt.GATT_INSUFFICIENT_AUTHENTICATION:
+                return "GATT_INSUFFICIENT_AUTHENTICATION";
+            case BluetoothGatt.GATT_INSUFFICIENT_AUTHORIZATION:
+                return "GATT_INSUFFICIENT_AUTHORIZATION";
+            case BluetoothGatt.GATT_INSUFFICIENT_ENCRYPTION:
+                return "GATT_INSUFFICIENT_ENCRYPTION";
+            case BluetoothGatt.GATT_INVALID_ATTRIBUTE_LENGTH:
+                return "GATT_INVALID_ATTRIBUTE_LENGTH";
+            case BluetoothGatt.GATT_INVALID_OFFSET:
+                return "GATT_INVALID_OFFSET";
+            case BluetoothGatt.GATT_READ_NOT_PERMITTED:
+                return "GATT_READ_NOT_PERMITTED";
+            case BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED:
+                return "GATT_REQUEST_NOT_SUPPORTED";
+            case BluetoothGatt.GATT_WRITE_NOT_PERMITTED:
+                return "GATT_WRITE_NOT_PERMITTED";
+            case BluetoothGatt.GATT_CONNECTION_CONGESTED:
+                return "GATT_CONNECTION_CONGESTED";
+            default:
+                return "Unknown error code";
+        }
+    }
+
+    /** Clones a {@link BluetoothGattCharacteristic} so the value can be changed thread-safely. */
+    public static BluetoothGattCharacteristic clone(BluetoothGattCharacteristic characteristic)
+            throws BluetoothException {
+        BluetoothGattCharacteristic result = new BluetoothGattCharacteristic(characteristic.getUuid(),
+                characteristic.getProperties(), characteristic.getPermissions());
+        try {
+            Field instanceIdField = BluetoothGattCharacteristic.class.getDeclaredField("mInstance");
+            Field serviceField = BluetoothGattCharacteristic.class.getDeclaredField("mService");
+            Field descriptorField = BluetoothGattCharacteristic.class.getDeclaredField("mDescriptors");
+            instanceIdField.setAccessible(true);
+            serviceField.setAccessible(true);
+            descriptorField.setAccessible(true);
+            instanceIdField.set(result, instanceIdField.get(characteristic));
+            serviceField.set(result, serviceField.get(characteristic));
+            descriptorField.set(result, descriptorField.get(characteristic));
+            byte[] value = characteristic.getValue();
+            if (value != null) {
+                result.setValue(Arrays.copyOf(value, value.length));
+            }
+            result.setWriteType(characteristic.getWriteType());
+        } catch (NoSuchFieldException e) {
+            throw new BluetoothException("Cannot clone characteristic.", e);
+        } catch (IllegalAccessException e) {
+            throw new BluetoothException("Cannot clone characteristic.", e);
+        } catch (IllegalArgumentException e) {
+            throw new BluetoothException("Cannot clone characteristic.", e);
+        }
+        return result;
+    }
+
+    /** Creates a user-readable string from a {@link BluetoothGattDescriptor}. */
+    public static String toString(@Nullable BluetoothGattDescriptor descriptor) {
+        if (descriptor == null) {
+            return "null descriptor";
+        }
+        return String.format("descriptor %s on %s",
+                descriptor.getUuid(),
+                toString(descriptor.getCharacteristic()));
+    }
+
+    /** Creates a user-readable string from a {@link BluetoothGattCharacteristic}. */
+    public static String toString(@Nullable BluetoothGattCharacteristic characteristic) {
+        if (characteristic == null) {
+            return "null characteristic";
+        }
+        return String.format("characteristic %s on %s",
+                characteristic.getUuid(),
+                toString(characteristic.getService()));
+    }
+
+    /** Creates a user-readable string from a {@link BluetoothGattService}. */
+    public static String toString(@Nullable BluetoothGattService service) {
+        if (service == null) {
+            return "null service";
+        }
+        return String.format("service %s", service.getUuid());
+    }
+}
diff --git a/nearby/tests/multidevices/clients/tests/Android.bp b/nearby/tests/multidevices/clients/tests/Android.bp
new file mode 100644
index 0000000..a29a298
--- /dev/null
+++ b/nearby/tests/multidevices/clients/tests/Android.bp
@@ -0,0 +1,39 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+// Run the tests: atest --host NearbyMultiDevicesClientsRoboTest
+android_robolectric_test {
+    name: "NearbyMultiDevicesClientsRoboTest",
+    srcs: ["src/**/*.kt"],
+    instrumentation_for: "NearbyMultiDevicesClientsSnippets",
+    java_resources: ["robolectric.properties"],
+
+    static_libs: [
+        "NearbyMultiDevicesClientsLib",
+        "androidx.test.ext.junit",
+        "androidx.test.rules",
+        "junit",
+        "mobly-snippet-lib",
+        "platform-test-annotations",
+        "truth-prebuilt",
+    ],
+    test_options: {
+        // timeout in seconds.
+        timeout: 36000,
+    },
+}
\ No newline at end of file
diff --git a/nearby/tests/multidevices/clients/tests/AndroidManifest.xml b/nearby/tests/multidevices/clients/tests/AndroidManifest.xml
new file mode 100644
index 0000000..c8e17e8
--- /dev/null
+++ b/nearby/tests/multidevices/clients/tests/AndroidManifest.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2022 The Android Open Source Project
+  ~
+  ~ Licensed under the Apache License, Version 2.0 (the "License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~      http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+          package="com.android.nearby.multidevices"/>
diff --git a/nearby/tests/multidevices/clients/tests/robolectric.properties b/nearby/tests/multidevices/clients/tests/robolectric.properties
new file mode 100644
index 0000000..2ea03bb
--- /dev/null
+++ b/nearby/tests/multidevices/clients/tests/robolectric.properties
@@ -0,0 +1,16 @@
+#
+# Copyright (C) 2022 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/multidevices/clients/tests/src/com/android/nearby/multidevices/common/Mockotlin.kt b/nearby/tests/multidevices/clients/tests/src/com/android/nearby/multidevices/common/Mockotlin.kt
new file mode 100644
index 0000000..8e70d9f
--- /dev/null
+++ b/nearby/tests/multidevices/clients/tests/src/com/android/nearby/multidevices/common/Mockotlin.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.nearby.multidevices.common
+
+import org.mockito.ArgumentCaptor
+import org.mockito.Mockito
+import org.mockito.MockitoAnnotations
+
+/**
+ * Helper methods to wrap common Mockito functions that don't do quite what you would expect in
+ * Kotlin. The returned null values need to be recast to their original type in Kotlin otherwise it
+ * breaks.
+ */
+object Mockotlin {
+
+    /**
+     * Delegates to [Mockito.any].
+     * @return null as T
+     */
+    fun <T> any() = Mockito.any<T>() as T
+
+    /**
+     * Delegates to [Mockito.eq].
+     * @return null as T
+     */
+    fun <T> eq(match: T) = Mockito.eq(match) as T
+
+    /**
+     * Delegates to [Mockito.isA].
+     * @return null as T
+     */
+    fun <T> isA(match: Class<T>): T = Mockito.isA(match) as T
+
+    /** Delegates to [Mockito.when ], uses the same API as the mockitokotlin2 library. */
+    fun <T> whenever(methodCall: T) = Mockito.`when`(methodCall)!!
+
+    /**
+     * Delegates to [Mockito.any] and calls it with Class<T>.
+     * @return Class<T>
+     */
+    inline fun <reified T> anyClass(): Class<T> {
+        Mockito.any(T::class.java)
+        return T::class.java
+    }
+
+    /**
+     * Delegates to [Mockito.anyListOf] and calls it with Class<T>.
+     * @return List<T>
+     */
+    fun <T> anyListOf(): List<T> = Mockito.anyList<T>()
+
+    /**
+     * Delegates to [Mockito.mock].
+     * @return T
+     */
+    inline fun <reified T> mock() = Mockito.mock(T::class.java)!!
+
+    /** This is the same as calling `MockitoAnnotations.initMocks(this)` */
+    fun Any.initMocks() {
+        MockitoAnnotations.initMocks(this)
+    }
+
+    /**
+     * Returns ArgumentCaptor.capture() as nullable type to avoid java.lang.IllegalStateException
+     * when null is returned.
+     */
+    fun <T> capture(argumentCaptor: ArgumentCaptor<T>): T = argumentCaptor.capture()
+}
diff --git a/nearby/tests/multidevices/clients/tests/src/com/android/nearby/multidevices/common/SnippetEventHelperTest.kt b/nearby/tests/multidevices/clients/tests/src/com/android/nearby/multidevices/common/SnippetEventHelperTest.kt
new file mode 100644
index 0000000..1fbd352
--- /dev/null
+++ b/nearby/tests/multidevices/clients/tests/src/com/android/nearby/multidevices/common/SnippetEventHelperTest.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.nearby.multidevices.common
+
+import android.nearby.multidevices.common.postSnippetEvent
+import androidx.test.platform.app.InstrumentationRegistry
+import com.google.android.mobly.snippet.event.EventSnippet
+import com.google.android.mobly.snippet.util.Log
+import com.google.common.truth.Truth.assertThat
+import org.json.JSONObject
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+/** Robolectric tests for SnippetEventHelper.kt. */
+@RunWith(RobolectricTestRunner::class)
+class SnippetEventHelperTest {
+
+  @Test
+  fun testPostSnippetEvent_withDataBundle_writesEventCache() {
+    val testCallbackId = "test_1234"
+    val testEventName = "onTestEvent"
+    val testBundleDataStrKey = "testStrKey"
+    val testBundleDataStrValue = "testStrValue"
+    val testBundleDataIntKey = "testIntKey"
+    val testBundleDataIntValue = 777
+    val eventSnippet = EventSnippet()
+    Log.initLogTag(InstrumentationRegistry.getInstrumentation().context)
+
+    postSnippetEvent(testCallbackId, testEventName) {
+      putString(testBundleDataStrKey, testBundleDataStrValue)
+      putInt(testBundleDataIntKey, testBundleDataIntValue)
+    }
+
+    val event = eventSnippet.eventWaitAndGet(testCallbackId, testEventName, null)
+    assertThat(event.getJSONObject("data").toString())
+      .isEqualTo(
+        JSONObject()
+          .put(testBundleDataIntKey, testBundleDataIntValue)
+          .put(testBundleDataStrKey, testBundleDataStrValue)
+          .toString()
+      )
+  }
+}
diff --git a/nearby/tests/multidevices/clients/tests/src/com/android/nearby/multidevices/fastpair/provider/BluetoothStateChangeReceiverTest.kt b/nearby/tests/multidevices/clients/tests/src/com/android/nearby/multidevices/fastpair/provider/BluetoothStateChangeReceiverTest.kt
new file mode 100644
index 0000000..f23ccbb
--- /dev/null
+++ b/nearby/tests/multidevices/clients/tests/src/com/android/nearby/multidevices/fastpair/provider/BluetoothStateChangeReceiverTest.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.nearby.multidevices.fastpair.provider
+
+import android.Manifest
+import android.bluetooth.BluetoothAdapter
+import android.content.Context
+import android.content.Intent
+import android.nearby.multidevices.fastpair.provider.BluetoothStateChangeReceiver
+import androidx.annotation.RequiresPermission
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.google.android.mobly.snippet.util.Log
+import com.google.common.truth.Truth.assertThat
+import com.android.nearby.multidevices.common.Mockotlin.mock
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.verify
+
+/** Robolectric tests for [BluetoothStateChangeReceiver]. */
+@RunWith(AndroidJUnit4::class)
+class BluetoothStateChangeReceiverTest {
+  private lateinit var bluetoothStateChangeReceiver: BluetoothStateChangeReceiver
+  private lateinit var context: Context
+  private val mockListener = mock<BluetoothStateChangeReceiver.EventListener>()
+
+  @Before
+  fun setUp() {
+    context = InstrumentationRegistry.getInstrumentation().context
+    bluetoothStateChangeReceiver = BluetoothStateChangeReceiver(context)
+    Log.apkLogTag = "BluetoothStateChangeReceiverTest"
+  }
+
+  @Test
+  fun testRegister_setsListener() {
+    bluetoothStateChangeReceiver.register(mockListener)
+
+    assertThat(bluetoothStateChangeReceiver.listener).isNotNull()
+  }
+
+  @Test
+  fun testUnregister_clearListener() {
+    bluetoothStateChangeReceiver.register(mockListener)
+
+    bluetoothStateChangeReceiver.unregister()
+
+    assertThat(bluetoothStateChangeReceiver.listener).isNull()
+  }
+
+  @Test
+  @RequiresPermission(Manifest.permission.BLUETOOTH)
+  fun testOnReceive_actionScanModeChanged_reportsOnScanModeChange() {
+    val intent =
+      Intent(BluetoothAdapter.ACTION_SCAN_MODE_CHANGED)
+        .putExtra(
+          BluetoothAdapter.EXTRA_SCAN_MODE,
+          BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE
+        )
+    bluetoothStateChangeReceiver.register(mockListener)
+
+    bluetoothStateChangeReceiver.onReceive(context, intent)
+
+    verify(mockListener).onScanModeChange("DISCOVERABLE")
+  }
+}
diff --git a/nearby/tests/multidevices/clients/tests/src/com/android/nearby/multidevices/fastpair/seeker/CompanionAppUtilsTest.kt b/nearby/tests/multidevices/clients/tests/src/com/android/nearby/multidevices/fastpair/seeker/CompanionAppUtilsTest.kt
new file mode 100644
index 0000000..94c0952
--- /dev/null
+++ b/nearby/tests/multidevices/clients/tests/src/com/android/nearby/multidevices/fastpair/seeker/CompanionAppUtilsTest.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.nearby.multidevices.fastpair.seeker
+
+import android.nearby.multidevices.fastpair.seeker.generateCompanionAppLaunchIntentUri
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+/** Robolectric tests for CompanionAppUtils.kt. */
+@RunWith(RobolectricTestRunner::class)
+class CompanionAppUtilsTest {
+
+    @Test
+    fun testGenerateCompanionAppLaunchIntentUri_defaultNullPackage_returnsEmptyString() {
+        assertThat(generateCompanionAppLaunchIntentUri()).isEmpty()
+    }
+
+    @Test
+    fun testGenerateCompanionAppLaunchIntentUri_emptyPackageName_returnsEmptyString() {
+        assertThat(generateCompanionAppLaunchIntentUri(companionAppPackageName = "")).isEmpty()
+    }
+
+    @Test
+    fun testGenerateCompanionAppLaunchIntentUri_emptyActivityName_returnsEmptyString() {
+        val uriString = generateCompanionAppLaunchIntentUri(
+                companionAppPackageName = COMPANION_APP_PACKAGE_TEST_CONSTANT, activityName = "")
+
+        assertThat(uriString).isEmpty()
+    }
+
+    @Test
+    fun testGenerateCompanionAppLaunchIntentUri_emptyAction_returnsNoActionUriString() {
+        val uriString = generateCompanionAppLaunchIntentUri(
+                companionAppPackageName = COMPANION_APP_PACKAGE_TEST_CONSTANT,
+                activityName = COMPANION_APP_ACTIVITY_TEST_CONSTANT,
+                action = "")
+
+        assertThat(uriString).doesNotContain("action=")
+        assertThat(uriString).contains("package=$COMPANION_APP_PACKAGE_TEST_CONSTANT")
+        assertThat(uriString).contains(COMPANION_APP_ACTIVITY_TEST_CONSTANT)
+    }
+
+    @Test
+    fun testGenerateCompanionAppLaunchIntentUri_nonNullArgs_returnsUriString() {
+        val uriString = generateCompanionAppLaunchIntentUri(
+                companionAppPackageName = COMPANION_APP_PACKAGE_TEST_CONSTANT,
+                activityName = COMPANION_APP_ACTIVITY_TEST_CONSTANT,
+                action = COMPANION_APP_ACTION_TEST_CONSTANT)
+
+        assertThat(uriString).isEqualTo("intent:#Intent;" +
+                "action=android.nearby.SHOW_WELCOME;" +
+                "package=android.nearby.companion;" +
+                "component=android.nearby.companion/android.nearby.companion.MainActivity;" +
+                "end")
+    }
+
+    companion object {
+        private const val COMPANION_APP_PACKAGE_TEST_CONSTANT = "android.nearby.companion"
+        private const val COMPANION_APP_ACTIVITY_TEST_CONSTANT =
+                "android.nearby.companion.MainActivity"
+        private const val COMPANION_APP_ACTION_TEST_CONSTANT = "android.nearby.SHOW_WELCOME"
+    }
+}